From 8874aa553a308e63868a7ca7e7be19d02c8b1990 Mon Sep 17 00:00:00 2001 From: Zhang Sheng Date: Tue, 31 Mar 2026 15:27:33 +0800 Subject: [PATCH 01/21] chore: init trellis as title Log: --- .agents/skills/before-backend-dev/SKILL.md | 18 + .agents/skills/before-frontend-dev/SKILL.md | 18 + .agents/skills/brainstorm/SKILL.md | 492 ++++++ .agents/skills/break-loop/SKILL.md | 130 ++ .agents/skills/check-backend/SKILL.md | 18 + .agents/skills/check-cross-layer/SKILL.md | 158 ++ .agents/skills/check-frontend/SKILL.md | 18 + .agents/skills/create-command/SKILL.md | 101 ++ .agents/skills/finish-work/SKILL.md | 148 ++ .agents/skills/integrate-skill/SKILL.md | 221 +++ .agents/skills/onboard/SKILL.md | 363 +++++ .agents/skills/record-session/SKILL.md | 66 + .agents/skills/start/SKILL.md | 346 +++++ .agents/skills/update-spec/SKILL.md | 335 ++++ .claude/agents/check.md | 122 ++ .claude/agents/debug.md | 106 ++ .claude/agents/dispatch.md | 214 +++ .claude/agents/implement.md | 96 ++ .claude/agents/plan.md | 396 +++++ .claude/agents/research.md | 120 ++ .../commands/trellis/before-backend-dev.md | 13 + .../commands/trellis/before-frontend-dev.md | 13 + .claude/commands/trellis/brainstorm.md | 487 ++++++ .claude/commands/trellis/break-loop.md | 125 ++ .claude/commands/trellis/check-backend.md | 13 + .claude/commands/trellis/check-cross-layer.md | 153 ++ .claude/commands/trellis/check-frontend.md | 13 + .claude/commands/trellis/create-command.md | 154 ++ .claude/commands/trellis/finish-work.md | 153 ++ .claude/commands/trellis/integrate-skill.md | 219 +++ .claude/commands/trellis/onboard.md | 358 +++++ .claude/commands/trellis/parallel.md | 193 +++ .claude/commands/trellis/record-session.md | 61 + .claude/commands/trellis/start.md | 389 +++++ .claude/commands/trellis/update-spec.md | 354 +++++ .claude/hooks/inject-subagent-context.py | 788 ++++++++++ .claude/hooks/ralph-loop.py | 388 +++++ .claude/hooks/session-start.py | 205 +++ .claude/settings.json | 71 + .trellis/.gitignore | 29 + .trellis/.template-hashes.json | 68 + .trellis/.version | 1 + .trellis/config.yaml | 33 + .trellis/scripts/__init__.py | 5 + .trellis/scripts/add_session.py | 423 +++++ .trellis/scripts/common/__init__.py | 82 + .trellis/scripts/common/cli_adapter.py | 628 ++++++++ .trellis/scripts/common/config.py | 72 + .trellis/scripts/common/developer.py | 190 +++ .trellis/scripts/common/git_context.py | 641 ++++++++ .trellis/scripts/common/paths.py | 347 +++++ .trellis/scripts/common/phase.py | 253 +++ .trellis/scripts/common/registry.py | 366 +++++ .trellis/scripts/common/task_queue.py | 259 ++++ .trellis/scripts/common/task_utils.py | 178 +++ .trellis/scripts/common/worktree.py | 305 ++++ .trellis/scripts/create_bootstrap.py | 293 ++++ .trellis/scripts/get_context.py | 16 + .trellis/scripts/get_developer.py | 26 + .trellis/scripts/init_developer.py | 51 + .trellis/scripts/multi_agent/__init__.py | 5 + .trellis/scripts/multi_agent/cleanup.py | 403 +++++ .trellis/scripts/multi_agent/create_pr.py | 329 ++++ .trellis/scripts/multi_agent/plan.py | 236 +++ .trellis/scripts/multi_agent/start.py | 465 ++++++ .trellis/scripts/multi_agent/status.py | 817 ++++++++++ .trellis/scripts/task.py | 1370 +++++++++++++++++ .trellis/spec/README.md | 111 ++ .../spec/backend/architecture-decisions.md | 195 +++ .trellis/spec/backend/architecture-design.md | 293 ++++ .trellis/spec/backend/bug-fix-standards.md | 223 +++ .trellis/spec/backend/cmake-conventions.md | 289 ++++ .trellis/spec/backend/index.md | 89 ++ .trellis/spec/backend/plugin-architecture.md | 293 ++++ .trellis/spec/backend/qt-dtk-guide.md | 507 ++++++ .../backend/reference/dbus-service-usage.md | 313 ++++ .../backend/reference/dtk-widgets-guide.md | 202 +++ .../backend/reference/gvfs-gio-integration.md | 295 ++++ .../backend/reference/polkit-auth-workflow.md | 304 ++++ .trellis/spec/backend/test-standards.md | 379 +++++ .../spec/big-question/dbus-async-vs-sync.md | 261 ++++ .../big-question/gthread-ui-thread-safety.md | 232 +++ .../big-question/gvfs-mount-path-issues.md | 223 +++ .trellis/spec/big-question/index.md | 49 + .../qt-memory-management-pitfalls.md | 204 +++ .trellis/spec/dfm-burn/error-handling.md | 203 +++ .trellis/spec/dfm-burn/index.md | 40 + .trellis/spec/dfm-burn/naming-conventions.md | 171 ++ .trellis/spec/dfm-io/error-handling.md | 228 +++ .trellis/spec/dfm-io/index.md | 41 + .trellis/spec/dfm-io/naming-conventions.md | 198 +++ .trellis/spec/dfm-mount/error-handling.md | 259 ++++ .trellis/spec/dfm-mount/index.md | 44 + .trellis/spec/dfm-mount/naming-conventions.md | 192 +++ .trellis/spec/dfm-search/error-handling.md | 271 ++++ .trellis/spec/dfm-search/index.md | 42 + .../spec/dfm-search/naming-conventions.md | 247 +++ .trellis/spec/guides/index.md | 73 + .trellis/spec/guides/root-cause-analysis.md | 186 +++ .trellis/spec/review/code-review-standards.md | 373 +++++ .trellis/spec/review/index.md | 112 ++ .../reference/architecture-review-guide.md | 472 ++++++ .trellis/spec/review/reference/c.md | 285 ++++ .../reference/code-review-best-practices.md | 136 ++ .../review/reference/common-bugs-checklist.md | 1227 +++++++++++++++ .trellis/spec/review/reference/cpp.md | 385 +++++ .../reference/performance-review-guide.md | 752 +++++++++ .trellis/spec/review/reference/qt.md | 186 +++ .../review/reference/security-review-guide.md | 265 ++++ .trellis/spec/shared/cpp-conventions.md | 605 ++++++++ .trellis/spec/shared/dbus-conventions.md | 437 ++++++ .trellis/spec/shared/deepin-terminology.md | 215 +++ .trellis/spec/shared/git-conventions.md | 272 ++++ .trellis/spec/shared/index.md | 59 + .trellis/spec/shared/internationalization.md | 327 ++++ .trellis/tasks/00-bootstrap-guidelines/prd.md | 127 ++ .../tasks/00-bootstrap-guidelines/task.json | 35 + .trellis/workflow.md | 416 +++++ .trellis/workspace/Zhang Sheng/index.md | 40 + .trellis/workspace/Zhang Sheng/journal-1.md | 7 + .trellis/workspace/index.md | 123 ++ .trellis/worktree.yaml | 47 + AGENTS.md | 18 + 123 files changed, 29105 insertions(+) create mode 100644 .agents/skills/before-backend-dev/SKILL.md create mode 100644 .agents/skills/before-frontend-dev/SKILL.md create mode 100644 .agents/skills/brainstorm/SKILL.md create mode 100644 .agents/skills/break-loop/SKILL.md create mode 100644 .agents/skills/check-backend/SKILL.md create mode 100644 .agents/skills/check-cross-layer/SKILL.md create mode 100644 .agents/skills/check-frontend/SKILL.md create mode 100644 .agents/skills/create-command/SKILL.md create mode 100644 .agents/skills/finish-work/SKILL.md create mode 100644 .agents/skills/integrate-skill/SKILL.md create mode 100644 .agents/skills/onboard/SKILL.md create mode 100644 .agents/skills/record-session/SKILL.md create mode 100644 .agents/skills/start/SKILL.md create mode 100644 .agents/skills/update-spec/SKILL.md create mode 100644 .claude/agents/check.md create mode 100644 .claude/agents/debug.md create mode 100644 .claude/agents/dispatch.md create mode 100644 .claude/agents/implement.md create mode 100644 .claude/agents/plan.md create mode 100644 .claude/agents/research.md create mode 100644 .claude/commands/trellis/before-backend-dev.md create mode 100644 .claude/commands/trellis/before-frontend-dev.md create mode 100644 .claude/commands/trellis/brainstorm.md create mode 100644 .claude/commands/trellis/break-loop.md create mode 100644 .claude/commands/trellis/check-backend.md create mode 100644 .claude/commands/trellis/check-cross-layer.md create mode 100644 .claude/commands/trellis/check-frontend.md create mode 100644 .claude/commands/trellis/create-command.md create mode 100644 .claude/commands/trellis/finish-work.md create mode 100644 .claude/commands/trellis/integrate-skill.md create mode 100644 .claude/commands/trellis/onboard.md create mode 100644 .claude/commands/trellis/parallel.md create mode 100644 .claude/commands/trellis/record-session.md create mode 100644 .claude/commands/trellis/start.md create mode 100644 .claude/commands/trellis/update-spec.md create mode 100644 .claude/hooks/inject-subagent-context.py create mode 100644 .claude/hooks/ralph-loop.py create mode 100644 .claude/hooks/session-start.py create mode 100644 .claude/settings.json create mode 100644 .trellis/.gitignore create mode 100644 .trellis/.template-hashes.json create mode 100644 .trellis/.version create mode 100644 .trellis/config.yaml create mode 100755 .trellis/scripts/__init__.py create mode 100755 .trellis/scripts/add_session.py create mode 100755 .trellis/scripts/common/__init__.py create mode 100755 .trellis/scripts/common/cli_adapter.py create mode 100755 .trellis/scripts/common/config.py create mode 100755 .trellis/scripts/common/developer.py create mode 100755 .trellis/scripts/common/git_context.py create mode 100755 .trellis/scripts/common/paths.py create mode 100755 .trellis/scripts/common/phase.py create mode 100755 .trellis/scripts/common/registry.py create mode 100755 .trellis/scripts/common/task_queue.py create mode 100755 .trellis/scripts/common/task_utils.py create mode 100755 .trellis/scripts/common/worktree.py create mode 100755 .trellis/scripts/create_bootstrap.py create mode 100755 .trellis/scripts/get_context.py create mode 100755 .trellis/scripts/get_developer.py create mode 100755 .trellis/scripts/init_developer.py create mode 100755 .trellis/scripts/multi_agent/__init__.py create mode 100755 .trellis/scripts/multi_agent/cleanup.py create mode 100755 .trellis/scripts/multi_agent/create_pr.py create mode 100755 .trellis/scripts/multi_agent/plan.py create mode 100755 .trellis/scripts/multi_agent/start.py create mode 100755 .trellis/scripts/multi_agent/status.py create mode 100755 .trellis/scripts/task.py create mode 100644 .trellis/spec/README.md create mode 100644 .trellis/spec/backend/architecture-decisions.md create mode 100644 .trellis/spec/backend/architecture-design.md create mode 100644 .trellis/spec/backend/bug-fix-standards.md create mode 100644 .trellis/spec/backend/cmake-conventions.md create mode 100644 .trellis/spec/backend/index.md create mode 100644 .trellis/spec/backend/plugin-architecture.md create mode 100644 .trellis/spec/backend/qt-dtk-guide.md create mode 100644 .trellis/spec/backend/reference/dbus-service-usage.md create mode 100644 .trellis/spec/backend/reference/dtk-widgets-guide.md create mode 100644 .trellis/spec/backend/reference/gvfs-gio-integration.md create mode 100644 .trellis/spec/backend/reference/polkit-auth-workflow.md create mode 100644 .trellis/spec/backend/test-standards.md create mode 100644 .trellis/spec/big-question/dbus-async-vs-sync.md create mode 100644 .trellis/spec/big-question/gthread-ui-thread-safety.md create mode 100644 .trellis/spec/big-question/gvfs-mount-path-issues.md create mode 100644 .trellis/spec/big-question/index.md create mode 100644 .trellis/spec/big-question/qt-memory-management-pitfalls.md create mode 100644 .trellis/spec/dfm-burn/error-handling.md create mode 100644 .trellis/spec/dfm-burn/index.md create mode 100644 .trellis/spec/dfm-burn/naming-conventions.md create mode 100644 .trellis/spec/dfm-io/error-handling.md create mode 100644 .trellis/spec/dfm-io/index.md create mode 100644 .trellis/spec/dfm-io/naming-conventions.md create mode 100644 .trellis/spec/dfm-mount/error-handling.md create mode 100644 .trellis/spec/dfm-mount/index.md create mode 100644 .trellis/spec/dfm-mount/naming-conventions.md create mode 100644 .trellis/spec/dfm-search/error-handling.md create mode 100644 .trellis/spec/dfm-search/index.md create mode 100644 .trellis/spec/dfm-search/naming-conventions.md create mode 100644 .trellis/spec/guides/index.md create mode 100644 .trellis/spec/guides/root-cause-analysis.md create mode 100644 .trellis/spec/review/code-review-standards.md create mode 100644 .trellis/spec/review/index.md create mode 100644 .trellis/spec/review/reference/architecture-review-guide.md create mode 100644 .trellis/spec/review/reference/c.md create mode 100644 .trellis/spec/review/reference/code-review-best-practices.md create mode 100644 .trellis/spec/review/reference/common-bugs-checklist.md create mode 100644 .trellis/spec/review/reference/cpp.md create mode 100644 .trellis/spec/review/reference/performance-review-guide.md create mode 100644 .trellis/spec/review/reference/qt.md create mode 100644 .trellis/spec/review/reference/security-review-guide.md create mode 100644 .trellis/spec/shared/cpp-conventions.md create mode 100644 .trellis/spec/shared/dbus-conventions.md create mode 100644 .trellis/spec/shared/deepin-terminology.md create mode 100644 .trellis/spec/shared/git-conventions.md create mode 100644 .trellis/spec/shared/index.md create mode 100644 .trellis/spec/shared/internationalization.md create mode 100644 .trellis/tasks/00-bootstrap-guidelines/prd.md create mode 100644 .trellis/tasks/00-bootstrap-guidelines/task.json create mode 100644 .trellis/workflow.md create mode 100644 .trellis/workspace/Zhang Sheng/index.md create mode 100644 .trellis/workspace/Zhang Sheng/journal-1.md create mode 100644 .trellis/workspace/index.md create mode 100644 .trellis/worktree.yaml create mode 100644 AGENTS.md diff --git a/.agents/skills/before-backend-dev/SKILL.md b/.agents/skills/before-backend-dev/SKILL.md new file mode 100644 index 00000000..0615694c --- /dev/null +++ b/.agents/skills/before-backend-dev/SKILL.md @@ -0,0 +1,18 @@ +--- +name: before-backend-dev +description: "Read the backend development guidelines before starting your development task." +--- + +Read the backend development guidelines before starting your development task. + +Execute these steps: +1. Read `.trellis/spec/backend/index.md` to understand available guidelines +2. Based on your task, read the relevant guideline files: + - Database work → `.trellis/spec/backend/database-guidelines.md` + - Error handling → `.trellis/spec/backend/error-handling.md` + - Logging → `.trellis/spec/backend/logging-guidelines.md` + - Type questions → `.trellis/spec/backend/type-safety.md` +3. Understand the coding standards and patterns you need to follow +4. Then proceed with your development plan + +This step is **mandatory** before writing any backend code. diff --git a/.agents/skills/before-frontend-dev/SKILL.md b/.agents/skills/before-frontend-dev/SKILL.md new file mode 100644 index 00000000..b048b8db --- /dev/null +++ b/.agents/skills/before-frontend-dev/SKILL.md @@ -0,0 +1,18 @@ +--- +name: before-frontend-dev +description: "Read the frontend development guidelines before starting your development task." +--- + +Read the frontend development guidelines before starting your development task. + +Execute these steps: +1. Read `.trellis/spec/frontend/index.md` to understand available guidelines +2. Based on your task, read the relevant guideline files: + - Component work → `.trellis/spec/frontend/component-guidelines.md` + - Hook work → `.trellis/spec/frontend/hook-guidelines.md` + - State management → `.trellis/spec/frontend/state-management.md` + - Type questions → `.trellis/spec/frontend/type-safety.md` +3. Understand the coding standards and patterns you need to follow +4. Then proceed with your development plan + +This step is **mandatory** before writing any frontend code. diff --git a/.agents/skills/brainstorm/SKILL.md b/.agents/skills/brainstorm/SKILL.md new file mode 100644 index 00000000..e26005dc --- /dev/null +++ b/.agents/skills/brainstorm/SKILL.md @@ -0,0 +1,492 @@ +--- +name: brainstorm +description: "Brainstorm - Requirements Discovery (AI Coding Enhanced)" +--- + +# Brainstorm - Requirements Discovery (AI Coding Enhanced) + +Guide AI through collaborative requirements discovery **before implementation**, optimized for AI coding workflows: + +* **Task-first** (capture ideas immediately) +* **Action-before-asking** (reduce low-value questions) +* **Research-first** for technical choices (avoid asking users to invent options) +* **Diverge → Converge** (expand thinking, then lock MVP) + +--- + +## When to Use + +Triggered from `$start` when the user describes a development task, especially when: + +* requirements are unclear or evolving +* there are multiple valid implementation paths +* trade-offs matter (UX, reliability, maintainability, cost, performance) +* the user might not know the best options up front + +--- + +## Core Principles (Non-negotiable) + +1. **Task-first (capture early)** + Always ensure a task exists at the start so the user's ideas are recorded immediately. + +2. **Action before asking** + If you can derive the answer from repo code, docs, configs, conventions, or quick research — do that first. + +3. **One question per message** + Never overwhelm the user with a list of questions. Ask one, update PRD, repeat. + +4. **Prefer concrete options** + For preference/decision questions, present 2–3 feasible, specific approaches with trade-offs. + +5. **Research-first for technical choices** + If the decision depends on industry conventions / similar tools / established patterns, do research first, then propose options. + +6. **Diverge → Converge** + After initial understanding, proactively consider future evolution, related scenarios, and failure/edge cases — then converge to an MVP with explicit out-of-scope. + +7. **No meta questions** + Do not ask "should I search?" or "can you paste the code so I can continue?" + If you need information: search/inspect. If blocked: ask the minimal blocking question. + +--- + +## Step 0: Ensure Task Exists (ALWAYS) + +Before any Q&A, ensure a task exists. If none exists, create one immediately. + +* Use a **temporary working title** derived from the user's message. +* It's OK if the title is imperfect — refine later in PRD. + +```bash +TASK_DIR=$(python3 ./.trellis/scripts/task.py create "brainstorm: " --slug ) +``` + +Create/seed `prd.md` immediately with what you know: + +```markdown +# brainstorm: + +## Goal + + + +## What I already know + +* +* + +## Assumptions (temporary) + +* + +## Open Questions + +* + +## Requirements (evolving) + +* + +## Acceptance Criteria (evolving) + +* [ ] + +## Definition of Done (team quality bar) + +* Tests added/updated (unit/integration where appropriate) +* Lint / typecheck / CI green +* Docs/notes updated if behavior changes +* Rollout/rollback considered if risky + +## Out of Scope (explicit) + +* + +## Technical Notes + +* +* +``` + +--- + +## Step 1: Auto-Context (DO THIS BEFORE ASKING QUESTIONS) + +Before asking questions like "what does the code look like?", gather context yourself: + +### Repo inspection checklist + +* Identify likely modules/files impacted +* Locate existing patterns (similar features, conventions, error handling style) +* Check configs, scripts, existing command definitions +* Note any constraints (runtime, dependency policy, build tooling) + +### Documentation checklist + +* Look for existing PRDs/specs/templates +* Look for command usage examples, README, ADRs if any + +Write findings into PRD: + +* Add to `What I already know` +* Add constraints/links to `Technical Notes` + +--- + +## Step 2: Classify Complexity (still useful, not gating task creation) + +| Complexity | Criteria | Action | +| ------------ | ------------------------------------------------------ | ------------------------------------------- | +| **Trivial** | Single-line fix, typo, obvious change | Skip brainstorm, implement directly | +| **Simple** | Clear goal, 1–2 files, scope well-defined | Ask 1 confirm question, then implement | +| **Moderate** | Multiple files, some ambiguity | Light brainstorm (2–3 high-value questions) | +| **Complex** | Vague goal, architectural choices, multiple approaches | Full brainstorm | + +> Note: Task already exists from Step 0. Classification only affects depth of brainstorming. + +--- + +## Step 3: Question Gate (Ask ONLY high-value questions) + +Before asking ANY question, run the following gate: + +### Gate A — Can I derive this without the user? + +If answer is available via: + +* repo inspection (code/config) +* docs/specs/conventions +* quick market/OSS research + +→ **Do not ask.** Fetch it, summarize, update PRD. + +### Gate B — Is this a meta/lazy question? + +Examples: + +* "Should I search?" +* "Can you paste the code so I can proceed?" +* "What does the code look like?" (when repo is available) + +→ **Do not ask.** Take action. + +### Gate C — What type of question is it? + +* **Blocking**: cannot proceed without user input +* **Preference**: multiple valid choices, depends on product/UX/risk preference +* **Derivable**: should be answered by inspection/research + +→ Only ask **Blocking** or **Preference**. + +--- + +## Step 4: Research-first Mode (Mandatory for technical choices) + +### Trigger conditions (any → research-first) + +* The task involves selecting an approach, library, protocol, framework, template system, plugin mechanism, or CLI UX convention +* The user asks for "best practice", "how others do it", "recommendation" +* The user can't reasonably enumerate options + +### Research steps + +1. Identify 2–4 comparable tools/patterns +2. Summarize common conventions and why they exist +3. Map conventions onto our repo constraints +4. Produce **2–3 feasible approaches** for our project + +### Research output format (PRD) + +Add a section in PRD (either within Technical Notes or as its own): + +```markdown +## Research Notes + +### What similar tools do + +* ... +* ... + +### Constraints from our repo/project + +* ... + +### Feasible approaches here + +**Approach A: ** (Recommended) + +* How it works: +* Pros: +* Cons: + +**Approach B: ** + +* How it works: +* Pros: +* Cons: + +**Approach C: ** (optional) + +* ... +``` + +Then ask **one** preference question: + +* "Which approach do you prefer: A / B / C (or other)?" + +--- + +## Step 5: Expansion Sweep (DIVERGE) — Required after initial understanding + +After you can summarize the goal, proactively broaden thinking before converging. + +### Expansion categories (keep to 1–2 bullets each) + +1. **Future evolution** + + * What might this feature become in 1–3 months? + * What extension points are worth preserving now? + +2. **Related scenarios** + + * What adjacent commands/flows should remain consistent with this? + * Are there parity expectations (create vs update, import vs export, etc.)? + +3. **Failure & edge cases** + + * Conflicts, offline/network failure, retries, idempotency, compatibility, rollback + * Input validation, security boundaries, permission checks + +### Expansion message template (to user) + +```markdown +I understand you want to implement: . + +Before diving into design, let me quickly diverge to consider three categories (to avoid rework later): + +1. Future evolution: <1–2 bullets> +2. Related scenarios: <1–2 bullets> +3. Failure/edge cases: <1–2 bullets> + +For this MVP, which would you like to include (or none)? + +1. Current requirement only (minimal viable) +2. Add (reserve for future extension) +3. Add (improve robustness/consistency) +4. Other: describe your preference +``` + +Then update PRD: + +* What's in MVP → `Requirements` +* What's excluded → `Out of Scope` + +--- + +## Step 6: Q&A Loop (CONVERGE) + +### Rules + +* One question per message +* Prefer multiple-choice when possible +* After each user answer: + + * Update PRD immediately + * Move answered items from `Open Questions` → `Requirements` + * Update `Acceptance Criteria` with testable checkboxes + * Clarify `Out of Scope` + +### Question priority (recommended) + +1. **MVP scope boundary** (what is included/excluded) +2. **Preference decisions** (after presenting concrete options) +3. **Failure/edge behavior** (only for MVP-critical paths) +4. **Success metrics & Acceptance Criteria** (what proves it works) + +### Preferred question format (multiple choice) + +```markdown +For , which approach do you prefer? + +1. **Option A** — +2. **Option B** — +3. **Option C** — +4. **Other** — describe your preference +``` + +--- + +## Step 7: Propose Approaches + Record Decisions (Complex tasks) + +After requirements are clear enough, propose 2–3 approaches (if not already done via research-first): + +```markdown +Based on current information, here are 2–3 feasible approaches: + +**Approach A: ** (Recommended) + +* How: +* Pros: +* Cons: + +**Approach B: ** + +* How: +* Pros: +* Cons: + +Which direction do you prefer? +``` + +Record the outcome in PRD as an ADR-lite section: + +```markdown +## Decision (ADR-lite) + +**Context**: Why this decision was needed +**Decision**: Which approach was chosen +**Consequences**: Trade-offs, risks, potential future improvements +``` + +--- + +## Step 8: Final Confirmation + Implementation Plan + +When open questions are resolved, confirm complete requirements with a structured summary: + +### Final confirmation format + +```markdown +Here's my understanding of the complete requirements: + +**Goal**: + +**Requirements**: + +* ... +* ... + +**Acceptance Criteria**: + +* [ ] ... +* [ ] ... + +**Definition of Done**: + +* ... + +**Out of Scope**: + +* ... + +**Technical Approach**: + + +**Implementation Plan (small PRs)**: + +* PR1: +* PR2: +* PR3: + +Does this look correct? If yes, I'll proceed with implementation. +``` + +### Subtask Decomposition (Complex Tasks) + +For complex tasks with multiple independent work items, create subtasks: + +```bash +# Create child tasks +CHILD1=$(python3 ./.trellis/scripts/task.py create "Child task 1" --slug child1 --parent "$TASK_DIR") +CHILD2=$(python3 ./.trellis/scripts/task.py create "Child task 2" --slug child2 --parent "$TASK_DIR") + +# Or link existing tasks +python3 ./.trellis/scripts/task.py add-subtask "$TASK_DIR" "$CHILD_DIR" +``` + +--- + +## PRD Target Structure (final) + +`prd.md` should converge to: + +```markdown +# + +## Goal + + + +## Requirements + +* ... + +## Acceptance Criteria + +* [ ] ... + +## Definition of Done + +* ... + +## Technical Approach + + + +## Decision (ADR-lite) + +Context / Decision / Consequences + +## Out of Scope + +* ... + +## Technical Notes + + +``` + +--- + +## Anti-Patterns (Hard Avoid) + +* Asking user for code/context that can be derived from repo +* Asking user to choose an approach before presenting concrete options +* Meta questions about whether to research +* Staying narrowly on the initial request without considering evolution/edges +* Letting brainstorming drift without updating PRD + +--- + +## Integration with Start Workflow + +After brainstorm completes (Step 8 confirmation approved), the flow continues to the Task Workflow's **Phase 2: Prepare for Implementation**: + +```text +Brainstorm + Step 0: Create task directory + seed PRD + Step 1–7: Discover requirements, research, converge + Step 8: Final confirmation → user approves + ↓ +Task Workflow Phase 2 (Prepare for Implementation) + Code-Spec Depth Check (if applicable) + → Research codebase (based on confirmed PRD) + → Configure code-spec context (jsonl files) + → Activate task + ↓ +Task Workflow Phase 3 (Execute) + Implement → Check → Complete +``` + +The task directory and PRD already exist from brainstorm, so Phase 1 of the Task Workflow is skipped entirely. + +--- + +## Related Commands + +| Command | When to Use | +|---------|-------------| +| `$start` | Entry point that triggers brainstorm | +| `$finish-work` | After implementation is complete | +| `$update-spec` | If new patterns emerge during work | diff --git a/.agents/skills/break-loop/SKILL.md b/.agents/skills/break-loop/SKILL.md new file mode 100644 index 00000000..0f5f4e1c --- /dev/null +++ b/.agents/skills/break-loop/SKILL.md @@ -0,0 +1,130 @@ +--- +name: break-loop +description: "Break the Loop - Deep Bug Analysis" +--- + +# Break the Loop - Deep Bug Analysis + +When debug is complete, use this skill for deep analysis to break the "fix bug -> forget -> repeat" cycle. + +--- + +## Analysis Framework + +Analyze the bug you just fixed from these 5 dimensions: + +### 1. Root Cause Category + +Which category does this bug belong to? + +| Category | Characteristics | Example | +|----------|-----------------|---------| +| **A. Missing Spec** | No documentation on how to do it | New feature without checklist | +| **B. Cross-Layer Contract** | Interface between layers unclear | API returns different format than expected | +| **C. Change Propagation Failure** | Changed one place, missed others | Changed function signature, missed call sites | +| **D. Test Coverage Gap** | Unit test passes, integration fails | Works alone, breaks when combined | +| **E. Implicit Assumption** | Code relies on undocumented assumption | Timestamp seconds vs milliseconds | + +### 2. Why Fixes Failed (if applicable) + +If you tried multiple fixes before succeeding, analyze each failure: + +- **Surface Fix**: Fixed symptom, not root cause +- **Incomplete Scope**: Found root cause, didn't cover all cases +- **Tool Limitation**: grep missed it, type check wasn't strict +- **Mental Model**: Kept looking in same layer, didn't think cross-layer + +### 3. Prevention Mechanisms + +What mechanisms would prevent this from happening again? + +| Type | Description | Example | +|------|-------------|---------| +| **Documentation** | Write it down so people know | Update thinking guide | +| **Architecture** | Make the error impossible structurally | Type-safe wrappers | +| **Compile-time** | TypeScript strict, no any | Signature change causes compile error | +| **Runtime** | Monitoring, alerts, scans | Detect orphan entities | +| **Test Coverage** | E2E tests, integration tests | Verify full flow | +| **Code Review** | Checklist, PR template | "Did you check X?" | + +### 4. Systematic Expansion + +What broader problems does this bug reveal? + +- **Similar Issues**: Where else might this problem exist? +- **Design Flaw**: Is there a fundamental architecture issue? +- **Process Flaw**: Is there a development process improvement? +- **Knowledge Gap**: Is the team missing some understanding? + +### 5. Knowledge Capture + +Solidify insights into the system: + +- [ ] Update `.trellis/spec/guides/` thinking guides +- [ ] Update `.trellis/spec/backend/` or `frontend/` docs +- [ ] Create issue record (if applicable) +- [ ] Create feature ticket for root fix +- [ ] Update check skills if needed + +--- + +## Output Format + +Please output analysis in this format: + +```markdown +## Bug Analysis: [Short Description] + +### 1. Root Cause Category +- **Category**: [A/B/C/D/E] - [Category Name] +- **Specific Cause**: [Detailed description] + +### 2. Why Fixes Failed (if applicable) +1. [First attempt]: [Why it failed] +2. [Second attempt]: [Why it failed] +... + +### 3. Prevention Mechanisms +| Priority | Mechanism | Specific Action | Status | +|----------|-----------|-----------------|--------| +| P0 | ... | ... | TODO/DONE | + +### 4. Systematic Expansion +- **Similar Issues**: [List places with similar problems] +- **Design Improvement**: [Architecture-level suggestions] +- **Process Improvement**: [Development process suggestions] + +### 5. Knowledge Capture +- [ ] [Documents to update / tickets to create] +``` + +--- + +## Core Philosophy + +> **The value of debugging is not in fixing the bug, but in making this class of bugs never happen again.** + +Three levels of insight: +1. **Tactical**: How to fix THIS bug +2. **Strategic**: How to prevent THIS CLASS of bugs +3. **Philosophical**: How to expand thinking patterns + +30 minutes of analysis saves 30 hours of future debugging. + +--- + +## After Analysis: Immediate Actions + +**IMPORTANT**: After completing the analysis above, you MUST immediately: + +1. **Update spec/guides** - Don't just list TODOs, actually update the relevant files: + - If it's a cross-platform issue → update `cross-platform-thinking-guide.md` + - If it's a cross-layer issue → update `cross-layer-thinking-guide.md` + - If it's a code reuse issue → update `code-reuse-thinking-guide.md` + - If it's domain-specific → update `backend/*.md` or `frontend/*.md` + +2. **Sync templates** - After updating `.trellis/spec/`, sync to `src/templates/markdown/spec/` + +3. **Commit the spec updates** - This is the primary output, not just the analysis text + +> **The analysis is worthless if it stays in chat. The value is in the updated specs.** diff --git a/.agents/skills/check-backend/SKILL.md b/.agents/skills/check-backend/SKILL.md new file mode 100644 index 00000000..dce49bc8 --- /dev/null +++ b/.agents/skills/check-backend/SKILL.md @@ -0,0 +1,18 @@ +--- +name: check-backend +description: "Check if the code you just wrote follows the backend development guidelines." +--- + +Check if the code you just wrote follows the backend development guidelines. + +Execute these steps: +1. Run `git status` to see modified files +2. Read `.trellis/spec/backend/index.md` to understand which guidelines apply +3. Based on what you changed, read the relevant guideline files: + - Database changes → `.trellis/spec/backend/database-guidelines.md` + - Error handling → `.trellis/spec/backend/error-handling.md` + - Logging changes → `.trellis/spec/backend/logging-guidelines.md` + - Type changes → `.trellis/spec/backend/type-safety.md` + - Any changes → `.trellis/spec/backend/quality-guidelines.md` +4. Review your code against the guidelines +5. Report any violations and fix them if found diff --git a/.agents/skills/check-cross-layer/SKILL.md b/.agents/skills/check-cross-layer/SKILL.md new file mode 100644 index 00000000..3a3d9777 --- /dev/null +++ b/.agents/skills/check-cross-layer/SKILL.md @@ -0,0 +1,158 @@ +--- +name: check-cross-layer +description: "Cross-Layer Check" +--- + +# Cross-Layer Check + +Check if your changes considered all dimensions. Most bugs come from "didn't think of it", not lack of technical skill. + +> **Note**: This is a **post-implementation** safety net. Ideally, read the [Pre-Implementation Checklist](.trellis/spec/guides/pre-implementation-checklist.md) **before** writing code. + +--- + +## Related Documents + +| Document | Purpose | Timing | +|----------|---------|--------| +| [Pre-Implementation Checklist](.trellis/spec/guides/pre-implementation-checklist.md) | Questions before coding | **Before** writing code | +| [Code Reuse Thinking Guide](.trellis/spec/guides/code-reuse-thinking-guide.md) | Pattern recognition | During implementation | +| **`$check-cross-layer`** (this skill) | Verification check | **After** implementation | + +--- + +## Execution Steps + +### 1. Identify Change Scope + +```bash +git status +git diff --name-only +``` + +### 2. Select Applicable Check Dimensions + +Based on your change type, execute relevant checks below: + +--- + +## Dimension A: Cross-Layer Data Flow (Required when 3+ layers) + +**Trigger**: Changes involve 3 or more layers + +| Layer | Common Locations | +|-------|------------------| +| API/Routes | `routes/`, `api/`, `handlers/`, `controllers/` | +| Service/Business Logic | `services/`, `lib/`, `core/`, `domain/` | +| Database/Storage | `db/`, `models/`, `repositories/`, `schema/` | +| UI/Presentation | `components/`, `views/`, `templates/`, `pages/` | +| Utility | `utils/`, `helpers/`, `common/` | + +**Checklist**: +- [ ] Read flow: Database -> Service -> API -> UI +- [ ] Write flow: UI -> API -> Service -> Database +- [ ] Types/schemas correctly passed between layers? +- [ ] Errors properly propagated to caller? +- [ ] Loading/pending states handled at each layer? + +**Detailed Guide**: `.trellis/spec/guides/cross-layer-thinking-guide.md` + +--- + +## Dimension B: Code Reuse (Required when modifying constants/config) + +**Trigger**: +- Modifying UI constants (label, icon, color) +- Modifying any hardcoded value +- Seeing similar code in multiple places +- Creating a new utility/helper function +- Just finished batch modifications across files + +**Checklist**: +- [ ] Search first: How many places define this value? + ```bash + # Search in source files (adjust extensions for your project) + grep -r "value-to-change" src/ + ``` +- [ ] If 2+ places define same value -> Should extract to shared constant +- [ ] After modification, all usage sites updated? +- [ ] If creating utility: Does similar utility already exist? + +**Detailed Guide**: `.trellis/spec/guides/code-reuse-thinking-guide.md` + +--- + +## Dimension B2: New Utility Functions + +**Trigger**: About to create a new utility/helper function + +**Checklist**: +- [ ] Search for existing similar utilities first + ```bash + grep -r "functionNamePattern" src/ + ``` +- [ ] If similar exists, can you extend it instead? +- [ ] If creating new, is it in the right location (shared vs domain-specific)? + +--- + +## Dimension B3: After Batch Modifications + +**Trigger**: Just modified similar patterns in multiple files + +**Checklist**: +- [ ] Did you check ALL files with similar patterns? + ```bash + grep -r "patternYouChanged" src/ + ``` +- [ ] Any files missed that should also be updated? +- [ ] Should this pattern be abstracted to prevent future duplication? + +--- + +## Dimension C: Import/Dependency Paths (Required when creating new files) + +**Trigger**: Creating new source files + +**Checklist**: +- [ ] Using correct import paths (relative vs absolute)? +- [ ] No circular dependencies? +- [ ] Consistent with project's module organization? + +--- + +## Dimension D: Same-Layer Consistency + +**Trigger**: +- Modifying display logic or formatting +- Same domain concept used in multiple places + +**Checklist**: +- [ ] Search for other places using same concept + ```bash + grep -r "ConceptName" src/ + ``` +- [ ] Are these usages consistent? +- [ ] Should they share configuration/constants? + +--- + +## Common Issues Quick Reference + +| Issue | Root Cause | Prevention | +|-------|------------|------------| +| Changed one place, missed others | Didn't search impact scope | `grep` before changing | +| Data lost at some layer | Didn't check data flow | Trace data source to destination | +| Type/schema mismatch | Cross-layer types inconsistent | Use shared type definitions | +| UI/output inconsistent | Same concept in multiple places | Extract shared constants | +| Similar utility exists | Didn't search first | Search before creating | +| Batch fix incomplete | Didn't verify all occurrences | grep after fixing | + +--- + +## Output + +Report: +1. Which dimensions your changes involve +2. Check results for each dimension +3. Issues found and fix suggestions diff --git a/.agents/skills/check-frontend/SKILL.md b/.agents/skills/check-frontend/SKILL.md new file mode 100644 index 00000000..cdef3cb9 --- /dev/null +++ b/.agents/skills/check-frontend/SKILL.md @@ -0,0 +1,18 @@ +--- +name: check-frontend +description: "Check if the code you just wrote follows the frontend development guidelines." +--- + +Check if the code you just wrote follows the frontend development guidelines. + +Execute these steps: +1. Run `git status` to see modified files +2. Read `.trellis/spec/frontend/index.md` to understand which guidelines apply +3. Based on what you changed, read the relevant guideline files: + - Component changes → `.trellis/spec/frontend/component-guidelines.md` + - Hook changes → `.trellis/spec/frontend/hook-guidelines.md` + - State changes → `.trellis/spec/frontend/state-management.md` + - Type changes → `.trellis/spec/frontend/type-safety.md` + - Any changes → `.trellis/spec/frontend/quality-guidelines.md` +4. Review your code against the guidelines +5. Report any violations and fix them if found diff --git a/.agents/skills/create-command/SKILL.md b/.agents/skills/create-command/SKILL.md new file mode 100644 index 00000000..eed6dafb --- /dev/null +++ b/.agents/skills/create-command/SKILL.md @@ -0,0 +1,101 @@ +--- +name: create-command +description: "Create New Skill" +--- + +# Create New Skill + +Create a new Codex skill in `.agents/skills//SKILL.md` based on user requirements. + +## Usage + +```bash +$create-command +``` + +**Example**: +```bash +$create-command review-pr Check PR code changes against project guidelines +``` + +## Execution Steps + +### 1. Parse Input + +Extract from user input: +- **Skill name**: Use kebab-case (e.g., `review-pr`) +- **Description**: What the skill should accomplish + +### 2. Analyze Requirements + +Determine skill type based on description: +- **Initialization**: Read docs, establish context +- **Pre-development**: Read guidelines, check dependencies +- **Code check**: Validate code quality and guideline compliance +- **Recording**: Record progress, questions, structure changes +- **Generation**: Generate docs or code templates + +### 3. Generate Skill Content + +Minimum `SKILL.md` structure: + +```markdown +--- +name: +description: "" +--- + +# + + +``` + +### 4. Create Files + +Create: +- `.agents/skills//SKILL.md` + +### 5. Confirm Creation + +Output result: + +```text +[OK] Created Skill: + +File path: +- .agents/skills//SKILL.md + +Usage: +- Trigger directly with $ +- Or open /skills and select it + +Description: + +``` + +## Skill Content Guidelines + +### [OK] Good skill content + +1. **Clear and concise**: Immediately understandable +2. **Executable**: AI can follow steps directly +3. **Well-scoped**: Clear boundaries of what to do and not do +4. **Has output**: Specifies expected output format (if needed) + +### [X] Avoid + +1. **Too vague**: e.g., "optimize code" +2. **Too complex**: Single skill should not exceed 100 lines +3. **Duplicate functionality**: Check if similar skill exists first + +## Naming Conventions + +| Skill Type | Prefix | Example | +|------------|--------|---------| +| Session Start | `start` | `start` | +| Pre-development | `before-` | `before-frontend-dev` | +| Check | `check-` | `check-frontend` | +| Record | `record-` | `record-session` | +| Generate | `generate-` | `generate-api-doc` | +| Update | `update-` | `update-changelog` | +| Other | Verb-first | `review-code`, `sync-data` | diff --git a/.agents/skills/finish-work/SKILL.md b/.agents/skills/finish-work/SKILL.md new file mode 100644 index 00000000..75ec3688 --- /dev/null +++ b/.agents/skills/finish-work/SKILL.md @@ -0,0 +1,148 @@ +--- +name: finish-work +description: "Finish Work - Pre-Commit Checklist" +--- + +# Finish Work - Pre-Commit Checklist + +Before submitting or committing, use this checklist to ensure work completeness. + +**Timing**: After code is written and tested, before commit + +--- + +## Checklist + +### 1. Code Quality + +```bash +# Must pass +pnpm lint +pnpm type-check +pnpm test +``` + +- [ ] `pnpm lint` passes with 0 errors? +- [ ] `pnpm type-check` passes with no type errors? +- [ ] Tests pass? +- [ ] No `console.log` statements (use logger)? +- [ ] No non-null assertions (the `x!` operator)? +- [ ] No `any` types? + +### 2. Code-Spec Sync + +**Code-Spec Docs**: +- [ ] Does `.trellis/spec/backend/` need updates? + - New patterns, new modules, new conventions +- [ ] Does `.trellis/spec/frontend/` need updates? + - New components, new hooks, new patterns +- [ ] Does `.trellis/spec/guides/` need updates? + - New cross-layer flows, lessons from bugs + +**Key Question**: +> "If I fixed a bug or discovered something non-obvious, should I document it so future me (or others) won't hit the same issue?" + +If YES -> Update the relevant code-spec doc. + +### 2.5. Code-Spec Hard Block (Infra/Cross-Layer) + +If this change touches infra or cross-layer contracts, this is a blocking checklist: + +- [ ] Spec content is executable (real signatures/contracts), not principle-only text +- [ ] Includes file path + command/API name + payload field names +- [ ] Includes validation and error matrix +- [ ] Includes Good/Base/Bad cases +- [ ] Includes required tests and assertion points + +**Block Rule**: +If infra/cross-layer changed but the related spec is still abstract, do NOT finish. Run `$update-spec` manually first. + +### 3. API Changes + +If you modified API endpoints: + +- [ ] Input schema updated? +- [ ] Output schema updated? +- [ ] API documentation updated? +- [ ] Client code updated to match? + +### 4. Database Changes + +If you modified database schema: + +- [ ] Migration file created? +- [ ] Schema file updated? +- [ ] Related queries updated? +- [ ] Seed data updated (if applicable)? + +### 5. Cross-Layer Verification + +If the change spans multiple layers: + +- [ ] Data flows correctly through all layers? +- [ ] Error handling works at each boundary? +- [ ] Types are consistent across layers? +- [ ] Loading states handled? + +### 6. Manual Testing + +- [ ] Feature works in browser/app? +- [ ] Edge cases tested? +- [ ] Error states tested? +- [ ] Works after page refresh? + +--- + +## Quick Check Flow + +```bash +# 1. Code checks +pnpm lint && pnpm type-check + +# 2. View changes +git status +git diff --name-only + +# 3. Based on changed files, check relevant items above +``` + +--- + +## Common Oversights + +| Oversight | Consequence | Check | +|-----------|-------------|-------| +| Code-spec docs not updated | Others don't know the change | Check .trellis/spec/ | +| Spec text is abstract only | Easy regressions in infra/cross-layer changes | Require signature/contract/matrix/cases/tests | +| Migration not created | Schema out of sync | Check db/migrations/ | +| Types not synced | Runtime errors | Check shared types | +| Tests not updated | False confidence | Run full test suite | +| Console.log left in | Noisy production logs | Search for console.log | + +--- + +## Relationship to Other Commands + +``` +Development Flow: + Write code -> Test -> $finish-work -> git commit -> $record-session + | | + Ensure completeness Record progress + +Debug Flow: + Hit bug -> Fix -> $break-loop -> Knowledge capture + | + Deep analysis +``` + +- `$finish-work` - Check work completeness (this skill) +- `$record-session` - Record session and commits +- `$break-loop` - Deep analysis after debugging + +--- + +## Core Principle + +> **Delivery includes not just code, but also documentation, verification, and knowledge capture.** + +Complete work = Code + Docs + Tests + Verification diff --git a/.agents/skills/integrate-skill/SKILL.md b/.agents/skills/integrate-skill/SKILL.md new file mode 100644 index 00000000..41107884 --- /dev/null +++ b/.agents/skills/integrate-skill/SKILL.md @@ -0,0 +1,221 @@ +--- +name: integrate-skill +description: "Integrate Skill into Project Guidelines" +--- + +# Integrate Skill into Project Guidelines + +Adapt and integrate a reusable skill into your project's development guidelines (not directly into project code). + +## Usage + +``` +$integrate-skill +``` + +**Examples**: +``` +$integrate-skill frontend-design +$integrate-skill mcp-builder +``` + +## Core Principle + +> [!] **Important**: The goal of skill integration is to update **development guidelines**, not to generate project code directly. +> +> - Guidelines content -> Write to `.trellis/spec/{target}/doc.md` +> - Code examples -> Place in `.trellis/spec/{target}/examples/skills//` +> - Example files -> Use `.template` suffix (e.g., `component.tsx.template`) to avoid IDE errors +> +> Where `{target}` is `frontend` or `backend`, determined by skill type. + +## Execution Steps + +### 1. Read Skill Content + +Locate and read the skill instructions: +- `.agents/skills//SKILL.md` in the repository +- Skill list in `AGENTS.md` (when available in current context) + +If the skill cannot be found, ask the user for the source path or repository. + +### 2. Determine Integration Target + +Based on skill type, determine which guidelines to update: + +| Skill Category | Integration Target | +|----------------|-------------------| +| UI/Frontend (`frontend-design`, `web-artifacts-builder`) | `.trellis/spec/frontend/` | +| Backend/API (`mcp-builder`) | `.trellis/spec/backend/` | +| Documentation (`doc-coauthoring`, `docx`, `pdf`) | `.trellis/` or create dedicated guidelines | +| Testing (`webapp-testing`) | `.trellis/spec/frontend/` (E2E) | + +### 3. Analyze Skill Content + +Extract from the skill: +- **Core concepts**: How the skill works and key concepts +- **Best practices**: Recommended approaches +- **Code patterns**: Reusable code templates +- **Caveats**: Common issues and solutions + +### 4. Execute Integration + +#### 4.1 Update Guidelines Document + +Add a new section to the corresponding `doc.md`: + +```markdown +@@@section:skill- +## # Integration Guide + +### Overview +[Core functionality and use cases of the skill] + +### Project Adaptation +[How to use this skill in the current project] + +### Usage Steps +1. [Step 1] +2. [Step 2] + +### Caveats +- [Project-specific constraints] +- [Differences from default behavior] + +### Reference Examples +See `examples/skills//` + +@@@/section:skill- +``` + +#### 4.2 Create Examples Directory (if code examples exist) + +```bash +# Directory structure ({target} = frontend or backend) +.trellis/spec/{target}/ +|-- doc.md # Add skill-related section +|-- index.md # Update index ++-- examples/ + +-- skills/ + +-- / + |-- README.md # Example documentation + |-- example-1.ts.template # Code example (use .template suffix) + +-- example-2.tsx.template +``` + +**File naming conventions**: +- Code files: `..template` (e.g., `component.tsx.template`) +- Config files: `.config.template` (e.g., `tailwind.config.template`) +- Documentation: `README.md` (normal suffix) + +#### 4.3 Update Index File + +Add to the Quick Navigation table in `index.md`: + +```markdown +| |
| `skill-` | +``` + +### 5. Generate Integration Report + +--- + +## Skill Integration Report: `` + +### # Overview +- **Skill description**: [Functionality description] +- **Integration target**: `.trellis/spec/{target}/` + +### # Tech Stack Compatibility + +| Skill Requirement | Project Status | Compatibility | +|-------------------|----------------|---------------| +| [Tech 1] | [Project tech] | [OK]/[!]/[X] | + +### # Integration Locations + +| Type | Path | +|------|------| +| Guidelines doc | `.trellis/spec/{target}/doc.md` (section: `skill-`) | +| Code examples | `.trellis/spec/{target}/examples/skills//` | +| Index update | `.trellis/spec/{target}/index.md` | + +> `{target}` = `frontend` or `backend` + +### # Dependencies (if needed) + +```bash +# Install required dependencies (adjust for your package manager) +npm install +# or +pnpm add +# or +yarn add +``` + +### [OK] Completed Changes + +- [ ] Added `@@@section:skill-` section to `doc.md` +- [ ] Added index entry to `index.md` +- [ ] Created example files in `examples/skills//` +- [ ] Example files use `.template` suffix + +### # Related Guidelines + +- [Existing related section IDs] + +--- + +## 6. Optional: Create Usage Skill + +If this skill is frequently used, create a shortcut skill: + +```bash +$create-command use- Use skill following project guidelines +``` + +## Common Skill Integration Reference + +| Skill | Integration Target | Examples Directory | +|-------|-------------------|-------------------| +| `frontend-design` | `frontend` | `examples/skills/frontend-design/` | +| `mcp-builder` | `backend` | `examples/skills/mcp-builder/` | +| `webapp-testing` | `frontend` | `examples/skills/webapp-testing/` | +| `doc-coauthoring` | `.trellis/` | N/A (documentation workflow only) | + +## Example: Integrating `mcp-builder` Skill + +### Directory Structure + +``` +.trellis/spec/backend/ +|-- doc.md # Add MCP section +|-- index.md # Add index entry ++-- examples/ + +-- skills/ + +-- mcp-builder/ + |-- README.md + |-- server.ts.template + |-- tools.ts.template + +-- types.ts.template +``` + +### New Section in doc.md + +```markdown +@@@section:skill-mcp-builder +## # MCP Server Development Guide + +### Overview +Create LLM-callable tool services using MCP (Model Context Protocol). + +### Project Adaptation +- Place services in a dedicated directory +- Follow existing TypeScript and type definition conventions +- Use project's logging system + +### Reference Examples +See `examples/skills/mcp-builder/` + +@@@/section:skill-mcp-builder +``` diff --git a/.agents/skills/onboard/SKILL.md b/.agents/skills/onboard/SKILL.md new file mode 100644 index 00000000..36212719 --- /dev/null +++ b/.agents/skills/onboard/SKILL.md @@ -0,0 +1,363 @@ +--- +name: onboard +description: "PART 3: Customize Your Development Guidelines" +--- + +You are a senior developer onboarding a new team member to this project's AI-assisted workflow system. + +YOUR ROLE: Be a mentor and teacher. Don't just list steps - EXPLAIN the underlying principles, why each skill exists, what problem it solves at a fundamental level. + +## CRITICAL INSTRUCTION - YOU MUST COMPLETE ALL SECTIONS + +This onboarding has THREE equally important parts: + +**PART 1: Core Concepts** (Sections: CORE PHILOSOPHY, SYSTEM STRUCTURE, SKILL DEEP DIVE) +- Explain WHY this workflow exists +- Explain WHAT each skill does and WHY + +**PART 2: Real-World Examples** (Section: REAL-WORLD WORKFLOW EXAMPLES) +- Walk through ALL 5 examples in detail +- For EACH step in EACH example, explain: + - PRINCIPLE: Why this step exists + - WHAT HAPPENS: What the skill actually does + - IF SKIPPED: What goes wrong without it + +**PART 3: Customize Your Development Guidelines** (Section: CUSTOMIZE YOUR DEVELOPMENT GUIDELINES) +- Check if project guidelines are still empty templates +- If empty, guide the developer to fill them with project-specific content +- Explain the customization workflow + +DO NOT skip any part. All three parts are essential: +- Part 1 teaches the concepts +- Part 2 shows how concepts work in practice +- Part 3 ensures the project has proper guidelines for AI to follow + +After completing ALL THREE parts, ask the developer about their first task. + +--- + +## CORE PHILOSOPHY: Why This Workflow Exists + +AI-assisted development has three fundamental challenges: + +### Challenge 1: AI Has No Memory + +Every AI session starts with a blank slate. Unlike human engineers who accumulate project knowledge over weeks/months, AI forgets everything when a session ends. + +**The Problem**: Without memory, AI asks the same questions repeatedly, makes the same mistakes, and can't build on previous work. + +**The Solution**: The `.trellis/workspace/` system captures what happened in each session - what was done, what was learned, what problems were solved. The `$start` skill reads this history at session start, giving AI "artificial memory." + +### Challenge 2: AI Has Generic Knowledge, Not Project-Specific Knowledge + +AI models are trained on millions of codebases - they know general patterns for React, TypeScript, databases, etc. But they don't know YOUR project's conventions. + +**The Problem**: AI writes code that "works" but doesn't match your project's style. It uses patterns that conflict with existing code. It makes decisions that violate unwritten team rules. + +**The Solution**: The `.trellis/spec/` directory contains project-specific guidelines. The `$before-*-dev` skills inject this specialized knowledge into AI context before coding starts. + +### Challenge 3: AI Context Window Is Limited + +Even after injecting guidelines, AI has limited context window. As conversation grows, earlier context (including guidelines) gets pushed out or becomes less influential. + +**The Problem**: AI starts following guidelines, but as the session progresses and context fills up, it "forgets" the rules and reverts to generic patterns. + +**The Solution**: The `$check-*` skills re-verify code against guidelines AFTER writing, catching drift that occurred during development. The `$finish-work` skill does a final holistic review. + +--- + +## SYSTEM STRUCTURE + +``` +.trellis/ +|-- .developer # Your identity (gitignored) +|-- workflow.md # Complete workflow documentation +|-- workspace/ # "AI Memory" - session history +| |-- index.md # All developers' progress +| +-- {developer}/ # Per-developer directory +| |-- index.md # Personal progress index +| +-- journal-N.md # Session records (max 2000 lines) +|-- tasks/ # Task tracking (unified) +| +-- {MM}-{DD}-{slug}/ # Task directory +| |-- task.json # Task metadata +| +-- prd.md # Requirements doc +|-- spec/ # "AI Training Data" - project knowledge +| |-- frontend/ # Frontend conventions +| |-- backend/ # Backend conventions +| +-- guides/ # Thinking patterns ++-- scripts/ # Automation tools +``` + +### Understanding spec/ subdirectories + +**frontend/** - Single-layer frontend knowledge: +- Component patterns (how to write components in THIS project) +- State management rules (Redux? Zustand? Context?) +- Styling conventions (CSS modules? Tailwind? Styled-components?) +- Hook patterns (custom hooks, data fetching) + +**backend/** - Single-layer backend knowledge: +- API design patterns (REST? GraphQL? tRPC?) +- Database conventions (query patterns, migrations) +- Error handling standards +- Logging and monitoring rules + +**guides/** - Cross-layer thinking guides: +- Code reuse thinking guide +- Cross-layer thinking guide +- Pre-implementation checklists + +--- + +## SKILL DEEP DIVE + +### $start - Restore AI Memory + +**WHY IT EXISTS**: +When a human engineer joins a project, they spend days/weeks learning: What is this project? What's been built? What's in progress? What's the current state? + +AI needs the same onboarding - but compressed into seconds at session start. + +**WHAT IT ACTUALLY DOES**: +1. Reads developer identity (who am I in this project?) +2. Checks git status (what branch? uncommitted changes?) +3. Reads recent session history from `workspace/` (what happened before?) +4. Identifies active features (what's in progress?) +5. Understands current project state before making any changes + +**WHY THIS MATTERS**: +- Without $start: AI is blind. It might work on wrong branch, conflict with others' work, or redo already-completed work. +- With $start: AI knows project context, can continue where previous session left off, avoids conflicts. + +--- + +### $before-frontend-dev and $before-backend-dev - Inject Specialized Knowledge + +**WHY IT EXISTS**: +AI models have "pre-trained knowledge" - general patterns from millions of codebases. But YOUR project has specific conventions that differ from generic patterns. + +**WHAT IT ACTUALLY DOES**: +1. Reads `.trellis/spec/frontend/` or `.trellis/spec/backend/` +2. Loads project-specific patterns into AI's working context: + - Component naming conventions + - State management patterns + - Database query patterns + - Error handling standards + +**WHY THIS MATTERS**: +- Without before-*-dev: AI writes generic code that doesn't match project style. +- With before-*-dev: AI writes code that looks like the rest of the codebase. + +--- + +### $check-frontend and $check-backend - Combat Context Drift + +**WHY IT EXISTS**: +AI context window has limited capacity. As conversation progresses, guidelines injected at session start become less influential. This causes "context drift." + +**WHAT IT ACTUALLY DOES**: +1. Re-reads the guidelines that were injected earlier +2. Compares written code against those guidelines +3. Runs type checker and linter +4. Identifies violations and suggests fixes + +**WHY THIS MATTERS**: +- Without check-*: Context drift goes unnoticed, code quality degrades. +- With check-*: Drift is caught and corrected before commit. + +--- + +### $check-cross-layer - Multi-Dimension Verification + +**WHY IT EXISTS**: +Most bugs don't come from lack of technical skill - they come from "didn't think of it": +- Changed a constant in one place, missed 5 other places +- Modified database schema, forgot to update the API layer +- Created a utility function, but similar one already exists + +**WHAT IT ACTUALLY DOES**: +1. Identifies which dimensions your change involves +2. For each dimension, runs targeted checks: + - Cross-layer data flow + - Code reuse analysis + - Import path validation + - Consistency checks + +--- + +### $finish-work - Holistic Pre-Commit Review + +**WHY IT EXISTS**: +The `$check-*` skills focus on code quality within a single layer. But real changes often have cross-cutting concerns. + +**WHAT IT ACTUALLY DOES**: +1. Reviews all changes holistically +2. Checks cross-layer consistency +3. Identifies broader impacts +4. Checks if new patterns should be documented + +--- + +### $record-session - Persist Memory for Future + +**WHY IT EXISTS**: +All the context AI built during this session will be lost when session ends. The next session's `$start` needs this information. + +**WHAT IT ACTUALLY DOES**: +1. Records session summary to `workspace/{developer}/journal-N.md` +2. Captures what was done, learned, and what's remaining +3. Updates index files for quick lookup + +--- + +## REAL-WORLD WORKFLOW EXAMPLES + +### Example 1: Bug Fix Session + +**[1/8] $start** - AI needs project context before touching code +**[2/8] python3 ./.trellis/scripts/task.py create "Fix bug" --slug fix-bug** - Track work for future reference +**[3/8] $before-frontend-dev** - Inject project-specific frontend knowledge +**[4/8] Investigate and fix the bug** - Actual development work +**[5/8] $check-frontend** - Re-verify code against guidelines +**[6/8] $finish-work** - Holistic cross-layer review +**[7/8] Human tests and commits** - Human validates before code enters repo +**[8/8] $record-session** - Persist memory for future sessions + +### Example 2: Planning Session (No Code) + +**[1/4] $start** - Context needed even for non-coding work +**[2/4] python3 ./.trellis/scripts/task.py create "Planning task" --slug planning-task** - Planning is valuable work +**[3/4] Review docs, create subtask list** - Actual planning work +**[4/4] $record-session (with --summary)** - Planning decisions must be recorded + +### Example 3: Code Review Fixes + +**[1/6] $start** - Resume context from previous session +**[2/6] $before-backend-dev** - Re-inject guidelines before fixes +**[3/6] Fix each CR issue** - Address feedback with guidelines in context +**[4/6] $check-backend** - Verify fixes didn't introduce new issues +**[5/6] $finish-work** - Document lessons from CR +**[6/6] Human commits, then $record-session** - Preserve CR lessons + +### Example 4: Large Refactoring + +**[1/5] $start** - Clear baseline before major changes +**[2/5] Plan phases** - Break into verifiable chunks +**[3/5] Execute phase by phase with $check-* after each** - Incremental verification +**[4/5] $finish-work** - Check if new patterns should be documented +**[5/5] Record with multiple commit hashes** - Link all commits to one feature + +### Example 5: Debug Session + +**[1/6] $start** - See if this bug was investigated before +**[2/6] $before-backend-dev** - Guidelines might document known gotchas +**[3/6] Investigation** - Actual debugging work +**[4/6] $check-backend** - Verify debug changes don't break other things +**[5/6] $finish-work** - Debug findings might need documentation +**[6/6] Human commits, then $record-session** - Debug knowledge is valuable + +--- + +## KEY RULES TO EMPHASIZE + +1. **AI NEVER commits** - Human tests and approves. AI prepares, human validates. +2. **Guidelines before code** - `$before-*-dev` skills inject project knowledge. +3. **Check after code** - `$check-*` skills catch context drift. +4. **Record everything** - $record-session persists memory. + +--- + +# PART 3: Customize Your Development Guidelines + +After explaining Part 1 and Part 2, check if the project's development guidelines need customization. + +## Step 1: Check Current Guidelines Status + +Check if `.trellis/spec/` contains empty templates or customized guidelines: + +```bash +# Check if files are still empty templates (look for placeholder text) +grep -l "To be filled by the team" .trellis/spec/backend/*.md 2>/dev/null | wc -l +grep -l "To be filled by the team" .trellis/spec/frontend/*.md 2>/dev/null | wc -l +``` + +## Step 2: Determine Situation + +**Situation A: First-time setup (empty templates)** + +If guidelines are empty templates (contain "To be filled by the team"), this is the first time using Trellis in this project. + +Explain to the developer: + +"I see that the development guidelines in `.trellis/spec/` are still empty templates. This is normal for a new Trellis setup! + +The templates contain placeholder text that needs to be replaced with YOUR project's actual conventions. Without this, `$before-*-dev` skills won't provide useful guidance. + +**Your first task should be to fill in these guidelines:** + +1. Look at your existing codebase +2. Identify the patterns and conventions already in use +3. Document them in the guideline files + +For example, for `.trellis/spec/backend/database-guidelines.md`: +- What ORM/query library does your project use? +- How are migrations managed? +- What naming conventions for tables/columns? + +Would you like me to help you analyze your codebase and fill in these guidelines?" + +**Situation B: Guidelines already customized** + +If guidelines have real content (no "To be filled" placeholders), this is an existing setup. + +Explain to the developer: + +"Great! Your team has already customized the development guidelines. You can start using `$before-*-dev` skills right away. + +I recommend reading through `.trellis/spec/` to familiarize yourself with the team's coding standards." + +## Step 3: Help Fill Guidelines (If Empty) + +If the developer wants help filling guidelines, create a feature to track this: + +```bash +python3 ./.trellis/scripts/task.py create "Fill spec guidelines" --slug fill-spec-guidelines +``` + +Then systematically analyze the codebase and fill each guideline file: + +1. **Analyze the codebase** - Look at existing code patterns +2. **Document conventions** - Write what you observe, not ideals +3. **Include examples** - Reference actual files in the project +4. **List forbidden patterns** - Document anti-patterns the team avoids + +Work through one file at a time: +- `backend/directory-structure.md` +- `backend/database-guidelines.md` +- `backend/error-handling.md` +- `backend/quality-guidelines.md` +- `backend/logging-guidelines.md` +- `frontend/directory-structure.md` +- `frontend/component-guidelines.md` +- `frontend/hook-guidelines.md` +- `frontend/state-management.md` +- `frontend/quality-guidelines.md` +- `frontend/type-safety.md` + +--- + +## Completing the Onboard Session + +After covering all three parts, summarize: + +"You're now onboarded to the Trellis workflow system! Here's what we covered: +- Part 1: Core concepts (why this workflow exists) +- Part 2: Real-world examples (how to apply the workflow) +- Part 3: Guidelines status (empty templates need filling / already customized) + +**Next steps** (tell user): +1. Run `$record-session` to record this onboard session +2. [If guidelines empty] Start filling in `.trellis/spec/` guidelines +3. [If guidelines ready] Start your first development task + +What would you like to do first?" diff --git a/.agents/skills/record-session/SKILL.md b/.agents/skills/record-session/SKILL.md new file mode 100644 index 00000000..34dbbda7 --- /dev/null +++ b/.agents/skills/record-session/SKILL.md @@ -0,0 +1,66 @@ +--- +name: record-session +description: "Record work progress after human has tested and committed code" +--- + +[!] **Prerequisite**: This skill should only be used AFTER the human has tested and committed the code. + +**Do NOT run `git commit` directly** — the scripts below handle their own commits for `.trellis/` metadata. You only need to read git history (`git log`, `git status`, `git diff`) and run the Python scripts. + +--- + +## Record Work Progress + +### Step 1: Get Context & Check Tasks + +```bash +python3 ./.trellis/scripts/get_context.py --mode record +``` + +[!] Archive tasks whose work is **actually done** — judge by work status, not the `status` field in task.json: +- Code committed? → Archive it (don't wait for PR) +- All acceptance criteria met? → Archive it +- Don't skip archiving just because `status` still says `planning` or `in_progress` + +```bash +python3 ./.trellis/scripts/task.py archive +``` + +### Step 2: One-Click Add Session + +```bash +# Method 1: Simple parameters +python3 ./.trellis/scripts/add_session.py \ + --title "Session Title" \ + --commit "hash1,hash2" \ + --summary "Brief summary of what was done" + +# Method 2: Pass detailed content via stdin +cat << 'EOF' | python3 ./.trellis/scripts/add_session.py --title "Title" --commit "hash" +| Feature | Description | +|---------|-------------| +| New API | Added user authentication endpoint | +| Frontend | Updated login form | + +**Updated Files**: +- `packages/api/modules/auth/router.ts` +- `apps/web/modules/auth/components/login-form.tsx` +EOF +``` + +**Auto-completes**: +- [OK] Appends session to journal-N.md +- [OK] Auto-detects line count, creates new file if >2000 lines +- [OK] Updates index.md (Total Sessions +1, Last Active, line stats, history) +- [OK] Auto-commits .trellis/workspace and .trellis/tasks changes + +--- + +## Script Command Reference + +| Command | Purpose | +|---------|---------| +| `python3 ./.trellis/scripts/get_context.py --mode record` | Get context for record-session | +| `python3 ./.trellis/scripts/add_session.py --title "..." --commit "..."` | **One-click add session (recommended)** | +| `python3 ./.trellis/scripts/task.py archive ` | Archive completed task (auto-commits) | +| `python3 ./.trellis/scripts/task.py list` | List active tasks | diff --git a/.agents/skills/start/SKILL.md b/.agents/skills/start/SKILL.md new file mode 100644 index 00000000..0adfc87b --- /dev/null +++ b/.agents/skills/start/SKILL.md @@ -0,0 +1,346 @@ +--- +name: start +description: "Start Session" +--- + +# Start Session + +Initialize your AI development session and begin working on tasks. + +--- + +## Operation Types + +| Marker | Meaning | Executor | +|--------|---------|----------| +| `[AI]` | Bash scripts or tool calls executed by AI | You (AI) | +| `[USER]` | Skills executed by user | User | + +--- + +## Initialization `[AI]` + +### Step 1: Understand Development Workflow + +First, read the workflow guide to understand the development process: + +```bash +cat .trellis/workflow.md +``` + +**Follow the instructions in workflow.md** - it contains: +- Core principles (Read Before Write, Follow Standards, etc.) +- File system structure +- Development process +- Best practices + +### Step 2: Get Current Context + +```bash +python3 ./.trellis/scripts/get_context.py +``` + +This shows: developer identity, git status, current task (if any), active tasks. + +### Step 3: Read Guidelines Index + +```bash +cat .trellis/spec/frontend/index.md # Frontend guidelines +cat .trellis/spec/backend/index.md # Backend guidelines +cat .trellis/spec/guides/index.md # Thinking guides +``` + +> **Important**: The index files are navigation — they list the actual guideline files (e.g., `error-handling.md`, `conventions.md`, `mock-strategies.md`). +> At this step, just read the indexes to understand what's available. +> When you start actual development, you MUST go back and read the specific guideline files relevant to your task, as listed in the index's Pre-Development Checklist. + +### Step 4: Report and Ask + +Report what you learned and ask: "What would you like to work on?" + +--- + +## Task Classification + +When user describes a task, classify it: + +| Type | Criteria | Workflow | +|------|----------|----------| +| **Question** | User asks about code, architecture, or how something works | Answer directly | +| **Trivial Fix** | Typo fix, comment update, single-line change, < 5 minutes | Direct Edit | +| **Simple Task** | Clear goal, 1-2 files, well-defined scope | Quick confirm → Task Workflow | +| **Complex Task** | Vague goal, multiple files, architectural decisions | **Brainstorm → Task Workflow** | + +### Decision Rule + +> **If in doubt, use Brainstorm + Task Workflow.** +> +> Task Workflow ensures code-specs are injected to the right context, resulting in higher quality code. +> The overhead is minimal, but the benefit is significant. + +> **Subtask Decomposition**: If brainstorm reveals multiple independent work items, +> consider creating subtasks using `--parent` flag or `add-subtask` command. +> See the brainstorm skill's Step 8 for details. + +--- + +## Question / Trivial Fix + +For questions or trivial fixes, work directly: + +1. Answer question or make the fix +2. If code was changed, remind user to run `$finish-work` + +--- + +## Simple Task + +For simple, well-defined tasks: + +1. Quick confirm: "I understand you want to [goal]. Shall I proceed?" +2. If no, clarify and confirm again +3. **If yes: execute ALL steps below without stopping. Do NOT ask for additional confirmation between steps.** + - Create task directory (Phase 1 Path B, Step 2) + - Write PRD (Step 3) + - Research codebase (Phase 2, Step 5) + - Configure context (Step 6) + - Activate task (Step 7) + - Implement (Phase 3, Step 8) + - Check quality (Step 9) + - Complete (Step 10) + +--- + +## Complex Task - Brainstorm First + +For complex or vague tasks, **automatically start the brainstorm process** — do NOT skip directly to implementation. + +See `$brainstorm` for the full process. Summary: + +1. **Acknowledge and classify** - State your understanding +2. **Create task directory** - Track evolving requirements in `prd.md` +3. **Ask questions one at a time** - Update PRD after each answer +4. **Propose approaches** - For architectural decisions +5. **Confirm final requirements** - Get explicit approval +6. **Proceed to Task Workflow** - With clear requirements in PRD + +--- + +## Task Workflow (Development Tasks) + +**Why this workflow?** +- Run a dedicated research pass before coding +- Configure specs in jsonl context files +- Implement using injected context +- Verify with a separate check pass +- Result: Code that follows project conventions automatically + +### Overview: Two Entry Points + +``` +From Brainstorm (Complex Task): + PRD confirmed → Research → Configure Context → Activate → Implement → Check → Complete + +From Simple Task: + Confirm → Create Task → Write PRD → Research → Configure Context → Activate → Implement → Check → Complete +``` + +**Key principle: Research happens AFTER requirements are clear (PRD exists).** + +--- + +### Phase 1: Establish Requirements + +#### Path A: From Brainstorm (skip to Phase 2) + +PRD and task directory already exist from brainstorm. Skip directly to Phase 2. + +#### Path B: From Simple Task + +**Step 1: Confirm Understanding** `[AI]` + +Quick confirm: +- What is the goal? +- What type of development? (frontend / backend / fullstack) +- Any specific requirements or constraints? + +If unclear, ask clarifying questions. + +**Step 2: Create Task Directory** `[AI]` + +```bash +TASK_DIR=$(python3 ./.trellis/scripts/task.py create "" --slug <name>) +``` + +**Step 3: Write PRD** `[AI]` + +Create `prd.md` in the task directory with: + +```markdown +# <Task Title> + +## Goal +<What we're trying to achieve> + +## Requirements +- <Requirement 1> +- <Requirement 2> + +## Acceptance Criteria +- [ ] <Criterion 1> +- [ ] <Criterion 2> + +## Technical Notes +<Any technical decisions or constraints> +``` + +--- + +### Phase 2: Prepare for Implementation (shared) + +> Both paths converge here. PRD and task directory must exist before proceeding. + +**Step 4: Code-Spec Depth Check** `[AI]` + +If the task touches infra or cross-layer contracts, do not start implementation until code-spec depth is defined. + +Trigger this requirement when the change includes any of: +- New or changed command/API signatures +- Database schema or migration changes +- Infra integrations (storage, queue, cache, secrets, env contracts) +- Cross-layer payload transformations + +Must-have before proceeding: +- [ ] Target code-spec files to update are identified +- [ ] Concrete contract is defined (signature, fields, env keys) +- [ ] Validation and error matrix is defined +- [ ] At least one Good/Base/Bad case is defined + +**Step 5: Research the Codebase** `[AI]` + +Based on the confirmed PRD, run a focused research pass and produce: + +1. Relevant spec files in `.trellis/spec/` +2. Existing code patterns to follow (2-3 examples) +3. Files that will likely need modification + +Use this output format: + +```markdown +## Relevant Specs +- <path>: <why it's relevant> + +## Code Patterns Found +- <pattern>: <example file path> + +## Files to Modify +- <path>: <what change> +``` + +**Step 6: Configure Context** `[AI]` + +Initialize default context: + +```bash +python3 ./.trellis/scripts/task.py init-context "$TASK_DIR" <type> +# type: backend | frontend | fullstack +``` + +Add specs found in your research pass: + +```bash +# For each relevant spec and code pattern: +python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" implement "<path>" "<reason>" +python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" check "<path>" "<reason>" +``` + +**Step 7: Activate Task** `[AI]` + +```bash +python3 ./.trellis/scripts/task.py start "$TASK_DIR" +``` + +This sets `.current-task` so hooks can inject context. + +--- + +### Phase 3: Execute (shared) + +**Step 8: Implement** `[AI]` + +Implement the task described in `prd.md`. + +- Follow all specs injected into implement context +- Keep changes scoped to requirements +- Run lint and typecheck before finishing + +**Step 9: Check Quality** `[AI]` + +Run a quality pass against check context: + +- Review all code changes against the specs +- Fix issues directly +- Ensure lint and typecheck pass + +**Step 10: Complete** `[AI]` + +1. Verify lint and typecheck pass +2. Report what was implemented +3. Remind user to: + - Test the changes + - Commit when ready + - Run `$record-session` to record this session + +--- + +## Continuing Existing Task + +If `get_context.py` shows a current task: + +1. Read the task's `prd.md` to understand the goal +2. Check `task.json` for current status and phase +3. Ask user: "Continue working on <task-name>?" + +If yes, resume from the appropriate step (usually Step 7 or 8). + +--- + +## Skills Reference + +### User Skills `[USER]` + +| Skill | When to Use | +|---------|-------------| +| `$start` | Begin a session (this skill) | +| `$finish-work` | Before committing changes | +| `$record-session` | After completing a task | + +### AI Scripts `[AI]` + +| Script | Purpose | +|--------|---------| +| `python3 ./.trellis/scripts/get_context.py` | Get session context | +| `python3 ./.trellis/scripts/task.py create` | Create task directory | +| `python3 ./.trellis/scripts/task.py init-context` | Initialize jsonl files | +| `python3 ./.trellis/scripts/task.py add-context` | Add spec to jsonl | +| `python3 ./.trellis/scripts/task.py start` | Set current task | +| `python3 ./.trellis/scripts/task.py finish` | Clear current task | +| `python3 ./.trellis/scripts/task.py archive` | Archive completed task | + +### Workflow Phases `[AI]` + +| Phase | Purpose | Context Source | +|-------|---------|----------------| +| research | Analyze codebase | direct repo inspection | +| implement | Write code | `implement.jsonl` | +| check | Review & fix | `check.jsonl` | +| debug | Fix specific issues | `debug.jsonl` | + +--- + +## Key Principle + +> **Code-spec context is injected, not remembered.** +> +> The Task Workflow ensures agents receive relevant code-spec context automatically. +> This is more reliable than hoping the AI "remembers" conventions. diff --git a/.agents/skills/update-spec/SKILL.md b/.agents/skills/update-spec/SKILL.md new file mode 100644 index 00000000..435327b2 --- /dev/null +++ b/.agents/skills/update-spec/SKILL.md @@ -0,0 +1,335 @@ +--- +name: update-spec +description: "Update Code-Spec - Capture Executable Contracts" +--- + +# Update Code-Spec - Capture Executable Contracts + +When you learn something valuable (from debugging, implementing, or discussion), use this skill to update the relevant code-spec documents. + +**Timing**: After completing a task, fixing a bug, or discovering a new pattern + +--- + +## Code-Spec First Rule (CRITICAL) + +In this project, "spec" for implementation work means **code-spec**: +- Executable contracts (not principle-only text) +- Concrete signatures, payload fields, env keys, and boundary behavior +- Testable validation/error behavior + +If the change touches infra or cross-layer contracts, code-spec depth is mandatory. + +Required sections for infra/cross-layer specs: +1. Scope / Trigger +2. Signatures (command/API/DB) +3. Contracts (request/response/env) +4. Validation & Error Matrix +5. Good/Base/Bad Cases +6. Tests Required (with assertion points) +7. Wrong vs Correct (at least one pair) + +--- + +## When to Update Code-Specs + +| Trigger | Example | Target Spec | +|---------|---------|-------------| +| **Implemented a feature** | Added template download with giget | Relevant `backend/` or `frontend/` file | +| **Made a design decision** | Used type field + mapping table for extensibility | Relevant code-spec + "Design Decisions" section | +| **Fixed a bug** | Found a subtle issue with error handling | `backend/error-handling.md` | +| **Discovered a pattern** | Found a better way to structure code | Relevant `backend/` or `frontend/` file | +| **Hit a gotcha** | Learned that X must be done before Y | Relevant code-spec + "Common Mistakes" section | +| **Established a convention** | Team agreed on naming pattern | `quality-guidelines.md` | +| **New thinking trigger** | "Don't forget to check X before doing Y" | `guides/*.md` (as a checklist item, not detailed rules) | + +**Key Insight**: Code-spec updates are NOT just for problems. Every feature implementation contains design decisions and contracts that future AI/developers need to execute safely. + +--- + +## Spec Structure Overview + +``` +.trellis/spec/ +├── backend/ # Backend coding standards +│ ├── index.md # Overview and links +│ └── *.md # Topic-specific guidelines +├── frontend/ # Frontend coding standards +│ ├── index.md # Overview and links +│ └── *.md # Topic-specific guidelines +└── guides/ # Thinking checklists (NOT coding specs!) + ├── index.md # Guide index + └── *.md # Topic-specific guides +``` + +### CRITICAL: Code-Spec vs Guide - Know the Difference + +| Type | Location | Purpose | Content Style | +|------|----------|---------|---------------| +| **Code-Spec** | `backend/*.md`, `frontend/*.md` | Tell AI "how to implement safely" | Signatures, contracts, matrices, cases, test points | +| **Guide** | `guides/*.md` | Help AI "what to think about" | Checklists, questions, pointers to specs | + +**Decision Rule**: Ask yourself: + +- "This is **how to write** the code" → Put in `backend/` or `frontend/` +- "This is **what to consider** before writing" → Put in `guides/` + +**Example**: + +| Learning | Wrong Location | Correct Location | +|----------|----------------|------------------| +| "Use `reconfigure()` not `TextIOWrapper` for Windows stdout" | ❌ `guides/cross-platform-thinking-guide.md` | ✅ `backend/script-conventions.md` | +| "Remember to check encoding when writing cross-platform code" | ❌ `backend/script-conventions.md` | ✅ `guides/cross-platform-thinking-guide.md` | + +**Guides should be short checklists that point to specs**, not duplicate the detailed rules. + +--- + +## Update Process + +### Step 1: Identify What You Learned + +Answer these questions: + +1. **What did you learn?** (Be specific) +2. **Why is it important?** (What problem does it prevent?) +3. **Where does it belong?** (Which spec file?) + +### Step 2: Classify the Update Type + +| Type | Description | Action | +|------|-------------|--------| +| **Design Decision** | Why we chose approach X over Y | Add to "Design Decisions" section | +| **Project Convention** | How we do X in this project | Add to relevant section with examples | +| **New Pattern** | A reusable approach discovered | Add to "Patterns" section | +| **Forbidden Pattern** | Something that causes problems | Add to "Anti-patterns" or "Don't" section | +| **Common Mistake** | Easy-to-make error | Add to "Common Mistakes" section | +| **Convention** | Agreed-upon standard | Add to relevant section | +| **Gotcha** | Non-obvious behavior | Add warning callout | + +### Step 3: Read the Target Code-Spec + +Before editing, read the current code-spec to: +- Understand existing structure +- Avoid duplicating content +- Find the right section for your update + +```bash +cat .trellis/spec/<category>/<file>.md +``` + +### Step 4: Make the Update + +Follow these principles: + +1. **Be Specific**: Include concrete examples, not just abstract rules +2. **Explain Why**: State the problem this prevents +3. **Show Contracts**: Add signatures, payload fields, and error behavior +4. **Show Code**: Add code snippets for key patterns +5. **Keep it Short**: One concept per section + +### Step 5: Update the Index (if needed) + +If you added a new section or the code-spec status changed, update the category's `index.md`. + +--- + +## Update Templates + +### Mandatory Template for Infra/Cross-Layer Work + +```markdown +## Scenario: <name> + +### 1. Scope / Trigger +- Trigger: <why this requires code-spec depth> + +### 2. Signatures +### 3. Contracts +### 4. Validation & Error Matrix +### 5. Good/Base/Bad Cases +### 6. Tests Required +### 7. Wrong vs Correct +#### Wrong +... +#### Correct +... +``` + +### Adding a Design Decision + +```markdown +### Design Decision: [Decision Name] + +**Context**: What problem were we solving? + +**Options Considered**: +1. Option A - brief description +2. Option B - brief description + +**Decision**: We chose Option X because... + +**Example**: +\`\`\`typescript +// How it's implemented +code example +\`\`\` + +**Extensibility**: How to extend this in the future... +``` + +### Adding a Project Convention + +```markdown +### Convention: [Convention Name] + +**What**: Brief description of the convention. + +**Why**: Why we do it this way in this project. + +**Example**: +\`\`\`typescript +// How to follow this convention +code example +\`\`\` + +**Related**: Links to related conventions or specs. +``` + +### Adding a New Pattern + +```markdown +### Pattern Name + +**Problem**: What problem does this solve? + +**Solution**: Brief description of the approach. + +**Example**: +\`\`\` +// Good +code example + +// Bad +code example +\`\`\` + +**Why**: Explanation of why this works better. +``` + +### Adding a Forbidden Pattern + +```markdown +### Don't: Pattern Name + +**Problem**: +\`\`\` +// Don't do this +bad code example +\`\`\` + +**Why it's bad**: Explanation of the issue. + +**Instead**: +\`\`\` +// Do this instead +good code example +\`\`\` +``` + +### Adding a Common Mistake + +```markdown +### Common Mistake: Description + +**Symptom**: What goes wrong + +**Cause**: Why this happens + +**Fix**: How to correct it + +**Prevention**: How to avoid it in the future +``` + +### Adding a Gotcha + +```markdown +> **Warning**: Brief description of the non-obvious behavior. +> +> Details about when this happens and how to handle it. +``` + +--- + +## Interactive Mode + +If you're unsure what to update, answer these prompts: + +1. **What did you just finish?** + - [ ] Fixed a bug + - [ ] Implemented a feature + - [ ] Refactored code + - [ ] Had a discussion about approach + +2. **What did you learn or decide?** + - Design decision (why X over Y) + - Project convention (how we do X) + - Non-obvious behavior (gotcha) + - Better approach (pattern) + +3. **Would future AI/developers need to know this?** + - To understand how the code works → Yes, update spec + - To maintain or extend the feature → Yes, update spec + - To avoid repeating mistakes → Yes, update spec + - Purely one-off implementation detail → Maybe skip + +4. **Which area does it relate to?** + - [ ] Backend code + - [ ] Frontend code + - [ ] Cross-layer data flow + - [ ] Code organization/reuse + - [ ] Quality/testing + +--- + +## Quality Checklist + +Before finishing your code-spec update: + +- [ ] Is the content specific and actionable? +- [ ] Did you include a code example? +- [ ] Did you explain WHY, not just WHAT? +- [ ] Did you include executable signatures/contracts? +- [ ] Did you include validation and error matrix? +- [ ] Did you include Good/Base/Bad cases? +- [ ] Did you include required tests with assertion points? +- [ ] Is it in the right code-spec file? +- [ ] Does it duplicate existing content? +- [ ] Would a new team member understand it? + +--- + +## Relationship to Other Commands + +``` +Development Flow: + Learn something → $update-spec → Knowledge captured + ↑ ↓ + $break-loop ←──────────────────── Future sessions benefit + (deep bug analysis) +``` + +- `$break-loop` - Analyzes bugs deeply, often reveals spec updates needed +- `$update-spec` - Actually makes the updates (this skill) +- `$finish-work` - Reminds you to check if specs need updates + +--- + +## Core Philosophy + +> **Code-specs are living documents. Every debugging session, every "aha moment" is an opportunity to make the implementation contract clearer.** + +The goal is **institutional memory**: +- What one person learns, everyone benefits from +- What AI learns in one session, persists to future sessions +- Mistakes become documented guardrails diff --git a/.claude/agents/check.md b/.claude/agents/check.md new file mode 100644 index 00000000..071aec4e --- /dev/null +++ b/.claude/agents/check.md @@ -0,0 +1,122 @@ +--- +name: check +description: | + Code quality check expert. Reviews code changes against specs and self-fixes issues. +tools: Read, Write, Edit, Bash, Glob, Grep, mcp__exa__web_search_exa, mcp__exa__get_code_context_exa +model: opus +--- +# Check Agent + +You are the Check Agent in the Trellis workflow. + +## Context + +Before checking, read: +- `.trellis/spec/` - Development guidelines +- Pre-commit checklist for quality standards + +## Core Responsibilities + +1. **Get code changes** - Use git diff to get uncommitted code +2. **Check against specs** - Verify code follows guidelines +3. **Self-fix** - Fix issues yourself, not just report them +4. **Run verification** - typecheck and lint + +## Important + +**Fix issues yourself**, don't just report them. + +You have write and edit tools, you can modify code directly. + +--- + +## Workflow + +### Step 1: Get Changes + +```bash +git diff --name-only # List changed files +git diff # View specific changes +``` + +### Step 2: Check Against Specs + +Read relevant specs in `.trellis/spec/` to check code: + +- Does it follow directory structure conventions +- Does it follow naming conventions +- Does it follow code patterns +- Are there missing types +- Are there potential bugs + +### Step 3: Self-Fix + +After finding issues: + +1. Fix the issue directly (use edit tool) +2. Record what was fixed +3. Continue checking other issues + +### Step 4: Run Verification + +Run project's lint and typecheck commands to verify changes. + +If failed, fix issues and re-run. + +--- + +## Completion Markers (Ralph Loop) + +**CRITICAL**: You are in a loop controlled by the Ralph Loop system. +The loop will NOT stop until you output ALL required completion markers. + +Completion markers are generated from `check.jsonl` in the task directory. +Each entry's `reason` field becomes a marker: `{REASON}_FINISH` + +For example, if check.jsonl contains: +```json +{"file": "...", "reason": "TypeCheck"} +{"file": "...", "reason": "Lint"} +{"file": "...", "reason": "CodeReview"} +``` + +You MUST output these markers when each check passes: +- `TYPECHECK_FINISH` - After typecheck passes +- `LINT_FINISH` - After lint passes +- `CODEREVIEW_FINISH` - After code review passes + +If check.jsonl doesn't exist or has no reasons, output: `ALL_CHECKS_FINISH` + +**The loop will block you from stopping until all markers are present in your output.** + +--- + +## Report Format + +```markdown +## Self-Check Complete + +### Files Checked + +- src/components/Feature.tsx +- src/hooks/useFeature.ts + +### Issues Found and Fixed + +1. `<file>:<line>` - <what was fixed> +2. `<file>:<line>` - <what was fixed> + +### Issues Not Fixed + +(If there are issues that cannot be self-fixed, list them here with reasons) + +### Verification Results + +- TypeCheck: Passed TYPECHECK_FINISH +- Lint: Passed LINT_FINISH + +### Summary + +Checked X files, found Y issues, all fixed. +ALL_CHECKS_FINISH +``` diff --git a/.claude/agents/debug.md b/.claude/agents/debug.md new file mode 100644 index 00000000..0108d99f --- /dev/null +++ b/.claude/agents/debug.md @@ -0,0 +1,106 @@ +--- +name: debug +description: | + Issue fixing expert. Understands issues, fixes against specs, and verifies fixes. Precise fixes only. +tools: Read, Write, Edit, Bash, Glob, Grep, mcp__exa__web_search_exa, mcp__exa__get_code_context_exa +model: opus +--- +# Debug Agent + +You are the Debug Agent in the Trellis workflow. + +## Context + +Before debugging, read: +- `.trellis/spec/` - Development guidelines +- Error messages or issue descriptions provided + +## Core Responsibilities + +1. **Understand issues** - Analyze error messages or reported issues +2. **Fix against specs** - Fix issues following dev specs +3. **Verify fixes** - Run typecheck to ensure no new issues +4. **Report results** - Report fix status + +--- + +## Workflow + +### Step 1: Understand Issues + +Parse the issue, categorize by priority: + +- `[P1]` - Must fix (blocking) +- `[P2]` - Should fix (important) +- `[P3]` - Optional fix (nice to have) + +### Step 2: Research if Needed + +If you need additional info: + +```bash +# Check knowledge base +ls .trellis/big-question/ +``` + +### Step 3: Fix One by One + +For each issue: + +1. Locate the exact position +2. Fix following specs +3. Run typecheck to verify + +### Step 4: Verify + +Run project's lint and typecheck commands to verify fixes. + +If fix introduces new issues: + +1. Revert the fix +2. Use a more complete solution +3. Re-verify + +--- + +## Report Format + +```markdown +## Fix Report + +### Issues Fixed + +1. `[P1]` `<file>:<line>` - <what was fixed> +2. `[P2]` `<file>:<line>` - <what was fixed> + +### Issues Not Fixed + +- `<file>:<line>` - <reason why not fixed> + +### Verification + +- TypeCheck: Pass +- Lint: Pass + +### Summary + +Fixed X/Y issues. Z issues require discussion. +``` + +--- + +## Guidelines + +### DO + +- Precise fixes for reported issues +- Follow specs +- Verify each fix + +### DON'T + +- Don't refactor surrounding code +- Don't add new features +- Don't modify unrelated files +- Don't use non-null assertion (`x!` operator) +- Don't execute git commit diff --git a/.claude/agents/dispatch.md b/.claude/agents/dispatch.md new file mode 100644 index 00000000..827c3920 --- /dev/null +++ b/.claude/agents/dispatch.md @@ -0,0 +1,214 @@ +--- +name: dispatch +description: | + Multi-Agent Pipeline main dispatcher. Pure dispatcher. Only responsible for calling subagents and scripts in phase order. +tools: Read, Bash, mcp__exa__web_search_exa, mcp__exa__get_code_context_exa +model: opus +--- +# Dispatch Agent + +You are the Dispatch Agent in the Multi-Agent Pipeline (pure dispatcher). + +## Working Directory Convention + +Current Task is specified by `.trellis/.current-task` file, content is the relative path to task directory. + +Task directory path format: `.trellis/tasks/{MM}-{DD}-{name}/` + +This directory contains all context files for the current task: + +- `task.json` - Task configuration +- `prd.md` - Requirements document +- `info.md` - Technical design (optional) +- `implement.jsonl` - Implement context +- `check.jsonl` - Check context +- `debug.jsonl` - Debug context + +## Core Principles + +1. **You are a pure dispatcher** - Only responsible for calling subagents and scripts in order +2. **You don't read specs/requirements** - Hook will auto-inject all context to subagents +3. **You don't need resume** - Hook injects complete context on each subagent call +4. **You only need simple commands** - Tell subagent "start working" is enough + +--- + +## Startup Flow + +### Step 1: Determine Current Task Directory + +Read `.trellis/.current-task` to get current task directory path: + +```bash +TASK_DIR=$(cat .trellis/.current-task) +# e.g.: .trellis/tasks/02-03-my-feature +``` + +### Step 2: Read Task Configuration + +```bash +cat ${TASK_DIR}/task.json +``` + +Get the `next_action` array, which defines the list of phases to execute. + +### Step 3: Execute in Phase Order + +Execute each step in `phase` order. + +> **Note**: You do NOT need to manually update `current_phase`. The Hook automatically updates it when you call Task with a subagent. + +--- + +## Phase Handling + +> Hook will auto-inject all specs, requirements, and technical design to subagent context. +> Dispatch only needs to issue simple call commands. + +### action: "implement" + +``` +Task( + subagent_type: "implement", + prompt: "Implement the feature described in prd.md in the task directory", + model: "opus", + run_in_background: true +) +``` + +Hook will auto-inject: + +- All spec files from implement.jsonl +- Requirements document (prd.md) +- Technical design (info.md) + +Implement receives complete context and autonomously: read → understand → implement. + +### action: "check" + +``` +Task( + subagent_type: "check", + prompt: "Check code changes, fix issues yourself", + model: "opus", + run_in_background: true +) +``` + +Hook will auto-inject: + +- finish-work.md +- check-cross-layer.md +- check-backend.md +- check-frontend.md +- All spec files from check.jsonl + +### action: "debug" + +``` +Task( + subagent_type: "debug", + prompt: "Fix the issues described in the task context", + model: "opus", + run_in_background: true +) +``` + +Hook will auto-inject: + +- All spec files from debug.jsonl +- Error context if available + +### action: "finish" + +``` +Task( + subagent_type: "check", + prompt: "[finish] Execute final completion check before PR", + model: "opus", + run_in_background: true +) +``` + +**Important**: The `[finish]` marker in prompt triggers different context injection: +- finish-work.md checklist +- update-spec.md (spec update process and templates) +- prd.md for verifying requirements are met + +The finish agent actively updates spec docs when it detects new patterns or contracts in the changes. This is different from regular "check" which has full specs for self-fix loop. + +### action: "create-pr" + +This action creates a Pull Request from the feature branch. Run it via Bash: + +```bash +python3 ./.trellis/scripts/multi_agent/create_pr.py +``` + +This will: +1. Stage and commit all changes (excluding workspace) +2. Push to origin +3. Create a Draft PR using `gh pr create` +4. Update task.json with status="review", pr_url, and current_phase + +**Note**: This is the only action that performs git commit, as it's the final step after all implementation and checks are complete. + +--- + +## Calling Subagents + +### Basic Pattern + +``` +task_id = Task( + subagent_type: "implement", // or "check", "debug" + prompt: "Simple task description", + model: "opus", + run_in_background: true +) + +// Poll for completion +for i in 1..N: + result = TaskOutput(task_id, block=true, timeout=300000) + if result.status == "completed": + break +``` + +### Timeout Settings + +| Phase | Max Time | Poll Count | +|-------|----------|------------| +| implement | 30 min | 6 times | +| check | 15 min | 3 times | +| debug | 20 min | 4 times | + +--- + +## Error Handling + +### Timeout + +If a subagent times out, notify the user and ask for guidance: + +``` +"Subagent {phase} timed out after {time}. Options: +1. Retry the same phase +2. Skip to next phase +3. Abort the pipeline" +``` + +### Subagent Failure + +If a subagent reports failure, read the output and decide: + +- If recoverable: call debug agent to fix +- If not recoverable: notify user and ask for guidance + +--- + +## Key Constraints + +1. **Do not read spec/requirement files directly** - Let Hook inject to subagents +2. **Only commit via create-pr action** - Use `multi_agent/create_pr.py` at the end of pipeline +3. **All subagents should use opus model for complex tasks** +4. **Keep dispatch logic simple** - Complex logic belongs in subagents diff --git a/.claude/agents/implement.md b/.claude/agents/implement.md new file mode 100644 index 00000000..60eaa5d0 --- /dev/null +++ b/.claude/agents/implement.md @@ -0,0 +1,96 @@ +--- +name: implement +description: | + Code implementation expert. Understands specs and requirements, then implements features. No git commit allowed. +tools: Read, Write, Edit, Bash, Glob, Grep, mcp__exa__web_search_exa, mcp__exa__get_code_context_exa +model: opus +--- +# Implement Agent + +You are the Implement Agent in the Trellis workflow. + +## Context + +Before implementing, read: +- `.trellis/workflow.md` - Project workflow +- `.trellis/spec/` - Development guidelines +- Task `prd.md` - Requirements document +- Task `info.md` - Technical design (if exists) + +## Core Responsibilities + +1. **Understand specs** - Read relevant spec files in `.trellis/spec/` +2. **Understand requirements** - Read prd.md and info.md +3. **Implement features** - Write code following specs and design +4. **Self-check** - Ensure code quality +5. **Report results** - Report completion status + +## Forbidden Operations + +**Do NOT execute these git commands:** + +- `git commit` +- `git push` +- `git merge` + +--- + +## Workflow + +### 1. Understand Specs + +Read relevant specs based on task type: + +- Backend: `.trellis/spec/backend/` +- Frontend: `.trellis/spec/frontend/` +- Guides: `.trellis/spec/guides/` + +### 2. Understand Requirements + +Read the task's prd.md and info.md: + +- What are the core requirements +- Key points of technical design +- Which files to modify/create + +### 3. Implement Features + +- Write code following specs and technical design +- Follow existing code patterns +- Only do what's required, no over-engineering + +### 4. Verify + +Run project's lint and typecheck commands to verify changes. + +--- + +## Report Format + +```markdown +## Implementation Complete + +### Files Modified + +- `src/components/Feature.tsx` - New component +- `src/hooks/useFeature.ts` - New hook + +### Implementation Summary + +1. Created Feature component... +2. Added useFeature hook... + +### Verification Results + +- Lint: Passed +- TypeCheck: Passed +``` + +--- + +## Code Standards + +- Follow existing code patterns +- Don't add unnecessary abstractions +- Only do what's required, no over-engineering +- Keep code readable diff --git a/.claude/agents/plan.md b/.claude/agents/plan.md new file mode 100644 index 00000000..5c0d0be9 --- /dev/null +++ b/.claude/agents/plan.md @@ -0,0 +1,396 @@ +--- +name: plan +description: | + Multi-Agent Pipeline planner. Analyzes requirements and produces a fully configured task directory ready for dispatch. +tools: Read, Bash, Glob, Grep, Task +model: opus +--- +# Plan Agent + +You are the Plan Agent in the Multi-Agent Pipeline. + +**Your job**: Evaluate requirements and, if valid, transform them into a fully configured task directory. + +**You have the power to reject** - If a requirement is unclear, incomplete, unreasonable, or potentially harmful, you MUST refuse to proceed and clean up. + +--- + +## Step 0: Evaluate Requirement (CRITICAL) + +Before doing ANY work, evaluate the requirement: + +``` +PLAN_REQUIREMENT = <the requirement from environment> +``` + +### Reject If: + +1. **Unclear or Vague** + - "Make it better" / "Fix the bugs" / "Improve performance" + - No specific outcome defined + - Cannot determine what "done" looks like + +2. **Incomplete Information** + - Missing critical details to implement + - References unknown systems or files + - Depends on decisions not yet made + +3. **Out of Scope for This Project** + - Requirement doesn't match the project's purpose + - Requires changes to external systems + - Not technically feasible with current architecture + +4. **Potentially Harmful** + - Security vulnerabilities (intentional backdoors, data exfiltration) + - Destructive operations without clear justification + - Circumventing access controls + +5. **Too Large / Should Be Split** + - Multiple unrelated features bundled together + - Would require touching too many systems + - Cannot be completed in a reasonable scope + +### If Rejecting: + +1. **Update task.json status to "rejected"**: + ```bash + jq '.status = "rejected"' "$PLAN_TASK_DIR/task.json" > "$PLAN_TASK_DIR/task.json.tmp" \ + && mv "$PLAN_TASK_DIR/task.json.tmp" "$PLAN_TASK_DIR/task.json" + ``` + +2. **Write rejection reason to a file** (so user can see it): + ```bash + cat > "$PLAN_TASK_DIR/REJECTED.md" << 'EOF' + # Plan Rejected + + ## Reason + <category from above> + + ## Details + <specific explanation of why this requirement cannot proceed> + + ## Suggestions + - <what the user should clarify or change> + - <how to make the requirement actionable> + + ## To Retry + + 1. Delete this directory: + rm -rf $PLAN_TASK_DIR + + 2. Run with revised requirement: + python3 ./.trellis/scripts/multi_agent/plan.py --name "<name>" --type "<type>" --requirement "<revised requirement>" + EOF + ``` + +3. **Print summary to stdout** (will be captured in .plan-log): + ``` + === PLAN REJECTED === + + Reason: <category> + Details: <brief explanation> + + See: $PLAN_TASK_DIR/REJECTED.md + ``` + +4. **Exit immediately** - Do not proceed to Step 1. + +**The task directory is kept** with: +- `task.json` (status: "rejected") +- `REJECTED.md` (full explanation) +- `.plan-log` (execution log) + +This allows the user to review why it was rejected. + +### If Accepting: + +Continue to Step 1. The requirement is: +- Clear and specific +- Has a defined outcome +- Is technically feasible +- Is appropriately scoped + +--- + +## Input + +You receive input via environment variables (set by plan.py): + +```bash +PLAN_TASK_NAME # Task name (e.g., "user-auth") +PLAN_DEV_TYPE # Development type: backend | frontend | fullstack +PLAN_REQUIREMENT # Requirement description from user +PLAN_TASK_DIR # Pre-created task directory path +``` + +Read them at startup: + +```bash +echo "Task: $PLAN_TASK_NAME" +echo "Type: $PLAN_DEV_TYPE" +echo "Requirement: $PLAN_REQUIREMENT" +echo "Directory: $PLAN_TASK_DIR" +``` + +## Output (if accepted) + +A complete task directory containing: + +``` +${PLAN_TASK_DIR}/ +├── task.json # Updated with branch, scope, dev_type +├── prd.md # Requirements document +├── implement.jsonl # Implement phase context +├── check.jsonl # Check phase context +└── debug.jsonl # Debug phase context +``` + +--- + +## Workflow (After Acceptance) + +### Step 1: Initialize Context Files + +```bash +python3 ./.trellis/scripts/task.py init-context "$PLAN_TASK_DIR" "$PLAN_DEV_TYPE" +``` + +This creates base jsonl files with standard specs for the dev type. + +### Step 2: Analyze Codebase with Research Agent + +Call research agent to find relevant specs and code patterns: + +``` +Task( + subagent_type: "research", + prompt: "Analyze what specs and code patterns are needed for this task. + +Task: ${PLAN_REQUIREMENT} +Dev Type: ${PLAN_DEV_TYPE} + +Instructions: +1. Search .trellis/spec/ for relevant spec files +2. Search the codebase for related modules and patterns +3. Identify files that should be added to jsonl context + +Output format (use exactly this format): + +## implement.jsonl +- path: <relative file path>, reason: <why needed> +- path: <relative file path>, reason: <why needed> + +## check.jsonl +- path: <relative file path>, reason: <why needed> + +## debug.jsonl +- path: <relative file path>, reason: <why needed> + +## Suggested Scope +<single word for commit scope, e.g., auth, api, ui> + +## Technical Notes +<any important technical considerations for prd.md>", + model: "opus" +) +``` + +### Step 3: Add Context Entries + +Parse research agent output and add entries to jsonl files: + +```bash +# For each entry in implement.jsonl section: +python3 ./.trellis/scripts/task.py add-context "$PLAN_TASK_DIR" implement "<path>" "<reason>" + +# For each entry in check.jsonl section: +python3 ./.trellis/scripts/task.py add-context "$PLAN_TASK_DIR" check "<path>" "<reason>" + +# For each entry in debug.jsonl section: +python3 ./.trellis/scripts/task.py add-context "$PLAN_TASK_DIR" debug "<path>" "<reason>" +``` + +### Step 4: Write prd.md + +Create the requirements document: + +```bash +cat > "$PLAN_TASK_DIR/prd.md" << 'EOF' +# Task: ${PLAN_TASK_NAME} + +## Overview +[Brief description of what this feature does] + +## Requirements +- [Requirement 1] +- [Requirement 2] +- ... + +## Acceptance Criteria +- [ ] [Criterion 1] +- [ ] [Criterion 2] +- ... + +## Technical Notes +[Any technical considerations from research agent] + +## Out of Scope +- [What this feature does NOT include] +EOF +``` + +**Guidelines for prd.md**: +- Be specific and actionable +- Include acceptance criteria that can be verified +- Add technical notes from research agent +- Define what's out of scope to prevent scope creep + +### Step 5: Configure Task Metadata + +```bash +# Set branch name +python3 ./.trellis/scripts/task.py set-branch "$PLAN_TASK_DIR" "feature/${PLAN_TASK_NAME}" + +# Set scope (from research agent suggestion) +python3 ./.trellis/scripts/task.py set-scope "$PLAN_TASK_DIR" "<scope>" + +# Update dev_type in task.json +jq --arg type "$PLAN_DEV_TYPE" '.dev_type = $type' \ + "$PLAN_TASK_DIR/task.json" > "$PLAN_TASK_DIR/task.json.tmp" \ + && mv "$PLAN_TASK_DIR/task.json.tmp" "$PLAN_TASK_DIR/task.json" +``` + +### Step 6: Validate Configuration + +```bash +python3 ./.trellis/scripts/task.py validate "$PLAN_TASK_DIR" +``` + +If validation fails, fix the invalid paths and re-validate. + +### Step 7: Output Summary + +Print a summary for the caller: + +```bash +echo "=== Plan Complete ===" +echo "Task Directory: $PLAN_TASK_DIR" +echo "" +echo "Files created:" +ls -la "$PLAN_TASK_DIR" +echo "" +echo "Context summary:" +python3 ./.trellis/scripts/task.py list-context "$PLAN_TASK_DIR" +echo "" +echo "Ready for: python3 ./.trellis/scripts/multi_agent/start.py $PLAN_TASK_DIR" +``` + +--- + +## Key Principles + +1. **Reject early, reject clearly** - Don't waste time on bad requirements +2. **Research before configure** - Always call research agent to understand the codebase +3. **Validate all paths** - Every file in jsonl must exist +4. **Be specific in prd.md** - Vague requirements lead to wrong implementations +5. **Include acceptance criteria** - Check agent needs to verify something concrete +6. **Set appropriate scope** - This affects commit message format + +--- + +## Error Handling + +### Research Agent Returns No Results + +If research agent finds no relevant specs: +- Use only the base specs from init-context +- Add a note in prd.md that this is a new area without existing patterns + +### Path Not Found + +If add-context fails because path doesn't exist: +- Skip that entry +- Log a warning +- Continue with other entries + +### Validation Fails + +If final validation fails: +- Read the error output +- Remove invalid entries from jsonl files +- Re-validate + +--- + +## Examples + +### Example: Accepted Requirement + +``` +Input: + PLAN_TASK_NAME = "add-rate-limiting" + PLAN_DEV_TYPE = "backend" + PLAN_REQUIREMENT = "Add rate limiting to API endpoints using a sliding window algorithm. Limit to 100 requests per minute per IP. Return 429 status when exceeded." + +Result: ACCEPTED - Clear, specific, has defined behavior + +Output: + .trellis/tasks/02-03-add-rate-limiting/ + ├── task.json # branch: feature/add-rate-limiting, scope: api + ├── prd.md # Detailed requirements with acceptance criteria + ├── implement.jsonl # Backend specs + existing middleware patterns + ├── check.jsonl # Quality guidelines + API testing specs + └── debug.jsonl # Error handling specs +``` + +### Example: Rejected - Vague Requirement + +``` +Input: + PLAN_REQUIREMENT = "Make the API faster" + +Result: REJECTED + +=== PLAN REJECTED === + +Reason: Unclear or Vague + +Details: +"Make the API faster" does not specify: +- Which endpoints need optimization +- Current performance baseline +- Target performance metrics +- Acceptable trade-offs (memory, complexity) + +Suggestions: +- Identify specific slow endpoints with response times +- Define target latency (e.g., "GET /users should respond in <100ms") +- Specify if caching, query optimization, or architecture changes are acceptable +``` + +### Example: Rejected - Too Large + +``` +Input: + PLAN_REQUIREMENT = "Add user authentication, authorization, password reset, 2FA, OAuth integration, and audit logging" + +Result: REJECTED + +=== PLAN REJECTED === + +Reason: Too Large / Should Be Split + +Details: +This requirement bundles 6 distinct features that should be implemented separately: +1. User authentication (login/logout) +2. Authorization (roles/permissions) +3. Password reset flow +4. Two-factor authentication +5. OAuth integration +6. Audit logging + +Suggestions: +- Start with basic authentication first +- Create separate features for each capability +- Consider dependencies (auth before authz, etc.) +``` diff --git a/.claude/agents/research.md b/.claude/agents/research.md new file mode 100644 index 00000000..659d59c6 --- /dev/null +++ b/.claude/agents/research.md @@ -0,0 +1,120 @@ +--- +name: research +description: | + Code and tech search expert. Pure research, no code modifications. Finds files, patterns, and tech solutions. +tools: Read, Glob, Grep, mcp__exa__web_search_exa, mcp__exa__get_code_context_exa, Skill, mcp__chrome-devtools__* +model: opus +--- +# Research Agent + +You are the Research Agent in the Trellis workflow. + +## Core Principle + +**You do one thing: find and explain information.** + +You are a documenter, not a reviewer. Your job is to help get the information needed. + +--- + +## Core Responsibilities + +### 1. Internal Search (Project Code) + +| Search Type | Goal | Tools | +|-------------|------|-------| +| **WHERE** | Locate files/components | Glob, Grep | +| **HOW** | Understand code logic | Read, Grep | +| **PATTERN** | Discover existing patterns | Grep, Read | + +### 2. External Search (Tech Solutions) + +Use web search for best practices and code examples. + +--- + +## Strict Boundaries + +### Only Allowed + +- Describe **what exists** +- Describe **where it is** +- Describe **how it works** +- Describe **how components interact** + +### Forbidden (unless explicitly asked) + +- Suggest improvements +- Criticize implementation +- Recommend refactoring +- Modify any files +- Execute git commands + +--- + +## Workflow + +### Step 1: Understand Search Request + +Analyze the query, determine: + +- Search type (internal/external/mixed) +- Search scope (global/specific directory) +- Expected output (file list/code patterns/tech solutions) + +### Step 2: Execute Search + +Execute multiple independent searches in parallel for efficiency. + +### Step 3: Organize Results + +Output structured results in report format. + +--- + +## Report Format + +```markdown +## Search Results + +### Query + +{original query} + +### Files Found + +| File Path | Description | +|-----------|-------------| +| `src/services/xxx.ts` | Main implementation | +| `src/types/xxx.ts` | Type definitions | + +### Code Pattern Analysis + +{Describe discovered patterns, cite specific files and line numbers} + +### Related Spec Documents + +- `.trellis/spec/xxx.md` - {description} + +### Not Found + +{If some content was not found, explain} +``` + +--- + +## Guidelines + +### DO + +- Provide specific file paths and line numbers +- Quote actual code snippets +- Distinguish "definitely found" and "possibly related" +- Explain search scope and limitations + +### DON'T + +- Don't guess uncertain info +- Don't omit important search results +- Don't add improvement suggestions in report (unless explicitly asked) +- Don't modify any files diff --git a/.claude/commands/trellis/before-backend-dev.md b/.claude/commands/trellis/before-backend-dev.md new file mode 100644 index 00000000..7dfcd365 --- /dev/null +++ b/.claude/commands/trellis/before-backend-dev.md @@ -0,0 +1,13 @@ +Read the backend development guidelines before starting your development task. + +Execute these steps: +1. Read `.trellis/spec/backend/index.md` to understand available guidelines +2. Based on your task, read the relevant guideline files: + - Database work → `.trellis/spec/backend/database-guidelines.md` + - Error handling → `.trellis/spec/backend/error-handling.md` + - Logging → `.trellis/spec/backend/logging-guidelines.md` + - Type questions → `.trellis/spec/backend/type-safety.md` +3. Understand the coding standards and patterns you need to follow +4. Then proceed with your development plan + +This step is **mandatory** before writing any backend code. diff --git a/.claude/commands/trellis/before-frontend-dev.md b/.claude/commands/trellis/before-frontend-dev.md new file mode 100644 index 00000000..9687edc1 --- /dev/null +++ b/.claude/commands/trellis/before-frontend-dev.md @@ -0,0 +1,13 @@ +Read the frontend development guidelines before starting your development task. + +Execute these steps: +1. Read `.trellis/spec/frontend/index.md` to understand available guidelines +2. Based on your task, read the relevant guideline files: + - Component work → `.trellis/spec/frontend/component-guidelines.md` + - Hook work → `.trellis/spec/frontend/hook-guidelines.md` + - State management → `.trellis/spec/frontend/state-management.md` + - Type questions → `.trellis/spec/frontend/type-safety.md` +3. Understand the coding standards and patterns you need to follow +4. Then proceed with your development plan + +This step is **mandatory** before writing any frontend code. diff --git a/.claude/commands/trellis/brainstorm.md b/.claude/commands/trellis/brainstorm.md new file mode 100644 index 00000000..bc2b8afe --- /dev/null +++ b/.claude/commands/trellis/brainstorm.md @@ -0,0 +1,487 @@ +# Brainstorm - Requirements Discovery (AI Coding Enhanced) + +Guide AI through collaborative requirements discovery **before implementation**, optimized for AI coding workflows: + +* **Task-first** (capture ideas immediately) +* **Action-before-asking** (reduce low-value questions) +* **Research-first** for technical choices (avoid asking users to invent options) +* **Diverge → Converge** (expand thinking, then lock MVP) + +--- + +## When to Use + +Triggered from `/trellis:start` when the user describes a development task, especially when: + +* requirements are unclear or evolving +* there are multiple valid implementation paths +* trade-offs matter (UX, reliability, maintainability, cost, performance) +* the user might not know the best options up front + +--- + +## Core Principles (Non-negotiable) + +1. **Task-first (capture early)** + Always ensure a task exists at the start so the user's ideas are recorded immediately. + +2. **Action before asking** + If you can derive the answer from repo code, docs, configs, conventions, or quick research — do that first. + +3. **One question per message** + Never overwhelm the user with a list of questions. Ask one, update PRD, repeat. + +4. **Prefer concrete options** + For preference/decision questions, present 2–3 feasible, specific approaches with trade-offs. + +5. **Research-first for technical choices** + If the decision depends on industry conventions / similar tools / established patterns, do research first, then propose options. + +6. **Diverge → Converge** + After initial understanding, proactively consider future evolution, related scenarios, and failure/edge cases — then converge to an MVP with explicit out-of-scope. + +7. **No meta questions** + Do not ask "should I search?" or "can you paste the code so I can continue?" + If you need information: search/inspect. If blocked: ask the minimal blocking question. + +--- + +## Step 0: Ensure Task Exists (ALWAYS) + +Before any Q&A, ensure a task exists. If none exists, create one immediately. + +* Use a **temporary working title** derived from the user's message. +* It's OK if the title is imperfect — refine later in PRD. + +```bash +TASK_DIR=$(python3 ./.trellis/scripts/task.py create "brainstorm: <short goal>" --slug <auto>) +``` + +Create/seed `prd.md` immediately with what you know: + +```markdown +# brainstorm: <short goal> + +## Goal + +<one paragraph: what + why> + +## What I already know + +* <facts from user message> +* <facts discovered from repo/docs> + +## Assumptions (temporary) + +* <assumptions to validate> + +## Open Questions + +* <ONLY Blocking / Preference questions; keep list short> + +## Requirements (evolving) + +* <start with what is known> + +## Acceptance Criteria (evolving) + +* [ ] <testable criterion> + +## Definition of Done (team quality bar) + +* Tests added/updated (unit/integration where appropriate) +* Lint / typecheck / CI green +* Docs/notes updated if behavior changes +* Rollout/rollback considered if risky + +## Out of Scope (explicit) + +* <what we will not do in this task> + +## Technical Notes + +* <files inspected, constraints, links, references> +* <research notes summary if applicable> +``` + +--- + +## Step 1: Auto-Context (DO THIS BEFORE ASKING QUESTIONS) + +Before asking questions like "what does the code look like?", gather context yourself: + +### Repo inspection checklist + +* Identify likely modules/files impacted +* Locate existing patterns (similar features, conventions, error handling style) +* Check configs, scripts, existing command definitions +* Note any constraints (runtime, dependency policy, build tooling) + +### Documentation checklist + +* Look for existing PRDs/specs/templates +* Look for command usage examples, README, ADRs if any + +Write findings into PRD: + +* Add to `What I already know` +* Add constraints/links to `Technical Notes` + +--- + +## Step 2: Classify Complexity (still useful, not gating task creation) + +| Complexity | Criteria | Action | +| ------------ | ------------------------------------------------------ | ------------------------------------------- | +| **Trivial** | Single-line fix, typo, obvious change | Skip brainstorm, implement directly | +| **Simple** | Clear goal, 1–2 files, scope well-defined | Ask 1 confirm question, then implement | +| **Moderate** | Multiple files, some ambiguity | Light brainstorm (2–3 high-value questions) | +| **Complex** | Vague goal, architectural choices, multiple approaches | Full brainstorm | + +> Note: Task already exists from Step 0. Classification only affects depth of brainstorming. + +--- + +## Step 3: Question Gate (Ask ONLY high-value questions) + +Before asking ANY question, run the following gate: + +### Gate A — Can I derive this without the user? + +If answer is available via: + +* repo inspection (code/config) +* docs/specs/conventions +* quick market/OSS research + +→ **Do not ask.** Fetch it, summarize, update PRD. + +### Gate B — Is this a meta/lazy question? + +Examples: + +* "Should I search?" +* "Can you paste the code so I can proceed?" +* "What does the code look like?" (when repo is available) + +→ **Do not ask.** Take action. + +### Gate C — What type of question is it? + +* **Blocking**: cannot proceed without user input +* **Preference**: multiple valid choices, depends on product/UX/risk preference +* **Derivable**: should be answered by inspection/research + +→ Only ask **Blocking** or **Preference**. + +--- + +## Step 4: Research-first Mode (Mandatory for technical choices) + +### Trigger conditions (any → research-first) + +* The task involves selecting an approach, library, protocol, framework, template system, plugin mechanism, or CLI UX convention +* The user asks for "best practice", "how others do it", "recommendation" +* The user can't reasonably enumerate options + +### Research steps + +1. Identify 2–4 comparable tools/patterns +2. Summarize common conventions and why they exist +3. Map conventions onto our repo constraints +4. Produce **2–3 feasible approaches** for our project + +### Research output format (PRD) + +Add a section in PRD (either within Technical Notes or as its own): + +```markdown +## Research Notes + +### What similar tools do + +* ... +* ... + +### Constraints from our repo/project + +* ... + +### Feasible approaches here + +**Approach A: <name>** (Recommended) + +* How it works: +* Pros: +* Cons: + +**Approach B: <name>** + +* How it works: +* Pros: +* Cons: + +**Approach C: <name>** (optional) + +* ... +``` + +Then ask **one** preference question: + +* "Which approach do you prefer: A / B / C (or other)?" + +--- + +## Step 5: Expansion Sweep (DIVERGE) — Required after initial understanding + +After you can summarize the goal, proactively broaden thinking before converging. + +### Expansion categories (keep to 1–2 bullets each) + +1. **Future evolution** + + * What might this feature become in 1–3 months? + * What extension points are worth preserving now? + +2. **Related scenarios** + + * What adjacent commands/flows should remain consistent with this? + * Are there parity expectations (create vs update, import vs export, etc.)? + +3. **Failure & edge cases** + + * Conflicts, offline/network failure, retries, idempotency, compatibility, rollback + * Input validation, security boundaries, permission checks + +### Expansion message template (to user) + +```markdown +I understand you want to implement: <current goal>. + +Before diving into design, let me quickly diverge to consider three categories (to avoid rework later): + +1. Future evolution: <1–2 bullets> +2. Related scenarios: <1–2 bullets> +3. Failure/edge cases: <1–2 bullets> + +For this MVP, which would you like to include (or none)? + +1. Current requirement only (minimal viable) +2. Add <X> (reserve for future extension) +3. Add <Y> (improve robustness/consistency) +4. Other: describe your preference +``` + +Then update PRD: + +* What's in MVP → `Requirements` +* What's excluded → `Out of Scope` + +--- + +## Step 6: Q&A Loop (CONVERGE) + +### Rules + +* One question per message +* Prefer multiple-choice when possible +* After each user answer: + + * Update PRD immediately + * Move answered items from `Open Questions` → `Requirements` + * Update `Acceptance Criteria` with testable checkboxes + * Clarify `Out of Scope` + +### Question priority (recommended) + +1. **MVP scope boundary** (what is included/excluded) +2. **Preference decisions** (after presenting concrete options) +3. **Failure/edge behavior** (only for MVP-critical paths) +4. **Success metrics & Acceptance Criteria** (what proves it works) + +### Preferred question format (multiple choice) + +```markdown +For <topic>, which approach do you prefer? + +1. **Option A** — <what it means + trade-off> +2. **Option B** — <what it means + trade-off> +3. **Option C** — <what it means + trade-off> +4. **Other** — describe your preference +``` + +--- + +## Step 7: Propose Approaches + Record Decisions (Complex tasks) + +After requirements are clear enough, propose 2–3 approaches (if not already done via research-first): + +```markdown +Based on current information, here are 2–3 feasible approaches: + +**Approach A: <name>** (Recommended) + +* How: +* Pros: +* Cons: + +**Approach B: <name>** + +* How: +* Pros: +* Cons: + +Which direction do you prefer? +``` + +Record the outcome in PRD as an ADR-lite section: + +```markdown +## Decision (ADR-lite) + +**Context**: Why this decision was needed +**Decision**: Which approach was chosen +**Consequences**: Trade-offs, risks, potential future improvements +``` + +--- + +## Step 8: Final Confirmation + Implementation Plan + +When open questions are resolved, confirm complete requirements with a structured summary: + +### Final confirmation format + +```markdown +Here's my understanding of the complete requirements: + +**Goal**: <one sentence> + +**Requirements**: + +* ... +* ... + +**Acceptance Criteria**: + +* [ ] ... +* [ ] ... + +**Definition of Done**: + +* ... + +**Out of Scope**: + +* ... + +**Technical Approach**: +<brief summary + key decisions> + +**Implementation Plan (small PRs)**: + +* PR1: <scaffolding + tests + minimal plumbing> +* PR2: <core behavior> +* PR3: <edge cases + docs + cleanup> + +Does this look correct? If yes, I'll proceed with implementation. +``` + +### Subtask Decomposition (Complex Tasks) + +For complex tasks with multiple independent work items, create subtasks: + +```bash +# Create child tasks +CHILD1=$(python3 ./.trellis/scripts/task.py create "Child task 1" --slug child1 --parent "$TASK_DIR") +CHILD2=$(python3 ./.trellis/scripts/task.py create "Child task 2" --slug child2 --parent "$TASK_DIR") + +# Or link existing tasks +python3 ./.trellis/scripts/task.py add-subtask "$TASK_DIR" "$CHILD_DIR" +``` + +--- + +## PRD Target Structure (final) + +`prd.md` should converge to: + +```markdown +# <Task Title> + +## Goal + +<why + what> + +## Requirements + +* ... + +## Acceptance Criteria + +* [ ] ... + +## Definition of Done + +* ... + +## Technical Approach + +<key design + decisions> + +## Decision (ADR-lite) + +Context / Decision / Consequences + +## Out of Scope + +* ... + +## Technical Notes + +<constraints, references, files, research notes> +``` + +--- + +## Anti-Patterns (Hard Avoid) + +* Asking user for code/context that can be derived from repo +* Asking user to choose an approach before presenting concrete options +* Meta questions about whether to research +* Staying narrowly on the initial request without considering evolution/edges +* Letting brainstorming drift without updating PRD + +--- + +## Integration with Start Workflow + +After brainstorm completes (Step 8 confirmation approved), the flow continues to the Task Workflow's **Phase 2: Prepare for Implementation**: + +```text +Brainstorm + Step 0: Create task directory + seed PRD + Step 1–7: Discover requirements, research, converge + Step 8: Final confirmation → user approves + ↓ +Task Workflow Phase 2 (Prepare for Implementation) + Code-Spec Depth Check (if applicable) + → Research codebase (based on confirmed PRD) + → Configure code-spec context (jsonl files) + → Activate task + ↓ +Task Workflow Phase 3 (Execute) + Implement → Check → Complete +``` + +The task directory and PRD already exist from brainstorm, so Phase 1 of the Task Workflow is skipped entirely. + +--- + +## Related Commands + +| Command | When to Use | +|---------|-------------| +| `/trellis:start` | Entry point that triggers brainstorm | +| `/trellis:finish-work` | After implementation is complete | +| `/trellis:update-spec` | If new patterns emerge during work | diff --git a/.claude/commands/trellis/break-loop.md b/.claude/commands/trellis/break-loop.md new file mode 100644 index 00000000..99057513 --- /dev/null +++ b/.claude/commands/trellis/break-loop.md @@ -0,0 +1,125 @@ +# Break the Loop - Deep Bug Analysis + +When debug is complete, use this command for deep analysis to break the "fix bug -> forget -> repeat" cycle. + +--- + +## Analysis Framework + +Analyze the bug you just fixed from these 5 dimensions: + +### 1. Root Cause Category + +Which category does this bug belong to? + +| Category | Characteristics | Example | +|----------|-----------------|---------| +| **A. Missing Spec** | No documentation on how to do it | New feature without checklist | +| **B. Cross-Layer Contract** | Interface between layers unclear | API returns different format than expected | +| **C. Change Propagation Failure** | Changed one place, missed others | Changed function signature, missed call sites | +| **D. Test Coverage Gap** | Unit test passes, integration fails | Works alone, breaks when combined | +| **E. Implicit Assumption** | Code relies on undocumented assumption | Timestamp seconds vs milliseconds | + +### 2. Why Fixes Failed (if applicable) + +If you tried multiple fixes before succeeding, analyze each failure: + +- **Surface Fix**: Fixed symptom, not root cause +- **Incomplete Scope**: Found root cause, didn't cover all cases +- **Tool Limitation**: grep missed it, type check wasn't strict +- **Mental Model**: Kept looking in same layer, didn't think cross-layer + +### 3. Prevention Mechanisms + +What mechanisms would prevent this from happening again? + +| Type | Description | Example | +|------|-------------|---------| +| **Documentation** | Write it down so people know | Update thinking guide | +| **Architecture** | Make the error impossible structurally | Type-safe wrappers | +| **Compile-time** | TypeScript strict, no any | Signature change causes compile error | +| **Runtime** | Monitoring, alerts, scans | Detect orphan entities | +| **Test Coverage** | E2E tests, integration tests | Verify full flow | +| **Code Review** | Checklist, PR template | "Did you check X?" | + +### 4. Systematic Expansion + +What broader problems does this bug reveal? + +- **Similar Issues**: Where else might this problem exist? +- **Design Flaw**: Is there a fundamental architecture issue? +- **Process Flaw**: Is there a development process improvement? +- **Knowledge Gap**: Is the team missing some understanding? + +### 5. Knowledge Capture + +Solidify insights into the system: + +- [ ] Update `.trellis/spec/guides/` thinking guides +- [ ] Update `.trellis/spec/backend/` or `frontend/` docs +- [ ] Create issue record (if applicable) +- [ ] Create feature ticket for root fix +- [ ] Update check commands if needed + +--- + +## Output Format + +Please output analysis in this format: + +```markdown +## Bug Analysis: [Short Description] + +### 1. Root Cause Category +- **Category**: [A/B/C/D/E] - [Category Name] +- **Specific Cause**: [Detailed description] + +### 2. Why Fixes Failed (if applicable) +1. [First attempt]: [Why it failed] +2. [Second attempt]: [Why it failed] +... + +### 3. Prevention Mechanisms +| Priority | Mechanism | Specific Action | Status | +|----------|-----------|-----------------|--------| +| P0 | ... | ... | TODO/DONE | + +### 4. Systematic Expansion +- **Similar Issues**: [List places with similar problems] +- **Design Improvement**: [Architecture-level suggestions] +- **Process Improvement**: [Development process suggestions] + +### 5. Knowledge Capture +- [ ] [Documents to update / tickets to create] +``` + +--- + +## Core Philosophy + +> **The value of debugging is not in fixing the bug, but in making this class of bugs never happen again.** + +Three levels of insight: +1. **Tactical**: How to fix THIS bug +2. **Strategic**: How to prevent THIS CLASS of bugs +3. **Philosophical**: How to expand thinking patterns + +30 minutes of analysis saves 30 hours of future debugging. + +--- + +## After Analysis: Immediate Actions + +**IMPORTANT**: After completing the analysis above, you MUST immediately: + +1. **Update spec/guides** - Don't just list TODOs, actually update the relevant files: + - If it's a cross-platform issue → update `cross-platform-thinking-guide.md` + - If it's a cross-layer issue → update `cross-layer-thinking-guide.md` + - If it's a code reuse issue → update `code-reuse-thinking-guide.md` + - If it's domain-specific → update `backend/*.md` or `frontend/*.md` + +2. **Sync templates** - After updating `.trellis/spec/`, sync to `src/templates/markdown/spec/` + +3. **Commit the spec updates** - This is the primary output, not just the analysis text + +> **The analysis is worthless if it stays in chat. The value is in the updated specs.** diff --git a/.claude/commands/trellis/check-backend.md b/.claude/commands/trellis/check-backend.md new file mode 100644 index 00000000..886f5c9f --- /dev/null +++ b/.claude/commands/trellis/check-backend.md @@ -0,0 +1,13 @@ +Check if the code you just wrote follows the backend development guidelines. + +Execute these steps: +1. Run `git status` to see modified files +2. Read `.trellis/spec/backend/index.md` to understand which guidelines apply +3. Based on what you changed, read the relevant guideline files: + - Database changes → `.trellis/spec/backend/database-guidelines.md` + - Error handling → `.trellis/spec/backend/error-handling.md` + - Logging changes → `.trellis/spec/backend/logging-guidelines.md` + - Type changes → `.trellis/spec/backend/type-safety.md` + - Any changes → `.trellis/spec/backend/quality-guidelines.md` +4. Review your code against the guidelines +5. Report any violations and fix them if found diff --git a/.claude/commands/trellis/check-cross-layer.md b/.claude/commands/trellis/check-cross-layer.md new file mode 100644 index 00000000..591d39b5 --- /dev/null +++ b/.claude/commands/trellis/check-cross-layer.md @@ -0,0 +1,153 @@ +# Cross-Layer Check + +Check if your changes considered all dimensions. Most bugs come from "didn't think of it", not lack of technical skill. + +> **Note**: This is a **post-implementation** safety net. Ideally, read the [Pre-Implementation Checklist](.trellis/spec/guides/pre-implementation-checklist.md) **before** writing code. + +--- + +## Related Documents + +| Document | Purpose | Timing | +|----------|---------|--------| +| [Pre-Implementation Checklist](.trellis/spec/guides/pre-implementation-checklist.md) | Questions before coding | **Before** writing code | +| [Code Reuse Thinking Guide](.trellis/spec/guides/code-reuse-thinking-guide.md) | Pattern recognition | During implementation | +| **`/trellis:check-cross-layer`** (this) | Verification check | **After** implementation | + +--- + +## Execution Steps + +### 1. Identify Change Scope + +```bash +git status +git diff --name-only +``` + +### 2. Select Applicable Check Dimensions + +Based on your change type, execute relevant checks below: + +--- + +## Dimension A: Cross-Layer Data Flow (Required when 3+ layers) + +**Trigger**: Changes involve 3 or more layers + +| Layer | Common Locations | +|-------|------------------| +| API/Routes | `routes/`, `api/`, `handlers/`, `controllers/` | +| Service/Business Logic | `services/`, `lib/`, `core/`, `domain/` | +| Database/Storage | `db/`, `models/`, `repositories/`, `schema/` | +| UI/Presentation | `components/`, `views/`, `templates/`, `pages/` | +| Utility | `utils/`, `helpers/`, `common/` | + +**Checklist**: +- [ ] Read flow: Database -> Service -> API -> UI +- [ ] Write flow: UI -> API -> Service -> Database +- [ ] Types/schemas correctly passed between layers? +- [ ] Errors properly propagated to caller? +- [ ] Loading/pending states handled at each layer? + +**Detailed Guide**: `.trellis/spec/guides/cross-layer-thinking-guide.md` + +--- + +## Dimension B: Code Reuse (Required when modifying constants/config) + +**Trigger**: +- Modifying UI constants (label, icon, color) +- Modifying any hardcoded value +- Seeing similar code in multiple places +- Creating a new utility/helper function +- Just finished batch modifications across files + +**Checklist**: +- [ ] Search first: How many places define this value? + ```bash + # Search in source files (adjust extensions for your project) + grep -r "value-to-change" src/ + ``` +- [ ] If 2+ places define same value -> Should extract to shared constant +- [ ] After modification, all usage sites updated? +- [ ] If creating utility: Does similar utility already exist? + +**Detailed Guide**: `.trellis/spec/guides/code-reuse-thinking-guide.md` + +--- + +## Dimension B2: New Utility Functions + +**Trigger**: About to create a new utility/helper function + +**Checklist**: +- [ ] Search for existing similar utilities first + ```bash + grep -r "functionNamePattern" src/ + ``` +- [ ] If similar exists, can you extend it instead? +- [ ] If creating new, is it in the right location (shared vs domain-specific)? + +--- + +## Dimension B3: After Batch Modifications + +**Trigger**: Just modified similar patterns in multiple files + +**Checklist**: +- [ ] Did you check ALL files with similar patterns? + ```bash + grep -r "patternYouChanged" src/ + ``` +- [ ] Any files missed that should also be updated? +- [ ] Should this pattern be abstracted to prevent future duplication? + +--- + +## Dimension C: Import/Dependency Paths (Required when creating new files) + +**Trigger**: Creating new source files + +**Checklist**: +- [ ] Using correct import paths (relative vs absolute)? +- [ ] No circular dependencies? +- [ ] Consistent with project's module organization? + +--- + +## Dimension D: Same-Layer Consistency + +**Trigger**: +- Modifying display logic or formatting +- Same domain concept used in multiple places + +**Checklist**: +- [ ] Search for other places using same concept + ```bash + grep -r "ConceptName" src/ + ``` +- [ ] Are these usages consistent? +- [ ] Should they share configuration/constants? + +--- + +## Common Issues Quick Reference + +| Issue | Root Cause | Prevention | +|-------|------------|------------| +| Changed one place, missed others | Didn't search impact scope | `grep` before changing | +| Data lost at some layer | Didn't check data flow | Trace data source to destination | +| Type/schema mismatch | Cross-layer types inconsistent | Use shared type definitions | +| UI/output inconsistent | Same concept in multiple places | Extract shared constants | +| Similar utility exists | Didn't search first | Search before creating | +| Batch fix incomplete | Didn't verify all occurrences | grep after fixing | + +--- + +## Output + +Report: +1. Which dimensions your changes involve +2. Check results for each dimension +3. Issues found and fix suggestions diff --git a/.claude/commands/trellis/check-frontend.md b/.claude/commands/trellis/check-frontend.md new file mode 100644 index 00000000..3771ae3a --- /dev/null +++ b/.claude/commands/trellis/check-frontend.md @@ -0,0 +1,13 @@ +Check if the code you just wrote follows the frontend development guidelines. + +Execute these steps: +1. Run `git status` to see modified files +2. Read `.trellis/spec/frontend/index.md` to understand which guidelines apply +3. Based on what you changed, read the relevant guideline files: + - Component changes → `.trellis/spec/frontend/component-guidelines.md` + - Hook changes → `.trellis/spec/frontend/hook-guidelines.md` + - State changes → `.trellis/spec/frontend/state-management.md` + - Type changes → `.trellis/spec/frontend/type-safety.md` + - Any changes → `.trellis/spec/frontend/quality-guidelines.md` +4. Review your code against the guidelines +5. Report any violations and fix them if found diff --git a/.claude/commands/trellis/create-command.md b/.claude/commands/trellis/create-command.md new file mode 100644 index 00000000..121d37f9 --- /dev/null +++ b/.claude/commands/trellis/create-command.md @@ -0,0 +1,154 @@ +# Create New Slash Command + +Create a new slash command in both `.cursor/commands/` (with `trellis-` prefix) and `.claude/commands/trellis/` directories based on user requirements. + +## Usage + +``` +/trellis:create-command <command-name> <description> +``` + +**Example**: +``` +/trellis:create-command review-pr Check PR code changes against project guidelines +``` + +## Execution Steps + +### 1. Parse Input + +Extract from user input: +- **Command name**: Use kebab-case (e.g., `review-pr`) +- **Description**: What the command should accomplish + +### 2. Analyze Requirements + +Determine command type based on description: +- **Initialization**: Read docs, establish context +- **Pre-development**: Read guidelines, check dependencies +- **Code check**: Validate code quality and guideline compliance +- **Recording**: Record progress, questions, structure changes +- **Generation**: Generate docs, code templates + +### 3. Generate Command Content + +Based on command type, generate appropriate content: + +**Simple command** (1-3 lines): +```markdown +Concise instruction describing what to do +``` + +**Complex command** (with steps): +```markdown +# Command Title + +Command description + +## Steps + +### 1. First Step +Specific action + +### 2. Second Step +Specific action + +## Output Format (if needed) + +Template +``` + +### 4. Create Files + +Create in both directories: +- `.cursor/commands/trellis-<command-name>.md` +- `.claude/commands/trellis/<command-name>.md` + +### 5. Confirm Creation + +Output result: +``` +[OK] Created Slash Command: /<command-name> + +File paths: +- .cursor/commands/trellis-<command-name>.md +- .claude/commands/trellis/<command-name>.md + +Usage: +/trellis:<command-name> + +Description: +<description> +``` + +## Command Content Guidelines + +### [OK] Good command content + +1. **Clear and concise**: Immediately understandable +2. **Executable**: AI can follow steps directly +3. **Well-scoped**: Clear boundaries of what to do and not do +4. **Has output**: Specifies expected output format (if needed) + +### [X] Avoid + +1. **Too vague**: e.g., "optimize code" +2. **Too complex**: Single command should not exceed 100 lines +3. **Duplicate functionality**: Check if similar command exists first + +## Naming Conventions + +| Command Type | Prefix | Example | +|--------------|--------|---------| +| Session Start | `start` | `start` | +| Pre-development | `before-` | `before-frontend-dev` | +| Check | `check-` | `check-frontend` | +| Record | `record-` | `record-session` | +| Generate | `generate-` | `generate-api-doc` | +| Update | `update-` | `update-changelog` | +| Other | Verb-first | `review-code`, `sync-data` | + +## Example + +### Input +``` +/trellis:create-command review-pr Check PR code changes against project guidelines +``` + +### Generated Command Content +```markdown +# PR Code Review + +Check current PR code changes against project guidelines. + +## Steps + +### 1. Get Changed Files +```bash +git diff main...HEAD --name-only +``` + +### 2. Categorized Review + +**Frontend files** (`apps/web/`): +- Reference `.trellis/spec/frontend/index.md` + +**Backend files** (`packages/api/`): +- Reference `.trellis/spec/backend/index.md` + +### 3. Output Review Report + +Format: + +## PR Review Report + +### Changed Files +- [file list] + +### Check Results +- [OK] Passed items +- [X] Issues found + +### Suggestions +- [improvement suggestions] +``` diff --git a/.claude/commands/trellis/finish-work.md b/.claude/commands/trellis/finish-work.md new file mode 100644 index 00000000..9daea672 --- /dev/null +++ b/.claude/commands/trellis/finish-work.md @@ -0,0 +1,153 @@ +# Finish Work - Pre-Commit Checklist + +Before submitting or committing, use this checklist to ensure work completeness. + +**Timing**: After code is written and tested, before commit + +--- + +## Checklist + +### 1. Code Quality + +```bash +# Must pass +pnpm lint +pnpm type-check +pnpm test +``` + +- [ ] `pnpm lint` passes with 0 errors? +- [ ] `pnpm type-check` passes with no type errors? +- [ ] Tests pass? +- [ ] No `console.log` statements (use logger)? +- [ ] No non-null assertions (the `x!` operator)? +- [ ] No `any` types? + +### 1.5. Test Coverage + +Check if your change needs new or updated tests (see `.trellis/spec/unit-test/conventions.md`): + +- [ ] New pure function → unit test added? +- [ ] Bug fix → regression test added in `test/regression.test.ts`? +- [ ] Changed init/update behavior → integration test added/updated? +- [ ] No logic change (text/data only) → no test needed + +### 2. Code-Spec Sync + +**Code-Spec Docs**: +- [ ] Does `.trellis/spec/backend/` need updates? + - New patterns, new modules, new conventions +- [ ] Does `.trellis/spec/frontend/` need updates? + - New components, new hooks, new patterns +- [ ] Does `.trellis/spec/guides/` need updates? + - New cross-layer flows, lessons from bugs + +**Key Question**: +> "If I fixed a bug or discovered something non-obvious, should I document it so future me (or others) won't hit the same issue?" + +If YES -> Update the relevant code-spec doc. + +### 2.5. Code-Spec Hard Block (Infra/Cross-Layer) + +If this change touches infra or cross-layer contracts, this is a blocking checklist: + +- [ ] Spec content is executable (real signatures/contracts), not principle-only text +- [ ] Includes file path + command/API name + payload field names +- [ ] Includes validation and error matrix +- [ ] Includes Good/Base/Bad cases +- [ ] Includes required tests and assertion points + +**Block Rule**: +In pipeline mode, the finish agent will automatically detect and execute spec updates when gaps are found. +If running this checklist manually, ensure spec sync is complete before committing — run `/trellis:update-spec` if needed. + +### 3. API Changes + +If you modified API endpoints: + +- [ ] Input schema updated? +- [ ] Output schema updated? +- [ ] API documentation updated? +- [ ] Client code updated to match? + +### 4. Database Changes + +If you modified database schema: + +- [ ] Migration file created? +- [ ] Schema file updated? +- [ ] Related queries updated? +- [ ] Seed data updated (if applicable)? + +### 5. Cross-Layer Verification + +If the change spans multiple layers: + +- [ ] Data flows correctly through all layers? +- [ ] Error handling works at each boundary? +- [ ] Types are consistent across layers? +- [ ] Loading states handled? + +### 6. Manual Testing + +- [ ] Feature works in browser/app? +- [ ] Edge cases tested? +- [ ] Error states tested? +- [ ] Works after page refresh? + +--- + +## Quick Check Flow + +```bash +# 1. Code checks +pnpm lint && pnpm type-check + +# 2. View changes +git status +git diff --name-only + +# 3. Based on changed files, check relevant items above +``` + +--- + +## Common Oversights + +| Oversight | Consequence | Check | +|-----------|-------------|-------| +| Code-spec docs not updated | Others don't know the change | Check .trellis/spec/ | +| Spec text is abstract only | Easy regressions in infra/cross-layer changes | Require signature/contract/matrix/cases/tests | +| Migration not created | Schema out of sync | Check db/migrations/ | +| Types not synced | Runtime errors | Check shared types | +| Tests not updated | False confidence | Run full test suite | +| Console.log left in | Noisy production logs | Search for console.log | + +--- + +## Relationship to Other Commands + +``` +Development Flow: + Write code -> Test -> /trellis:finish-work -> git commit -> /trellis:record-session + | | + Ensure completeness Record progress + +Debug Flow: + Hit bug -> Fix -> /trellis:break-loop -> Knowledge capture + | + Deep analysis +``` + +- `/trellis:finish-work` - Check work completeness (this command) +- `/trellis:record-session` - Record session and commits +- `/trellis:break-loop` - Deep analysis after debugging + +--- + +## Core Principle + +> **Delivery includes not just code, but also documentation, verification, and knowledge capture.** + +Complete work = Code + Docs + Tests + Verification diff --git a/.claude/commands/trellis/integrate-skill.md b/.claude/commands/trellis/integrate-skill.md new file mode 100644 index 00000000..cacafd5a --- /dev/null +++ b/.claude/commands/trellis/integrate-skill.md @@ -0,0 +1,219 @@ +# Integrate Claude Skill into Project Guidelines + +Adapt and integrate a Claude global skill into your project's development guidelines (not directly into project code). + +## Usage + +``` +/trellis:integrate-skill <skill-name> +``` + +**Examples**: +``` +/trellis:integrate-skill frontend-design +/trellis:integrate-skill mcp-builder +``` + +## Core Principle + +> [!] **Important**: The goal of skill integration is to update **development guidelines**, not to generate project code directly. +> +> - Guidelines content -> Write to `.trellis/spec/{target}/doc.md` +> - Code examples -> Place in `.trellis/spec/{target}/examples/skills/<skill-name>/` +> - Example files -> Use `.template` suffix (e.g., `component.tsx.template`) to avoid IDE errors +> +> Where `{target}` is `frontend` or `backend`, determined by skill type. + +## Execution Steps + +### 1. Read Skill Content + +```bash +openskills read <skill-name> +``` + +If the skill doesn't exist, prompt user to check available skills: +```bash +# Available skills are listed in AGENTS.md under <available_skills> +``` + +### 2. Determine Integration Target + +Based on skill type, determine which guidelines to update: + +| Skill Category | Integration Target | +|----------------|-------------------| +| UI/Frontend (`frontend-design`, `web-artifacts-builder`) | `.trellis/spec/frontend/` | +| Backend/API (`mcp-builder`) | `.trellis/spec/backend/` | +| Documentation (`doc-coauthoring`, `docx`, `pdf`) | `.trellis/` or create dedicated guidelines | +| Testing (`webapp-testing`) | `.trellis/spec/frontend/` (E2E) | + +### 3. Analyze Skill Content + +Extract from the skill: +- **Core concepts**: How the skill works and key concepts +- **Best practices**: Recommended approaches +- **Code patterns**: Reusable code templates +- **Caveats**: Common issues and solutions + +### 4. Execute Integration + +#### 4.1 Update Guidelines Document + +Add a new section to the corresponding `doc.md`: + +```markdown +@@@section:skill-<skill-name> +## # <Skill Name> Integration Guide + +### Overview +[Core functionality and use cases of the skill] + +### Project Adaptation +[How to use this skill in the current project] + +### Usage Steps +1. [Step 1] +2. [Step 2] + +### Caveats +- [Project-specific constraints] +- [Differences from default behavior] + +### Reference Examples +See `examples/skills/<skill-name>/` + +@@@/section:skill-<skill-name> +``` + +#### 4.2 Create Examples Directory (if code examples exist) + +```bash +# Directory structure ({target} = frontend or backend) +.trellis/spec/{target}/ +|-- doc.md # Add skill-related section +|-- index.md # Update index ++-- examples/ + +-- skills/ + +-- <skill-name>/ + |-- README.md # Example documentation + |-- example-1.ts.template # Code example (use .template suffix) + +-- example-2.tsx.template +``` + +**File naming conventions**: +- Code files: `<name>.<ext>.template` (e.g., `component.tsx.template`) +- Config files: `<name>.config.template` (e.g., `tailwind.config.template`) +- Documentation: `README.md` (normal suffix) + +#### 4.3 Update Index File + +Add to the Quick Navigation table in `index.md`: + +```markdown +| <Skill-related task> | <Section name> | `skill-<skill-name>` | +``` + +### 5. Generate Integration Report + +--- + +## Skill Integration Report: `<skill-name>` + +### # Overview +- **Skill description**: [Functionality description] +- **Integration target**: `.trellis/spec/{target}/` + +### # Tech Stack Compatibility + +| Skill Requirement | Project Status | Compatibility | +|-------------------|----------------|---------------| +| [Tech 1] | [Project tech] | [OK]/[!]/[X] | + +### # Integration Locations + +| Type | Path | +|------|------| +| Guidelines doc | `.trellis/spec/{target}/doc.md` (section: `skill-<name>`) | +| Code examples | `.trellis/spec/{target}/examples/skills/<name>/` | +| Index update | `.trellis/spec/{target}/index.md` | + +> `{target}` = `frontend` or `backend` + +### # Dependencies (if needed) + +```bash +# Install required dependencies (adjust for your package manager) +npm install <package> +# or +pnpm add <package> +# or +yarn add <package> +``` + +### [OK] Completed Changes + +- [ ] Added `@@@section:skill-<name>` section to `doc.md` +- [ ] Added index entry to `index.md` +- [ ] Created example files in `examples/skills/<name>/` +- [ ] Example files use `.template` suffix + +### # Related Guidelines + +- [Existing related section IDs] + +--- + +## 6. Optional: Create Usage Command + +If this skill is frequently used, create a shortcut command: + +```bash +/trellis:create-command use-<skill-name> Use <skill-name> skill following project guidelines +``` + +## Common Skill Integration Reference + +| Skill | Integration Target | Examples Directory | +|-------|-------------------|-------------------| +| `frontend-design` | `frontend` | `examples/skills/frontend-design/` | +| `mcp-builder` | `backend` | `examples/skills/mcp-builder/` | +| `webapp-testing` | `frontend` | `examples/skills/webapp-testing/` | +| `doc-coauthoring` | `.trellis/` | N/A (documentation workflow only) | + +## Example: Integrating `mcp-builder` Skill + +### Directory Structure + +``` +.trellis/spec/backend/ +|-- doc.md # Add MCP section +|-- index.md # Add index entry ++-- examples/ + +-- skills/ + +-- mcp-builder/ + |-- README.md + |-- server.ts.template + |-- tools.ts.template + +-- types.ts.template +``` + +### New Section in doc.md + +```markdown +@@@section:skill-mcp-builder +## # MCP Server Development Guide + +### Overview +Create LLM-callable tool services using MCP (Model Context Protocol). + +### Project Adaptation +- Place services in a dedicated directory +- Follow existing TypeScript and type definition conventions +- Use project's logging system + +### Reference Examples +See `examples/skills/mcp-builder/` + +@@@/section:skill-mcp-builder +``` diff --git a/.claude/commands/trellis/onboard.md b/.claude/commands/trellis/onboard.md new file mode 100644 index 00000000..732f80d1 --- /dev/null +++ b/.claude/commands/trellis/onboard.md @@ -0,0 +1,358 @@ +You are a senior developer onboarding a new team member to this project's AI-assisted workflow system. + +YOUR ROLE: Be a mentor and teacher. Don't just list steps - EXPLAIN the underlying principles, why each command exists, what problem it solves at a fundamental level. + +## CRITICAL INSTRUCTION - YOU MUST COMPLETE ALL SECTIONS + +This onboarding has THREE equally important parts: + +**PART 1: Core Concepts** (Sections: CORE PHILOSOPHY, SYSTEM STRUCTURE, COMMAND DEEP DIVE) +- Explain WHY this workflow exists +- Explain WHAT each command does and WHY + +**PART 2: Real-World Examples** (Section: REAL-WORLD WORKFLOW EXAMPLES) +- Walk through ALL 5 examples in detail +- For EACH step in EACH example, explain: + - PRINCIPLE: Why this step exists + - WHAT HAPPENS: What the command actually does + - IF SKIPPED: What goes wrong without it + +**PART 3: Customize Your Development Guidelines** (Section: CUSTOMIZE YOUR DEVELOPMENT GUIDELINES) +- Check if project guidelines are still empty templates +- If empty, guide the developer to fill them with project-specific content +- Explain the customization workflow + +DO NOT skip any part. All three parts are essential: +- Part 1 teaches the concepts +- Part 2 shows how concepts work in practice +- Part 3 ensures the project has proper guidelines for AI to follow + +After completing ALL THREE parts, ask the developer about their first task. + +--- + +## CORE PHILOSOPHY: Why This Workflow Exists + +AI-assisted development has three fundamental challenges: + +### Challenge 1: AI Has No Memory + +Every AI session starts with a blank slate. Unlike human engineers who accumulate project knowledge over weeks/months, AI forgets everything when a session ends. + +**The Problem**: Without memory, AI asks the same questions repeatedly, makes the same mistakes, and can't build on previous work. + +**The Solution**: The `.trellis/workspace/` system captures what happened in each session - what was done, what was learned, what problems were solved. The `/trellis:start` command reads this history at session start, giving AI "artificial memory." + +### Challenge 2: AI Has Generic Knowledge, Not Project-Specific Knowledge + +AI models are trained on millions of codebases - they know general patterns for React, TypeScript, databases, etc. But they don't know YOUR project's conventions. + +**The Problem**: AI writes code that "works" but doesn't match your project's style. It uses patterns that conflict with existing code. It makes decisions that violate unwritten team rules. + +**The Solution**: The `.trellis/spec/` directory contains project-specific guidelines. The `/before-*-dev` commands inject this specialized knowledge into AI context before coding starts. + +### Challenge 3: AI Context Window Is Limited + +Even after injecting guidelines, AI has limited context window. As conversation grows, earlier context (including guidelines) gets pushed out or becomes less influential. + +**The Problem**: AI starts following guidelines, but as the session progresses and context fills up, it "forgets" the rules and reverts to generic patterns. + +**The Solution**: The `/check-*` commands re-verify code against guidelines AFTER writing, catching drift that occurred during development. The `/trellis:finish-work` command does a final holistic review. + +--- + +## SYSTEM STRUCTURE + +``` +.trellis/ +|-- .developer # Your identity (gitignored) +|-- workflow.md # Complete workflow documentation +|-- workspace/ # "AI Memory" - session history +| |-- index.md # All developers' progress +| +-- {developer}/ # Per-developer directory +| |-- index.md # Personal progress index +| +-- journal-N.md # Session records (max 2000 lines) +|-- tasks/ # Task tracking (unified) +| +-- {MM}-{DD}-{slug}/ # Task directory +| |-- task.json # Task metadata +| +-- prd.md # Requirements doc +|-- spec/ # "AI Training Data" - project knowledge +| |-- frontend/ # Frontend conventions +| |-- backend/ # Backend conventions +| +-- guides/ # Thinking patterns ++-- scripts/ # Automation tools +``` + +### Understanding spec/ subdirectories + +**frontend/** - Single-layer frontend knowledge: +- Component patterns (how to write components in THIS project) +- State management rules (Redux? Zustand? Context?) +- Styling conventions (CSS modules? Tailwind? Styled-components?) +- Hook patterns (custom hooks, data fetching) + +**backend/** - Single-layer backend knowledge: +- API design patterns (REST? GraphQL? tRPC?) +- Database conventions (query patterns, migrations) +- Error handling standards +- Logging and monitoring rules + +**guides/** - Cross-layer thinking guides: +- Code reuse thinking guide +- Cross-layer thinking guide +- Pre-implementation checklists + +--- + +## COMMAND DEEP DIVE + +### /trellis:start - Restore AI Memory + +**WHY IT EXISTS**: +When a human engineer joins a project, they spend days/weeks learning: What is this project? What's been built? What's in progress? What's the current state? + +AI needs the same onboarding - but compressed into seconds at session start. + +**WHAT IT ACTUALLY DOES**: +1. Reads developer identity (who am I in this project?) +2. Checks git status (what branch? uncommitted changes?) +3. Reads recent session history from `workspace/` (what happened before?) +4. Identifies active features (what's in progress?) +5. Understands current project state before making any changes + +**WHY THIS MATTERS**: +- Without /trellis:start: AI is blind. It might work on wrong branch, conflict with others' work, or redo already-completed work. +- With /trellis:start: AI knows project context, can continue where previous session left off, avoids conflicts. + +--- + +### /trellis:before-frontend-dev and /trellis:before-backend-dev - Inject Specialized Knowledge + +**WHY IT EXISTS**: +AI models have "pre-trained knowledge" - general patterns from millions of codebases. But YOUR project has specific conventions that differ from generic patterns. + +**WHAT IT ACTUALLY DOES**: +1. Reads `.trellis/spec/frontend/` or `.trellis/spec/backend/` +2. Loads project-specific patterns into AI's working context: + - Component naming conventions + - State management patterns + - Database query patterns + - Error handling standards + +**WHY THIS MATTERS**: +- Without before-*-dev: AI writes generic code that doesn't match project style. +- With before-*-dev: AI writes code that looks like the rest of the codebase. + +--- + +### /trellis:check-frontend and /trellis:check-backend - Combat Context Drift + +**WHY IT EXISTS**: +AI context window has limited capacity. As conversation progresses, guidelines injected at session start become less influential. This causes "context drift." + +**WHAT IT ACTUALLY DOES**: +1. Re-reads the guidelines that were injected earlier +2. Compares written code against those guidelines +3. Runs type checker and linter +4. Identifies violations and suggests fixes + +**WHY THIS MATTERS**: +- Without check-*: Context drift goes unnoticed, code quality degrades. +- With check-*: Drift is caught and corrected before commit. + +--- + +### /trellis:check-cross-layer - Multi-Dimension Verification + +**WHY IT EXISTS**: +Most bugs don't come from lack of technical skill - they come from "didn't think of it": +- Changed a constant in one place, missed 5 other places +- Modified database schema, forgot to update the API layer +- Created a utility function, but similar one already exists + +**WHAT IT ACTUALLY DOES**: +1. Identifies which dimensions your change involves +2. For each dimension, runs targeted checks: + - Cross-layer data flow + - Code reuse analysis + - Import path validation + - Consistency checks + +--- + +### /trellis:finish-work - Holistic Pre-Commit Review + +**WHY IT EXISTS**: +The `/check-*` commands focus on code quality within a single layer. But real changes often have cross-cutting concerns. + +**WHAT IT ACTUALLY DOES**: +1. Reviews all changes holistically +2. Checks cross-layer consistency +3. Identifies broader impacts +4. Checks if new patterns should be documented + +--- + +### /trellis:record-session - Persist Memory for Future + +**WHY IT EXISTS**: +All the context AI built during this session will be lost when session ends. The next session's `/trellis:start` needs this information. + +**WHAT IT ACTUALLY DOES**: +1. Records session summary to `workspace/{developer}/journal-N.md` +2. Captures what was done, learned, and what's remaining +3. Updates index files for quick lookup + +--- + +## REAL-WORLD WORKFLOW EXAMPLES + +### Example 1: Bug Fix Session + +**[1/8] /trellis:start** - AI needs project context before touching code +**[2/8] python3 ./.trellis/scripts/task.py create "Fix bug" --slug fix-bug** - Track work for future reference +**[3/8] /trellis:before-frontend-dev** - Inject project-specific frontend knowledge +**[4/8] Investigate and fix the bug** - Actual development work +**[5/8] /trellis:check-frontend** - Re-verify code against guidelines +**[6/8] /trellis:finish-work** - Holistic cross-layer review +**[7/8] Human tests and commits** - Human validates before code enters repo +**[8/8] /trellis:record-session** - Persist memory for future sessions + +### Example 2: Planning Session (No Code) + +**[1/4] /trellis:start** - Context needed even for non-coding work +**[2/4] python3 ./.trellis/scripts/task.py create "Planning task" --slug planning-task** - Planning is valuable work +**[3/4] Review docs, create subtask list** - Actual planning work +**[4/4] /trellis:record-session (with --summary)** - Planning decisions must be recorded + +### Example 3: Code Review Fixes + +**[1/6] /trellis:start** - Resume context from previous session +**[2/6] /trellis:before-backend-dev** - Re-inject guidelines before fixes +**[3/6] Fix each CR issue** - Address feedback with guidelines in context +**[4/6] /trellis:check-backend** - Verify fixes didn't introduce new issues +**[5/6] /trellis:finish-work** - Document lessons from CR +**[6/6] Human commits, then /trellis:record-session** - Preserve CR lessons + +### Example 4: Large Refactoring + +**[1/5] /trellis:start** - Clear baseline before major changes +**[2/5] Plan phases** - Break into verifiable chunks +**[3/5] Execute phase by phase with /check-* after each** - Incremental verification +**[4/5] /trellis:finish-work** - Check if new patterns should be documented +**[5/5] Record with multiple commit hashes** - Link all commits to one feature + +### Example 5: Debug Session + +**[1/6] /trellis:start** - See if this bug was investigated before +**[2/6] /trellis:before-backend-dev** - Guidelines might document known gotchas +**[3/6] Investigation** - Actual debugging work +**[4/6] /trellis:check-backend** - Verify debug changes don't break other things +**[5/6] /trellis:finish-work** - Debug findings might need documentation +**[6/6] Human commits, then /trellis:record-session** - Debug knowledge is valuable + +--- + +## KEY RULES TO EMPHASIZE + +1. **AI NEVER commits** - Human tests and approves. AI prepares, human validates. +2. **Guidelines before code** - /before-*-dev commands inject project knowledge. +3. **Check after code** - /check-* commands catch context drift. +4. **Record everything** - /trellis:record-session persists memory. + +--- + +# PART 3: Customize Your Development Guidelines + +After explaining Part 1 and Part 2, check if the project's development guidelines need customization. + +## Step 1: Check Current Guidelines Status + +Check if `.trellis/spec/` contains empty templates or customized guidelines: + +```bash +# Check if files are still empty templates (look for placeholder text) +grep -l "To be filled by the team" .trellis/spec/backend/*.md 2>/dev/null | wc -l +grep -l "To be filled by the team" .trellis/spec/frontend/*.md 2>/dev/null | wc -l +``` + +## Step 2: Determine Situation + +**Situation A: First-time setup (empty templates)** + +If guidelines are empty templates (contain "To be filled by the team"), this is the first time using Trellis in this project. + +Explain to the developer: + +"I see that the development guidelines in `.trellis/spec/` are still empty templates. This is normal for a new Trellis setup! + +The templates contain placeholder text that needs to be replaced with YOUR project's actual conventions. Without this, `/before-*-dev` commands won't provide useful guidance. + +**Your first task should be to fill in these guidelines:** + +1. Look at your existing codebase +2. Identify the patterns and conventions already in use +3. Document them in the guideline files + +For example, for `.trellis/spec/backend/database-guidelines.md`: +- What ORM/query library does your project use? +- How are migrations managed? +- What naming conventions for tables/columns? + +Would you like me to help you analyze your codebase and fill in these guidelines?" + +**Situation B: Guidelines already customized** + +If guidelines have real content (no "To be filled" placeholders), this is an existing setup. + +Explain to the developer: + +"Great! Your team has already customized the development guidelines. You can start using `/before-*-dev` commands right away. + +I recommend reading through `.trellis/spec/` to familiarize yourself with the team's coding standards." + +## Step 3: Help Fill Guidelines (If Empty) + +If the developer wants help filling guidelines, create a feature to track this: + +```bash +python3 ./.trellis/scripts/task.py create "Fill spec guidelines" --slug fill-spec-guidelines +``` + +Then systematically analyze the codebase and fill each guideline file: + +1. **Analyze the codebase** - Look at existing code patterns +2. **Document conventions** - Write what you observe, not ideals +3. **Include examples** - Reference actual files in the project +4. **List forbidden patterns** - Document anti-patterns the team avoids + +Work through one file at a time: +- `backend/directory-structure.md` +- `backend/database-guidelines.md` +- `backend/error-handling.md` +- `backend/quality-guidelines.md` +- `backend/logging-guidelines.md` +- `frontend/directory-structure.md` +- `frontend/component-guidelines.md` +- `frontend/hook-guidelines.md` +- `frontend/state-management.md` +- `frontend/quality-guidelines.md` +- `frontend/type-safety.md` + +--- + +## Completing the Onboard Session + +After covering all three parts, summarize: + +"You're now onboarded to the Trellis workflow system! Here's what we covered: +- Part 1: Core concepts (why this workflow exists) +- Part 2: Real-world examples (how to apply the workflow) +- Part 3: Guidelines status (empty templates need filling / already customized) + +**Next steps** (tell user): +1. Run `/trellis:record-session` to record this onboard session +2. [If guidelines empty] Start filling in `.trellis/spec/` guidelines +3. [If guidelines ready] Start your first development task + +What would you like to do first?" diff --git a/.claude/commands/trellis/parallel.md b/.claude/commands/trellis/parallel.md new file mode 100644 index 00000000..3db5c3ef --- /dev/null +++ b/.claude/commands/trellis/parallel.md @@ -0,0 +1,193 @@ +# Multi-Agent Pipeline Orchestrator + +You are the Multi-Agent Pipeline Orchestrator Agent, running in the main repository, responsible for collaborating with users to manage parallel development tasks. + +## Role Definition + +- **You are in the main repository**, not in a worktree +- **You don't write code directly** - code work is done by agents in worktrees +- **You are responsible for planning and dispatching**: discuss requirements, create plans, configure context, start worktree agents +- **Delegate complex analysis to research agent**: finding specs, analyzing code structure + +--- + +## Operation Types + +Operations in this document are categorized as: + +| Marker | Meaning | Executor | +|--------|---------|----------| +| `[AI]` | Bash scripts or Task calls executed by AI | You (AI) | +| `[USER]` | Slash commands executed by user | User | + +--- + +## Startup Flow + +### Step 1: Understand Trellis Workflow `[AI]` + +First, read the workflow guide to understand the development process: + +```bash +cat .trellis/workflow.md # Development process, conventions, and quick start guide +``` + +### Step 2: Get Current Status `[AI]` + +```bash +python3 ./.trellis/scripts/get_context.py +``` + +### Step 3: Read Project Guidelines `[AI]` + +```bash +cat .trellis/spec/frontend/index.md # Frontend guidelines index +cat .trellis/spec/backend/index.md # Backend guidelines index +cat .trellis/spec/guides/index.md # Thinking guides +``` + +### Step 4: Ask User for Requirements + +Ask the user: + +1. What feature to develop? +2. Which modules are involved? +3. Development type? (backend / frontend / fullstack) + +--- + +## Planning: Choose Your Approach + +Based on requirement complexity, choose one of these approaches: + +### Option A: Plan Agent (Recommended for complex features) `[AI]` + +Use when: +- Requirements need analysis and validation +- Multiple modules or cross-layer changes +- Unclear scope that needs research + +```bash +python3 ./.trellis/scripts/multi_agent/plan.py \ + --name "<feature-name>" \ + --type "<backend|frontend|fullstack>" \ + --requirement "<user requirement description>" +``` + +Plan Agent will: +1. Evaluate requirement validity (may reject if unclear/too large) +2. Call research agent to analyze codebase +3. Create and configure task directory +4. Write prd.md with acceptance criteria +5. Output ready-to-use task directory + +After plan.py completes, start the worktree agent: + +```bash +python3 ./.trellis/scripts/multi_agent/start.py "$TASK_DIR" +``` + +### Option B: Manual Configuration (For simple/clear features) `[AI]` + +Use when: +- Requirements are already clear and specific +- You know exactly which files are involved +- Simple, well-scoped changes + +#### Step 1: Create Task Directory + +```bash +# title is task description, --slug for task directory name +TASK_DIR=$(python3 ./.trellis/scripts/task.py create "<title>" --slug <task-name>) +``` + +#### Step 2: Configure Task + +```bash +# Initialize jsonl context files +python3 ./.trellis/scripts/task.py init-context "$TASK_DIR" <dev_type> + +# Set branch and scope +python3 ./.trellis/scripts/task.py set-branch "$TASK_DIR" feature/<name> +python3 ./.trellis/scripts/task.py set-scope "$TASK_DIR" <scope> +``` + +#### Step 3: Add Context (optional: use research agent) + +```bash +python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" implement "<path>" "<reason>" +python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" check "<path>" "<reason>" +``` + +#### Step 4: Create prd.md + +```bash +cat > "$TASK_DIR/prd.md" << 'EOF' +# Feature: <name> + +## Requirements +- ... + +## Acceptance Criteria +- ... +EOF +``` + +#### Step 5: Validate and Start + +```bash +python3 ./.trellis/scripts/task.py validate "$TASK_DIR" +python3 ./.trellis/scripts/multi_agent/start.py "$TASK_DIR" +``` + +--- + +## After Starting: Report Status + +Tell the user the agent has started and provide monitoring commands. + +--- + +## User Available Commands `[USER]` + +The following slash commands are for users (not AI): + +| Command | Description | +|---------|-------------| +| `/trellis:parallel` | Start Multi-Agent Pipeline (this command) | +| `/trellis:start` | Start normal development mode (single process) | +| `/trellis:record-session` | Record session progress | +| `/trellis:finish-work` | Pre-completion checklist | + +--- + +## Monitoring Commands (for user reference) + +Tell the user they can use these commands to monitor: + +```bash +python3 ./.trellis/scripts/multi_agent/status.py # Overview +python3 ./.trellis/scripts/multi_agent/status.py --log <name> # View log +python3 ./.trellis/scripts/multi_agent/status.py --watch <name> # Real-time monitoring +python3 ./.trellis/scripts/multi_agent/cleanup.py <branch> # Cleanup worktree +``` + +--- + +## Pipeline Phases + +The dispatch agent in worktree will automatically execute: + +1. implement → Implement feature +2. check → Check code quality +3. finish → Final verification +4. create-pr → Create PR + +--- + +## Core Rules + +- **Don't write code directly** - delegate to agents in worktree +- **Don't execute git commit** - agent does it via create-pr action +- **Delegate complex analysis to research** - finding specs, analyzing code structure +- **All sub agents use opus model** - ensure output quality diff --git a/.claude/commands/trellis/record-session.md b/.claude/commands/trellis/record-session.md new file mode 100644 index 00000000..4a7e6ff0 --- /dev/null +++ b/.claude/commands/trellis/record-session.md @@ -0,0 +1,61 @@ +[!] **Prerequisite**: This command should only be used AFTER the human has tested and committed the code. + +**Do NOT run `git commit` directly** — the scripts below handle their own commits for `.trellis/` metadata. You only need to read git history (`git log`, `git status`, `git diff`) and run the Python scripts. + +--- + +## Record Work Progress + +### Step 1: Get Context & Check Tasks + +```bash +python3 ./.trellis/scripts/get_context.py --mode record +``` + +[!] Archive tasks whose work is **actually done** — judge by work status, not the `status` field in task.json: +- Code committed? → Archive it (don't wait for PR) +- All acceptance criteria met? → Archive it +- Don't skip archiving just because `status` still says `planning` or `in_progress` + +```bash +python3 ./.trellis/scripts/task.py archive <task-name> +``` + +### Step 2: One-Click Add Session + +```bash +# Method 1: Simple parameters +python3 ./.trellis/scripts/add_session.py \ + --title "Session Title" \ + --commit "hash1,hash2" \ + --summary "Brief summary of what was done" + +# Method 2: Pass detailed content via stdin +cat << 'EOF' | python3 ./.trellis/scripts/add_session.py --title "Title" --commit "hash" +| Feature | Description | +|---------|-------------| +| New API | Added user authentication endpoint | +| Frontend | Updated login form | + +**Updated Files**: +- `packages/api/modules/auth/router.ts` +- `apps/web/modules/auth/components/login-form.tsx` +EOF +``` + +**Auto-completes**: +- [OK] Appends session to journal-N.md +- [OK] Auto-detects line count, creates new file if >2000 lines +- [OK] Updates index.md (Total Sessions +1, Last Active, line stats, history) +- [OK] Auto-commits .trellis/workspace and .trellis/tasks changes + +--- + +## Script Command Reference + +| Command | Purpose | +|---------|---------| +| `python3 ./.trellis/scripts/get_context.py --mode record` | Get context for record-session | +| `python3 ./.trellis/scripts/add_session.py --title "..." --commit "..."` | **One-click add session (recommended)** | +| `python3 ./.trellis/scripts/task.py archive <name>` | Archive completed task (auto-commits) | +| `python3 ./.trellis/scripts/task.py list` | List active tasks | diff --git a/.claude/commands/trellis/start.md b/.claude/commands/trellis/start.md new file mode 100644 index 00000000..39fd44f7 --- /dev/null +++ b/.claude/commands/trellis/start.md @@ -0,0 +1,389 @@ +# Start Session + +Initialize your AI development session and begin working on tasks. + +--- + +## Operation Types + +| Marker | Meaning | Executor | +|--------|---------|----------| +| `[AI]` | Bash scripts or Task calls executed by AI | You (AI) | +| `[USER]` | Slash commands executed by user | User | + +--- + +## Initialization `[AI]` + +### Step 1: Understand Development Workflow + +First, read the workflow guide to understand the development process: + +```bash +cat .trellis/workflow.md +``` + +**Follow the instructions in workflow.md** - it contains: +- Core principles (Read Before Write, Follow Standards, etc.) +- File system structure +- Development process +- Best practices + +### Step 2: Get Current Context + +```bash +python3 ./.trellis/scripts/get_context.py +``` + +This shows: developer identity, git status, current task (if any), active tasks. + +### Step 3: Read Guidelines Index + +```bash +cat .trellis/spec/frontend/index.md # Frontend guidelines +cat .trellis/spec/backend/index.md # Backend guidelines +cat .trellis/spec/guides/index.md # Thinking guides +cat .trellis/spec/unit-test/index.md # Testing guidelines +``` + +> **Important**: The index files are navigation — they list the actual guideline files (e.g., `error-handling.md`, `conventions.md`, `mock-strategies.md`). +> At this step, just read the indexes to understand what's available. +> When you start actual development, you MUST go back and read the specific guideline files relevant to your task, as listed in the index's Pre-Development Checklist. + +### Step 4: Report and Ask + +Report what you learned and ask: "What would you like to work on?" + +--- + +## Task Classification + +When user describes a task, classify it: + +| Type | Criteria | Workflow | +|------|----------|----------| +| **Question** | User asks about code, architecture, or how something works | Answer directly | +| **Trivial Fix** | Typo fix, comment update, single-line change | Direct Edit | +| **Simple Task** | Clear goal, 1-2 files, well-defined scope | Quick confirm → Implement | +| **Complex Task** | Vague goal, multiple files, architectural decisions | **Brainstorm → Task Workflow** | + +### Classification Signals + +**Trivial/Simple indicators:** +- User specifies exact file and change +- "Fix the typo in X" +- "Add field Y to component Z" +- Clear acceptance criteria already stated + +**Complex indicators:** +- "I want to add a feature for..." +- "Can you help me improve..." +- Mentions multiple areas or systems +- No clear implementation path +- User seems unsure about approach + +### Decision Rule + +> **If in doubt, use Brainstorm + Task Workflow.** +> +> Task Workflow ensures code-spec context is injected to agents, resulting in higher quality code. +> The overhead is minimal, but the benefit is significant. + +--- + +## Question / Trivial Fix + +For questions or trivial fixes, work directly: + +1. Answer question or make the fix +2. If code was changed, remind user to run `/trellis:finish-work` + +--- + +## Simple Task + +For simple, well-defined tasks: + +1. Quick confirm: "I understand you want to [goal]. Shall I proceed?" +2. If no, clarify and confirm again +3. **If yes: execute ALL steps below without stopping. Do NOT ask for additional confirmation between steps.** + - Create task directory (Phase 1 Path B, Step 2) + - Write PRD (Step 3) + - Research codebase (Phase 2, Step 5) + - Configure context (Step 6) + - Activate task (Step 7) + - Implement (Phase 3, Step 8) + - Check quality (Step 9) + - Complete (Step 10) + +--- + +## Complex Task - Brainstorm First + +For complex or vague tasks, **automatically start the brainstorm process** — do NOT skip directly to implementation. + +See `/trellis:brainstorm` for the full process. Summary: + +1. **Acknowledge and classify** - State your understanding +2. **Create task directory** - Track evolving requirements in `prd.md` +3. **Ask questions one at a time** - Update PRD after each answer +4. **Propose approaches** - For architectural decisions +5. **Confirm final requirements** - Get explicit approval +6. **Proceed to Task Workflow** - With clear requirements in PRD + +> **Subtask Decomposition**: If brainstorm reveals multiple independent work items, +> consider creating subtasks using `--parent` flag or `add-subtask` command. +> See `/trellis:brainstorm` Step 8 for details. + +### Key Brainstorm Principles + +| Principle | Description | +|-----------|-------------| +| **One question at a time** | Never overwhelm with multiple questions | +| **Update PRD immediately** | After each answer, update the document | +| **Prefer multiple choice** | Easier for users to answer | +| **YAGNI** | Challenge unnecessary complexity | + +--- + +## Task Workflow (Development Tasks) + +**Why this workflow?** +- Research Agent analyzes what code-spec files are needed +- Code-spec files are configured in jsonl files +- Implement Agent receives code-spec context via Hook injection +- Check Agent verifies against code-spec requirements +- Result: Code that follows project conventions automatically + +### Overview: Two Entry Points + +``` +From Brainstorm (Complex Task): + PRD confirmed → Research → Configure Context → Activate → Implement → Check → Complete + +From Simple Task: + Confirm → Create Task → Write PRD → Research → Configure Context → Activate → Implement → Check → Complete +``` + +**Key principle: Research happens AFTER requirements are clear (PRD exists).** + +--- + +### Phase 1: Establish Requirements + +#### Path A: From Brainstorm (skip to Phase 2) + +PRD and task directory already exist from brainstorm. Skip directly to Phase 2. + +#### Path B: From Simple Task + +**Step 1: Confirm Understanding** `[AI]` + +Quick confirm: +- What is the goal? +- What type of development? (frontend / backend / fullstack) +- Any specific requirements or constraints? + +**Step 2: Create Task Directory** `[AI]` + +```bash +TASK_DIR=$(python3 ./.trellis/scripts/task.py create "<title>" --slug <name>) +``` + +**Step 3: Write PRD** `[AI]` + +Create `prd.md` in the task directory with: + +```markdown +# <Task Title> + +## Goal +<What we're trying to achieve> + +## Requirements +- <Requirement 1> +- <Requirement 2> + +## Acceptance Criteria +- [ ] <Criterion 1> +- [ ] <Criterion 2> + +## Technical Notes +<Any technical decisions or constraints> +``` + +--- + +### Phase 2: Prepare for Implementation (shared) + +> Both paths converge here. PRD and task directory must exist before proceeding. + +**Step 4: Code-Spec Depth Check** `[AI]` + +If the task touches infra or cross-layer contracts, do not start implementation until code-spec depth is defined. + +Trigger this requirement when the change includes any of: +- New or changed command/API signatures +- Database schema or migration changes +- Infra integrations (storage, queue, cache, secrets, env contracts) +- Cross-layer payload transformations + +Must-have before proceeding: +- [ ] Target code-spec files to update are identified +- [ ] Concrete contract is defined (signature, fields, env keys) +- [ ] Validation and error matrix is defined +- [ ] At least one Good/Base/Bad case is defined + +**Step 5: Research the Codebase** `[AI]` + +Based on the confirmed PRD, call Research Agent to find relevant specs and patterns: + +``` +Task( + subagent_type: "research", + prompt: "Analyze the codebase for this task: + + Task: <goal from PRD> + Type: <frontend/backend/fullstack> + + Please find: + 1. Relevant code-spec files in .trellis/spec/ + 2. Existing code patterns to follow (find 2-3 examples) + 3. Files that will likely need modification + + Output: + ## Relevant Code-Specs + - <path>: <why it's relevant> + + ## Code Patterns Found + - <pattern>: <example file path> + + ## Files to Modify + - <path>: <what change>", + model: "opus" +) +``` + +**Step 6: Configure Context** `[AI]` + +Initialize default context: + +```bash +python3 ./.trellis/scripts/task.py init-context "$TASK_DIR" <type> +# type: backend | frontend | fullstack +``` + +Add code-spec files found by Research Agent: + +```bash +# For each relevant code-spec and code pattern: +python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" implement "<path>" "<reason>" +python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" check "<path>" "<reason>" +``` + +**Step 7: Activate Task** `[AI]` + +```bash +python3 ./.trellis/scripts/task.py start "$TASK_DIR" +``` + +This sets `.current-task` so hooks can inject context. + +--- + +### Phase 3: Execute (shared) + +**Step 8: Implement** `[AI]` + +Call Implement Agent (code-spec context is auto-injected by hook): + +``` +Task( + subagent_type: "implement", + prompt: "Implement the task described in prd.md. + + Follow all code-spec files that have been injected into your context. + Run lint and typecheck before finishing.", + model: "opus" +) +``` + +**Step 9: Check Quality** `[AI]` + +Call Check Agent (code-spec context is auto-injected by hook): + +``` +Task( + subagent_type: "check", + prompt: "Review all code changes against the code-spec requirements. + + Fix any issues you find directly. + Ensure lint and typecheck pass.", + model: "opus" +) +``` + +**Step 10: Complete** `[AI]` + +1. Verify lint and typecheck pass +2. Report what was implemented +3. Remind user to: + - Test the changes + - Commit when ready + - Run `/trellis:record-session` to record this session + +--- + +## Continuing Existing Task + +If `get_context.py` shows a current task: + +1. Read the task's `prd.md` to understand the goal +2. Check `task.json` for current status and phase +3. Ask user: "Continue working on <task-name>?" + +If yes, resume from the appropriate step (usually Step 7 or 8). + +--- + +## Commands Reference + +### User Commands `[USER]` + +| Command | When to Use | +|---------|-------------| +| `/trellis:start` | Begin a session (this command) | +| `/trellis:brainstorm` | Clarify vague requirements (called from start) | +| `/trellis:parallel` | Complex tasks needing isolated worktree | +| `/trellis:finish-work` | Before committing changes | +| `/trellis:record-session` | After completing a task | + +### AI Scripts `[AI]` + +| Script | Purpose | +|--------|---------| +| `python3 ./.trellis/scripts/get_context.py` | Get session context | +| `python3 ./.trellis/scripts/task.py create` | Create task directory | +| `python3 ./.trellis/scripts/task.py init-context` | Initialize jsonl files | +| `python3 ./.trellis/scripts/task.py add-context` | Add code-spec/context file to jsonl | +| `python3 ./.trellis/scripts/task.py start` | Set current task | +| `python3 ./.trellis/scripts/task.py finish` | Clear current task | +| `python3 ./.trellis/scripts/task.py archive` | Archive completed task | + +### Sub Agents `[AI]` + +| Agent | Purpose | Hook Injection | +|-------|---------|----------------| +| research | Analyze codebase | No (reads directly) | +| implement | Write code | Yes (implement.jsonl) | +| check | Review & fix | Yes (check.jsonl) | +| debug | Fix specific issues | Yes (debug.jsonl) | + +--- + +## Key Principle + +> **Code-spec context is injected, not remembered.** +> +> The Task Workflow ensures agents receive relevant code-spec context automatically. +> This is more reliable than hoping the AI "remembers" conventions. diff --git a/.claude/commands/trellis/update-spec.md b/.claude/commands/trellis/update-spec.md new file mode 100644 index 00000000..3f0b2e77 --- /dev/null +++ b/.claude/commands/trellis/update-spec.md @@ -0,0 +1,354 @@ +# Update Code-Spec - Capture Executable Contracts + +When you learn something valuable (from debugging, implementing, or discussion), use this command to update the relevant code-spec documents. + +**Timing**: After completing a task, fixing a bug, or discovering a new pattern + +--- + +## Code-Spec First Rule (CRITICAL) + +In this project, "spec" for implementation work means **code-spec**: +- Executable contracts (not principle-only text) +- Concrete signatures, payload fields, env keys, and boundary behavior +- Testable validation/error behavior + +If the change touches infra or cross-layer contracts, code-spec depth is mandatory. + +### Mandatory Triggers + +Apply code-spec depth when the change includes any of: +- New/changed command or API signature +- Cross-layer request/response contract change +- Database schema/migration change +- Infra integration (storage, queue, cache, secrets, env wiring) + +### Mandatory Output (7 Sections) + +For triggered tasks, include all sections below: +1. Scope / Trigger +2. Signatures (command/API/DB) +3. Contracts (request/response/env) +4. Validation & Error Matrix +5. Good/Base/Bad Cases +6. Tests Required (with assertion points) +7. Wrong vs Correct (at least one pair) + +--- + +## When to Update Code-Specs + +| Trigger | Example | Target Spec | +|---------|---------|-------------| +| **Implemented a feature** | Added template download with giget | Relevant `backend/` or `frontend/` file | +| **Made a design decision** | Used type field + mapping table for extensibility | Relevant code-spec + "Design Decisions" section | +| **Fixed a bug** | Found a subtle issue with error handling | `backend/error-handling.md` | +| **Discovered a pattern** | Found a better way to structure code | Relevant `backend/` or `frontend/` file | +| **Hit a gotcha** | Learned that X must be done before Y | Relevant code-spec + "Common Mistakes" section | +| **Established a convention** | Team agreed on naming pattern | `quality-guidelines.md` | +| **New thinking trigger** | "Don't forget to check X before doing Y" | `guides/*.md` (as a checklist item, not detailed rules) | + +**Key Insight**: Code-spec updates are NOT just for problems. Every feature implementation contains design decisions and contracts that future AI/developers need to execute safely. + +--- + +## Spec Structure Overview + +``` +.trellis/spec/ +├── backend/ # Backend coding standards +│ ├── index.md # Overview and links +│ └── *.md # Topic-specific guidelines +├── frontend/ # Frontend coding standards +│ ├── index.md # Overview and links +│ └── *.md # Topic-specific guidelines +└── guides/ # Thinking checklists (NOT coding specs!) + ├── index.md # Guide index + └── *.md # Topic-specific guides +``` + +### CRITICAL: Code-Spec vs Guide - Know the Difference + +| Type | Location | Purpose | Content Style | +|------|----------|---------|---------------| +| **Code-Spec** | `backend/*.md`, `frontend/*.md` | Tell AI "how to implement safely" | Signatures, contracts, matrices, cases, test points | +| **Guide** | `guides/*.md` | Help AI "what to think about" | Checklists, questions, pointers to specs | + +**Decision Rule**: Ask yourself: + +- "This is **how to write** the code" → Put in `backend/` or `frontend/` +- "This is **what to consider** before writing" → Put in `guides/` + +**Example**: + +| Learning | Wrong Location | Correct Location | +|----------|----------------|------------------| +| "Use `reconfigure()` not `TextIOWrapper` for Windows stdout" | ❌ `guides/cross-platform-thinking-guide.md` | ✅ `backend/script-conventions.md` | +| "Remember to check encoding when writing cross-platform code" | ❌ `backend/script-conventions.md` | ✅ `guides/cross-platform-thinking-guide.md` | + +**Guides should be short checklists that point to specs**, not duplicate the detailed rules. + +--- + +## Update Process + +### Step 1: Identify What You Learned + +Answer these questions: + +1. **What did you learn?** (Be specific) +2. **Why is it important?** (What problem does it prevent?) +3. **Where does it belong?** (Which spec file?) + +### Step 2: Classify the Update Type + +| Type | Description | Action | +|------|-------------|--------| +| **Design Decision** | Why we chose approach X over Y | Add to "Design Decisions" section | +| **Project Convention** | How we do X in this project | Add to relevant section with examples | +| **New Pattern** | A reusable approach discovered | Add to "Patterns" section | +| **Forbidden Pattern** | Something that causes problems | Add to "Anti-patterns" or "Don't" section | +| **Common Mistake** | Easy-to-make error | Add to "Common Mistakes" section | +| **Convention** | Agreed-upon standard | Add to relevant section | +| **Gotcha** | Non-obvious behavior | Add warning callout | + +### Step 3: Read the Target Code-Spec + +Before editing, read the current code-spec to: +- Understand existing structure +- Avoid duplicating content +- Find the right section for your update + +```bash +cat .trellis/spec/<category>/<file>.md +``` + +### Step 4: Make the Update + +Follow these principles: + +1. **Be Specific**: Include concrete examples, not just abstract rules +2. **Explain Why**: State the problem this prevents +3. **Show Contracts**: Add signatures, payload fields, and error behavior +4. **Show Code**: Add code snippets for key patterns +5. **Keep it Short**: One concept per section + +### Step 5: Update the Index (if needed) + +If you added a new section or the code-spec status changed, update the category's `index.md`. + +--- + +## Update Templates + +### Mandatory Template for Infra/Cross-Layer Work + +```markdown +## Scenario: <name> + +### 1. Scope / Trigger +- Trigger: <why this requires code-spec depth> + +### 2. Signatures +- Backend command/API/DB signature(s) + +### 3. Contracts +- Request fields (name, type, constraints) +- Response fields (name, type, constraints) +- Environment keys (required/optional) + +### 4. Validation & Error Matrix +- <condition> -> <error> + +### 5. Good/Base/Bad Cases +- Good: ... +- Base: ... +- Bad: ... + +### 6. Tests Required +- Unit/Integration/E2E with assertion points + +### 7. Wrong vs Correct +#### Wrong +... +#### Correct +... +``` + +### Adding a Design Decision + +```markdown +### Design Decision: [Decision Name] + +**Context**: What problem were we solving? + +**Options Considered**: +1. Option A - brief description +2. Option B - brief description + +**Decision**: We chose Option X because... + +**Example**: +\`\`\`typescript +// How it's implemented +code example +\`\`\` + +**Extensibility**: How to extend this in the future... +``` + +### Adding a Project Convention + +```markdown +### Convention: [Convention Name] + +**What**: Brief description of the convention. + +**Why**: Why we do it this way in this project. + +**Example**: +\`\`\`typescript +// How to follow this convention +code example +\`\`\` + +**Related**: Links to related conventions or specs. +``` + +### Adding a New Pattern + +```markdown +### Pattern Name + +**Problem**: What problem does this solve? + +**Solution**: Brief description of the approach. + +**Example**: +\`\`\` +// Good +code example + +// Bad +code example +\`\`\` + +**Why**: Explanation of why this works better. +``` + +### Adding a Forbidden Pattern + +```markdown +### Don't: Pattern Name + +**Problem**: +\`\`\` +// Don't do this +bad code example +\`\`\` + +**Why it's bad**: Explanation of the issue. + +**Instead**: +\`\`\` +// Do this instead +good code example +\`\`\` +``` + +### Adding a Common Mistake + +```markdown +### Common Mistake: Description + +**Symptom**: What goes wrong + +**Cause**: Why this happens + +**Fix**: How to correct it + +**Prevention**: How to avoid it in the future +``` + +### Adding a Gotcha + +```markdown +> **Warning**: Brief description of the non-obvious behavior. +> +> Details about when this happens and how to handle it. +``` + +--- + +## Interactive Mode + +If you're unsure what to update, answer these prompts: + +1. **What did you just finish?** + - [ ] Fixed a bug + - [ ] Implemented a feature + - [ ] Refactored code + - [ ] Had a discussion about approach + +2. **What did you learn or decide?** + - Design decision (why X over Y) + - Project convention (how we do X) + - Non-obvious behavior (gotcha) + - Better approach (pattern) + +3. **Would future AI/developers need to know this?** + - To understand how the code works → Yes, update spec + - To maintain or extend the feature → Yes, update spec + - To avoid repeating mistakes → Yes, update spec + - Purely one-off implementation detail → Maybe skip + +4. **Which area does it relate to?** + - [ ] Backend code + - [ ] Frontend code + - [ ] Cross-layer data flow + - [ ] Code organization/reuse + - [ ] Quality/testing + +--- + +## Quality Checklist + +Before finishing your code-spec update: + +- [ ] Is the content specific and actionable? +- [ ] Did you include a code example? +- [ ] Did you explain WHY, not just WHAT? +- [ ] Did you include executable signatures/contracts? +- [ ] Did you include validation and error matrix? +- [ ] Did you include Good/Base/Bad cases? +- [ ] Did you include required tests with assertion points? +- [ ] Is it in the right code-spec file? +- [ ] Does it duplicate existing content? +- [ ] Would a new team member understand it? + +--- + +## Relationship to Other Commands + +``` +Development Flow: + Learn something → /trellis:update-spec → Knowledge captured + ↑ ↓ + /trellis:break-loop ←──────────────────── Future sessions benefit + (deep bug analysis) +``` + +- `/trellis:break-loop` - Analyzes bugs deeply, often reveals spec updates needed +- `/trellis:update-spec` - Actually makes the updates (this command) +- `/trellis:finish-work` - Reminds you to check if specs need updates + +--- + +## Core Philosophy + +> **Code-specs are living documents. Every debugging session, every "aha moment" is an opportunity to make the implementation contract clearer.** + +The goal is **institutional memory**: +- What one person learns, everyone benefits from +- What AI learns in one session, persists to future sessions +- Mistakes become documented guardrails diff --git a/.claude/hooks/inject-subagent-context.py b/.claude/hooks/inject-subagent-context.py new file mode 100644 index 00000000..95e7f5ce --- /dev/null +++ b/.claude/hooks/inject-subagent-context.py @@ -0,0 +1,788 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Multi-Agent Pipeline Context Injection Hook + +Core Design Philosophy: +- Dispatch becomes a pure dispatcher, only responsible for "calling subagents" +- Hook is responsible for injecting all context, subagent works autonomously with complete info +- Each agent has a dedicated jsonl file defining its context +- No resume needed, no segmentation, behavior controlled by code not prompt + +Trigger: PreToolUse (before Task tool call) + +Context Source: .trellis/.current-task points to task directory +- implement.jsonl - Implement agent dedicated context +- check.jsonl - Check agent dedicated context +- debug.jsonl - Debug agent dedicated context +- research.jsonl - Research agent dedicated context (optional, usually not needed) +- cr.jsonl - Code review dedicated context +- prd.md - Requirements document +- info.md - Technical design +- codex-review-output.txt - Code Review results +""" + +# IMPORTANT: Suppress all warnings FIRST +import warnings +warnings.filterwarnings("ignore") + +import json +import os +import sys +from pathlib import Path + +# IMPORTANT: Force stdout to use UTF-8 on Windows +# This fixes UnicodeEncodeError when outputting non-ASCII characters +if sys.platform == "win32": + import io as _io + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + elif hasattr(sys.stdout, "detach"): + sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr] + +# ============================================================================= +# Path Constants (change here to rename directories) +# ============================================================================= + +DIR_WORKFLOW = ".trellis" +DIR_WORKSPACE = "workspace" +DIR_TASKS = "tasks" +DIR_SPEC = "spec" +FILE_CURRENT_TASK = ".current-task" +FILE_TASK_JSON = "task.json" + +# Agents that don't update phase (can be called at any time) +AGENTS_NO_PHASE_UPDATE = {"debug", "research"} + +# ============================================================================= +# Subagent Constants (change here to rename subagent types) +# ============================================================================= + +AGENT_IMPLEMENT = "implement" +AGENT_CHECK = "check" +AGENT_DEBUG = "debug" +AGENT_RESEARCH = "research" + +# Agents that require a task directory +AGENTS_REQUIRE_TASK = (AGENT_IMPLEMENT, AGENT_CHECK, AGENT_DEBUG) +# All supported agents +AGENTS_ALL = (AGENT_IMPLEMENT, AGENT_CHECK, AGENT_DEBUG, AGENT_RESEARCH) + + +def find_repo_root(start_path: str) -> str | None: + """ + Find git repo root from start_path upwards + + Returns: + Repo root path, or None if not found + """ + current = Path(start_path).resolve() + while current != current.parent: + if (current / ".git").exists(): + return str(current) + current = current.parent + return None + + +def get_current_task(repo_root: str) -> str | None: + """ + Read current task directory path from .trellis/.current-task + + Returns: + Task directory relative path (relative to repo_root) + None if not set + """ + current_task_file = os.path.join(repo_root, DIR_WORKFLOW, FILE_CURRENT_TASK) + if not os.path.exists(current_task_file): + return None + + try: + with open(current_task_file, "r", encoding="utf-8") as f: + content = f.read().strip() + return content if content else None + except Exception: + return None + + +def update_current_phase(repo_root: str, task_dir: str, subagent_type: str) -> None: + """ + Update current_phase in task.json based on subagent_type. + + This ensures phase tracking is always accurate, regardless of whether + dispatch agent remembers to update it. + + Logic: + - Read next_action array from task.json + - Find the next phase whose action matches subagent_type + - Only move forward, never backward + - Some agents (debug, research) don't update phase + """ + if subagent_type in AGENTS_NO_PHASE_UPDATE: + return + + task_json_path = os.path.join(repo_root, task_dir, FILE_TASK_JSON) + if not os.path.exists(task_json_path): + return + + try: + with open(task_json_path, "r", encoding="utf-8") as f: + task_data = json.load(f) + + current_phase = task_data.get("current_phase", 0) + next_actions = task_data.get("next_action", []) + + # Map action names to subagent types + # "implement" -> "implement", "check" -> "check", "finish" -> "check" + action_to_agent = { + "implement": "implement", + "check": "check", + "finish": "check", # finish uses check agent + } + + # Find the next phase that matches this subagent_type + new_phase = None + for action in next_actions: + phase_num = action.get("phase", 0) + action_name = action.get("action", "") + expected_agent = action_to_agent.get(action_name) + + # Only consider phases after current_phase + if phase_num > current_phase and expected_agent == subagent_type: + new_phase = phase_num + break + + if new_phase is not None: + task_data["current_phase"] = new_phase + + with open(task_json_path, "w", encoding="utf-8") as f: + json.dump(task_data, f, indent=2, ensure_ascii=False) + except Exception: + # Don't fail the hook if phase update fails + pass + + +def read_file_content(base_path: str, file_path: str) -> str | None: + """Read file content, return None if file doesn't exist""" + full_path = os.path.join(base_path, file_path) + if os.path.exists(full_path) and os.path.isfile(full_path): + try: + with open(full_path, "r", encoding="utf-8") as f: + return f.read() + except Exception: + return None + return None + + +def read_directory_contents( + base_path: str, dir_path: str, max_files: int = 20 +) -> list[tuple[str, str]]: + """ + Read all .md files in a directory + + Args: + base_path: Base path (usually repo_root) + dir_path: Directory relative path + max_files: Max files to read (prevent huge directories) + + Returns: + [(file_path, content), ...] + """ + full_path = os.path.join(base_path, dir_path) + if not os.path.exists(full_path) or not os.path.isdir(full_path): + return [] + + results = [] + try: + # Only read .md files, sorted by filename + md_files = sorted( + [ + f + for f in os.listdir(full_path) + if f.endswith(".md") and os.path.isfile(os.path.join(full_path, f)) + ] + ) + + for filename in md_files[:max_files]: + file_full_path = os.path.join(full_path, filename) + relative_path = os.path.join(dir_path, filename) + try: + with open(file_full_path, "r", encoding="utf-8") as f: + content = f.read() + results.append((relative_path, content)) + except Exception: + continue + except Exception: + pass + + return results + + +def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[tuple[str, str]]: + """ + Read all file/directory contents referenced in jsonl file + + Schema: + {"file": "path/to/file.md", "reason": "..."} + {"file": "path/to/dir/", "type": "directory", "reason": "..."} + + Returns: + [(path, content), ...] + """ + full_path = os.path.join(base_path, jsonl_path) + if not os.path.exists(full_path): + return [] + + results = [] + try: + with open(full_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + item = json.loads(line) + file_path = item.get("file") or item.get("path") + entry_type = item.get("type", "file") + + if not file_path: + continue + + if entry_type == "directory": + # Read all .md files in directory + dir_contents = read_directory_contents(base_path, file_path) + results.extend(dir_contents) + else: + # Read single file + content = read_file_content(base_path, file_path) + if content: + results.append((file_path, content)) + except json.JSONDecodeError: + continue + except Exception: + pass + + return results + + +def get_agent_context(repo_root: str, task_dir: str, agent_type: str) -> str: + """ + Get complete context for specified agent + + Prioritize agent-specific jsonl, fallback to spec.jsonl if not exists + """ + context_parts = [] + + # 1. Try agent-specific jsonl + agent_jsonl = f"{task_dir}/{agent_type}.jsonl" + agent_entries = read_jsonl_entries(repo_root, agent_jsonl) + + # 2. If agent-specific jsonl doesn't exist or empty, fallback to spec.jsonl + if not agent_entries: + agent_entries = read_jsonl_entries(repo_root, f"{task_dir}/spec.jsonl") + + # 3. Add all files from jsonl + for file_path, content in agent_entries: + context_parts.append(f"=== {file_path} ===\n{content}") + + return "\n\n".join(context_parts) + + +def get_implement_context(repo_root: str, task_dir: str) -> str: + """ + Complete context for Implement Agent + + Read order: + 1. All files in implement.jsonl (dev specs) + 2. prd.md (requirements) + 3. info.md (technical design) + """ + context_parts = [] + + # 1. Read implement.jsonl (or fallback to spec.jsonl) + base_context = get_agent_context(repo_root, task_dir, "implement") + if base_context: + context_parts.append(base_context) + + # 2. Requirements document + prd_content = read_file_content(repo_root, f"{task_dir}/prd.md") + if prd_content: + context_parts.append(f"=== {task_dir}/prd.md (Requirements) ===\n{prd_content}") + + # 3. Technical design + info_content = read_file_content(repo_root, f"{task_dir}/info.md") + if info_content: + context_parts.append( + f"=== {task_dir}/info.md (Technical Design) ===\n{info_content}" + ) + + return "\n\n".join(context_parts) + + +def get_check_context(repo_root: str, task_dir: str) -> str: + """ + Complete context for Check Agent + + Read order: + 1. All files in check.jsonl (check specs + dev specs) + 2. prd.md (for understanding task intent) + """ + context_parts = [] + + # 1. Read check.jsonl (or fallback to spec.jsonl + hardcoded check files) + check_entries = read_jsonl_entries(repo_root, f"{task_dir}/check.jsonl") + + if check_entries: + for file_path, content in check_entries: + context_parts.append(f"=== {file_path} ===\n{content}") + else: + # Fallback: use hardcoded check files + spec.jsonl + check_files = [ + (".claude/commands/trellis/finish-work.md", "Finish work checklist"), + (".claude/commands/trellis/check-cross-layer.md", "Cross-layer check spec"), + (".claude/commands/trellis/check-backend.md", "Backend check spec"), + (".claude/commands/trellis/check-frontend.md", "Frontend check spec"), + ] + for file_path, description in check_files: + content = read_file_content(repo_root, file_path) + if content: + context_parts.append(f"=== {file_path} ({description}) ===\n{content}") + + # Add spec.jsonl + spec_entries = read_jsonl_entries(repo_root, f"{task_dir}/spec.jsonl") + for file_path, content in spec_entries: + context_parts.append(f"=== {file_path} (Dev spec) ===\n{content}") + + # 2. Requirements document (for understanding task intent) + prd_content = read_file_content(repo_root, f"{task_dir}/prd.md") + if prd_content: + context_parts.append( + f"=== {task_dir}/prd.md (Requirements - for understanding intent) ===\n{prd_content}" + ) + + return "\n\n".join(context_parts) + + +def get_finish_context(repo_root: str, task_dir: str) -> str: + """ + Complete context for Finish phase (final check before PR) + + Read order: + 1. All files in finish.jsonl (if exists) + 2. Fallback to finish-work.md only (lightweight final check) + 3. update-spec.md (for active spec sync) + 4. prd.md (for verifying requirements are met) + """ + context_parts = [] + + # 1. Try finish.jsonl first + finish_entries = read_jsonl_entries(repo_root, f"{task_dir}/finish.jsonl") + + if finish_entries: + for file_path, content in finish_entries: + context_parts.append(f"=== {file_path} ===\n{content}") + else: + # Fallback: only finish-work.md (lightweight) + finish_work = read_file_content( + repo_root, ".claude/commands/trellis/finish-work.md" + ) + if finish_work: + context_parts.append( + f"=== .claude/commands/trellis/finish-work.md (Finish checklist) ===\n{finish_work}" + ) + + # 2. Spec update process (for active spec sync) + update_spec = read_file_content( + repo_root, ".claude/commands/trellis/update-spec.md" + ) + if update_spec: + context_parts.append( + f"=== .claude/commands/trellis/update-spec.md (Spec update process) ===\n{update_spec}" + ) + + # 3. Requirements document (for verifying requirements are met) + prd_content = read_file_content(repo_root, f"{task_dir}/prd.md") + if prd_content: + context_parts.append( + f"=== {task_dir}/prd.md (Requirements - verify all met) ===\n{prd_content}" + ) + + return "\n\n".join(context_parts) + + +def get_debug_context(repo_root: str, task_dir: str) -> str: + """ + Complete context for Debug Agent + + Read order: + 1. All files in debug.jsonl (specs needed for fixing) + 2. codex-review-output.txt (Codex Review results) + """ + context_parts = [] + + # 1. Read debug.jsonl (or fallback to spec.jsonl + hardcoded check files) + debug_entries = read_jsonl_entries(repo_root, f"{task_dir}/debug.jsonl") + + if debug_entries: + for file_path, content in debug_entries: + context_parts.append(f"=== {file_path} ===\n{content}") + else: + # Fallback: use spec.jsonl + hardcoded check files + spec_entries = read_jsonl_entries(repo_root, f"{task_dir}/spec.jsonl") + for file_path, content in spec_entries: + context_parts.append(f"=== {file_path} (Dev spec) ===\n{content}") + + check_files = [ + (".claude/commands/trellis/check-backend.md", "Backend check spec"), + (".claude/commands/trellis/check-frontend.md", "Frontend check spec"), + (".claude/commands/trellis/check-cross-layer.md", "Cross-layer check spec"), + ] + for file_path, description in check_files: + content = read_file_content(repo_root, file_path) + if content: + context_parts.append(f"=== {file_path} ({description}) ===\n{content}") + + # 2. Codex review output (if exists) + codex_output = read_file_content(repo_root, f"{task_dir}/codex-review-output.txt") + if codex_output: + context_parts.append( + f"=== {task_dir}/codex-review-output.txt (Codex Review Results) ===\n{codex_output}" + ) + + return "\n\n".join(context_parts) + + +def build_implement_prompt(original_prompt: str, context: str) -> str: + """Build complete prompt for Implement""" + return f"""# Implement Agent Task + +You are the Implement Agent in the Multi-Agent Pipeline. + +## Your Context + +All the information you need has been prepared for you: + +{context} + +--- + +## Your Task + +{original_prompt} + +--- + +## Workflow + +1. **Understand specs** - All dev specs are injected above, understand them +2. **Understand requirements** - Read requirements document and technical design +3. **Implement feature** - Implement following specs and design +4. **Self-check** - Ensure code quality against check specs + +## Important Constraints + +- Do NOT execute git commit, only code modifications +- Follow all dev specs injected above +- Report list of modified/created files when done""" + + +def build_check_prompt(original_prompt: str, context: str) -> str: + """Build complete prompt for Check""" + return f"""# Check Agent Task + +You are the Check Agent in the Multi-Agent Pipeline (code and cross-layer checker). + +## Your Context + +All check specs and dev specs you need: + +{context} + +--- + +## Your Task + +{original_prompt} + +--- + +## Workflow + +1. **Get changes** - Run `git diff --name-only` and `git diff` to get code changes +2. **Check against specs** - Check item by item against specs above +3. **Self-fix** - Fix issues directly, don't just report +4. **Run verification** - Run project's lint and typecheck commands + +## Important Constraints + +- Fix issues yourself, don't just report +- Must execute complete checklist in check specs +- Pay special attention to impact radius analysis (L1-L5)""" + + +def build_finish_prompt(original_prompt: str, context: str) -> str: + """Build complete prompt for Finish (final check before PR)""" + return f"""# Finish Agent Task + +You are performing the final check before creating a PR. + +## Your Context + +Finish checklist and requirements: + +{context} + +--- + +## Your Task + +{original_prompt} + +--- + +## Workflow + +1. **Review changes** - Run `git diff --name-only` to see all changed files +2. **Verify requirements** - Check each requirement in prd.md is implemented +3. **Spec sync** - Analyze whether changes introduce new patterns, contracts, or conventions + - If new pattern/convention found: read target spec file → update it → update index.md if needed + - If infra/cross-layer change: follow the 7-section mandatory template from update-spec.md + - If pure code fix with no new patterns: skip this step +4. **Run final checks** - Execute lint and typecheck +5. **Confirm ready** - Ensure code is ready for PR + +## Important Constraints + +- You MAY update spec files when gaps are detected (use update-spec.md as guide) +- MUST read the target spec file BEFORE editing (avoid duplicating existing content) +- Do NOT update specs for trivial changes (typos, formatting, obvious fixes) +- If critical CODE issues found, report them clearly (fix specs, not code) +- Verify all acceptance criteria in prd.md are met""" + + +def build_debug_prompt(original_prompt: str, context: str) -> str: + """Build complete prompt for Debug""" + return f"""# Debug Agent Task + +You are the Debug Agent in the Multi-Agent Pipeline (issue fixer). + +## Your Context + +Dev specs and Codex Review results: + +{context} + +--- + +## Your Task + +{original_prompt} + +--- + +## Workflow + +1. **Understand issues** - Analyze issues pointed out in Codex Review +2. **Locate code** - Find positions that need fixing +3. **Fix against specs** - Fix issues following dev specs +4. **Verify fixes** - Run typecheck to ensure no new issues + +## Important Constraints + +- Do NOT execute git commit, only code modifications +- Run typecheck after each fix to verify +- Report which issues were fixed and which files were modified""" + + +def get_research_context(repo_root: str, task_dir: str | None) -> str: + """ + Context for Research Agent + + Research doesn't need much preset context, only needs: + 1. Project structure overview (where spec directories are) + 2. Optional research.jsonl (if there are specific search needs) + """ + context_parts = [] + + # 1. Project structure overview (uses constants for paths) + spec_path = f"{DIR_WORKFLOW}/{DIR_SPEC}" + project_structure = f"""## Project Spec Directory Structure + +``` +{spec_path}/ +├── shared/ # Cross-project common specs (TypeScript, code quality, git) +├── frontend/ # Frontend standards +├── backend/ # Backend standards +└── guides/ # Thinking guides (cross-layer, code reuse, etc.) + +{DIR_WORKFLOW}/big-question/ # Known issues and pitfalls +``` + +## Search Tips + +- Spec files: `{spec_path}/**/*.md` +- Known issues: `{DIR_WORKFLOW}/big-question/` +- Code search: Use Glob and Grep tools +- Tech solutions: Use mcp__exa__web_search_exa or mcp__exa__get_code_context_exa""" + + context_parts.append(project_structure) + + # 2. If task directory exists, try reading research.jsonl (optional) + if task_dir: + research_entries = read_jsonl_entries(repo_root, f"{task_dir}/research.jsonl") + if research_entries: + context_parts.append( + "\n## Additional Search Context (from research.jsonl)\n" + ) + for file_path, content in research_entries: + context_parts.append(f"=== {file_path} ===\n{content}") + + return "\n\n".join(context_parts) + + +def build_research_prompt(original_prompt: str, context: str) -> str: + """Build complete prompt for Research""" + return f"""# Research Agent Task + +You are the Research Agent in the Multi-Agent Pipeline (search researcher). + +## Core Principle + +**You do one thing: find and explain information.** + +You are a documenter, not a reviewer. + +## Project Info + +{context} + +--- + +## Your Task + +{original_prompt} + +--- + +## Workflow + +1. **Understand query** - Determine search type (internal/external) and scope +2. **Plan search** - List search steps for complex queries +3. **Execute search** - Execute multiple independent searches in parallel +4. **Organize results** - Output structured report + +## Search Tools + +| Tool | Purpose | +|------|---------| +| Glob | Search by filename pattern | +| Grep | Search by content | +| Read | Read file content | +| mcp__exa__web_search_exa | External web search | +| mcp__exa__get_code_context_exa | External code/doc search | + +## Strict Boundaries + +**Only allowed**: Describe what exists, where it is, how it works + +**Forbidden** (unless explicitly asked): +- Suggest improvements +- Criticize implementation +- Recommend refactoring +- Modify any files + +## Report Format + +Provide structured search results including: +- List of files found (with paths) +- Code pattern analysis (if applicable) +- Related spec documents +- External references (if any)""" + + +def main(): + try: + input_data = json.load(sys.stdin) + except json.JSONDecodeError: + sys.exit(0) + + tool_name = input_data.get("tool_name", "") + + if tool_name not in ("Task", "Agent"): + sys.exit(0) + + tool_input = input_data.get("tool_input", {}) + subagent_type = tool_input.get("subagent_type", "") + original_prompt = tool_input.get("prompt", "") + cwd = input_data.get("cwd", os.getcwd()) + + # Only handle subagent types we care about + if subagent_type not in AGENTS_ALL: + sys.exit(0) + + # Find repo root + repo_root = find_repo_root(cwd) + if not repo_root: + sys.exit(0) + + # Get current task directory (research doesn't require it) + task_dir = get_current_task(repo_root) + + # implement/check/debug need task directory + if subagent_type in AGENTS_REQUIRE_TASK: + if not task_dir: + sys.exit(0) + # Check if task directory exists + task_dir_full = os.path.join(repo_root, task_dir) + if not os.path.exists(task_dir_full): + sys.exit(0) + + # Update current_phase in task.json (system-level enforcement) + update_current_phase(repo_root, task_dir, subagent_type) + + # Check for [finish] marker in prompt (check agent with finish context) + is_finish_phase = "[finish]" in original_prompt.lower() + + # Get context and build prompt based on subagent type + if subagent_type == AGENT_IMPLEMENT: + assert task_dir is not None # validated above + context = get_implement_context(repo_root, task_dir) + new_prompt = build_implement_prompt(original_prompt, context) + elif subagent_type == AGENT_CHECK: + assert task_dir is not None # validated above + if is_finish_phase: + # Finish phase: use finish context (lighter, focused on final verification) + context = get_finish_context(repo_root, task_dir) + new_prompt = build_finish_prompt(original_prompt, context) + else: + # Regular check phase: use check context (full specs for self-fix loop) + context = get_check_context(repo_root, task_dir) + new_prompt = build_check_prompt(original_prompt, context) + elif subagent_type == AGENT_DEBUG: + assert task_dir is not None # validated above + context = get_debug_context(repo_root, task_dir) + new_prompt = build_debug_prompt(original_prompt, context) + elif subagent_type == AGENT_RESEARCH: + # Research can work without task directory + context = get_research_context(repo_root, task_dir) + new_prompt = build_research_prompt(original_prompt, context) + else: + sys.exit(0) + + if not context: + sys.exit(0) + + # Return updated input with correct Claude Code PreToolUse format + output = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "updatedInput": {**tool_input, "prompt": new_prompt}, + } + } + + print(json.dumps(output, ensure_ascii=False)) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/ralph-loop.py b/.claude/hooks/ralph-loop.py new file mode 100644 index 00000000..983660fc --- /dev/null +++ b/.claude/hooks/ralph-loop.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Ralph Loop - SubagentStop Hook for Check Agent Loop Control + +Based on the Ralph Wiggum technique for autonomous agent loops. +Uses completion promises to control when the check agent can stop. + +Mechanism: +- Intercepts when check subagent tries to stop (SubagentStop event) +- If verify commands configured in worktree.yaml, runs them to verify +- Otherwise, reads check.jsonl to get dynamic completion markers ({reason}_FINISH) +- Blocks stopping until verification passes or all markers found +- Has max iterations as safety limit + +State file: .trellis/.ralph-state.json +- Tracks current iteration count per session +- Resets when task changes +""" + +# IMPORTANT: Suppress all warnings FIRST +import warnings +warnings.filterwarnings("ignore") + +import json +import os +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +# IMPORTANT: Force stdout to use UTF-8 on Windows +# This fixes UnicodeEncodeError when outputting non-ASCII characters +if sys.platform == "win32": + import io as _io + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + elif hasattr(sys.stdout, "detach"): + sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr] + +# ============================================================================= +# Configuration +# ============================================================================= + +MAX_ITERATIONS = 5 # Safety limit to prevent infinite loops +STATE_TIMEOUT_MINUTES = 30 # Reset state if older than this +STATE_FILE = ".trellis/.ralph-state.json" +WORKTREE_YAML = ".trellis/worktree.yaml" +DIR_WORKFLOW = ".trellis" +FILE_CURRENT_TASK = ".current-task" + +# Only control loop for check agent +TARGET_AGENT = "check" + + +def find_repo_root(start_path: str) -> str | None: + """Find git repo root from start_path upwards""" + current = Path(start_path).resolve() + while current != current.parent: + if (current / ".git").exists(): + return str(current) + current = current.parent + return None + + +def get_current_task(repo_root: str) -> str | None: + """Read current task directory path""" + current_task_file = os.path.join(repo_root, DIR_WORKFLOW, FILE_CURRENT_TASK) + if not os.path.exists(current_task_file): + return None + + try: + with open(current_task_file, "r", encoding="utf-8") as f: + content = f.read().strip() + return content if content else None + except Exception: + return None + + +def get_verify_commands(repo_root: str) -> list[str]: + """ + Read verify commands from worktree.yaml. + + Returns list of commands to run, or empty list if not configured. + Uses simple YAML parsing without external dependencies. + """ + yaml_path = os.path.join(repo_root, WORKTREE_YAML) + if not os.path.exists(yaml_path): + return [] + + try: + with open(yaml_path, "r", encoding="utf-8") as f: + content = f.read() + + # Simple YAML parsing for verify section + # Look for "verify:" followed by list items + lines = content.split("\n") + in_verify_section = False + commands = [] + + for line in lines: + stripped = line.strip() + + # Check for section start + if stripped.startswith("verify:"): + in_verify_section = True + continue + + # Check for new section (not indented, ends with :) + if ( + not line.startswith(" ") + and not line.startswith("\t") + and stripped.endswith(":") + and stripped != "" + ): + in_verify_section = False + continue + + # If in verify section, look for list items + if in_verify_section: + # Skip comments and empty lines + if stripped.startswith("#") or stripped == "": + continue + # Parse list item (- command) + if stripped.startswith("- "): + cmd = stripped[2:].strip() + if cmd: + commands.append(cmd) + + return commands + except Exception: + return [] + + +def run_verify_commands(repo_root: str, commands: list[str]) -> tuple[bool, str]: + """ + Run verify commands and return (success, message). + + All commands must pass for success. + """ + for cmd in commands: + try: + result = subprocess.run( + cmd, + shell=True, + cwd=repo_root, + capture_output=True, + timeout=120, # 2 minute timeout per command + ) + if result.returncode != 0: + stderr = result.stderr.decode("utf-8", errors="replace") + stdout = result.stdout.decode("utf-8", errors="replace") + error_output = stderr or stdout + # Truncate long output + if len(error_output) > 500: + error_output = error_output[:500] + "..." + return False, f"Command failed: {cmd}\n{error_output}" + except subprocess.TimeoutExpired: + return False, f"Command timed out: {cmd}" + except Exception as e: + return False, f"Command error: {cmd} - {str(e)}" + + return True, "All verify commands passed" + + +def get_completion_markers(repo_root: str, task_dir: str) -> list[str]: + """ + Read check.jsonl and generate completion markers from reasons. + + Each entry's "reason" field becomes {REASON}_FINISH marker. + Example: {"file": "...", "reason": "TypeCheck"} -> "TYPECHECK_FINISH" + """ + check_jsonl_path = os.path.join(repo_root, task_dir, "check.jsonl") + markers = [] + + if not os.path.exists(check_jsonl_path): + # Fallback: if no check.jsonl, use default marker + return ["ALL_CHECKS_FINISH"] + + try: + with open(check_jsonl_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + item = json.loads(line) + reason = item.get("reason", "") + if reason: + # Convert to uppercase and add _FINISH suffix + marker = f"{reason.upper().replace(' ', '_')}_FINISH" + if marker not in markers: + markers.append(marker) + except json.JSONDecodeError: + continue + except Exception: + pass + + # If no markers found, use default + if not markers: + markers = ["ALL_CHECKS_FINISH"] + + return markers + + +def load_state(repo_root: str) -> dict: + """Load Ralph Loop state from file""" + state_path = os.path.join(repo_root, STATE_FILE) + if not os.path.exists(state_path): + return {"task": None, "iteration": 0, "started_at": None} + + try: + with open(state_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return {"task": None, "iteration": 0, "started_at": None} + + +def save_state(repo_root: str, state: dict) -> None: + """Save Ralph Loop state to file""" + state_path = os.path.join(repo_root, STATE_FILE) + try: + # Ensure directory exists + os.makedirs(os.path.dirname(state_path), exist_ok=True) + with open(state_path, "w", encoding="utf-8") as f: + json.dump(state, f, indent=2, ensure_ascii=False) + except Exception: + pass + + +def check_completion(agent_output: str, markers: list[str]) -> tuple[bool, list[str]]: + """ + Check if all completion markers are present in agent output. + + Returns: + (all_complete, missing_markers) + """ + missing = [] + for marker in markers: + if marker not in agent_output: + missing.append(marker) + + return len(missing) == 0, missing + + +def main(): + try: + input_data = json.load(sys.stdin) + except json.JSONDecodeError: + # If can't parse input, allow stop + sys.exit(0) + + # Get event info + hook_event = input_data.get("hook_event_name", "") + + # Only handle SubagentStop event + if hook_event != "SubagentStop": + sys.exit(0) + + # Get subagent info + subagent_type = input_data.get("subagent_type", "") + agent_output = input_data.get("agent_output", "") + original_prompt = input_data.get("prompt", "") + cwd = input_data.get("cwd", os.getcwd()) + + # Only control check agent + if subagent_type != TARGET_AGENT: + sys.exit(0) + + # Skip Ralph Loop for finish phase (already verified in check phase) + if "[finish]" in original_prompt.lower(): + sys.exit(0) + + # Find repo root + repo_root = find_repo_root(cwd) + if not repo_root: + sys.exit(0) + + # Get current task + task_dir = get_current_task(repo_root) + if not task_dir: + sys.exit(0) + + # Load state + state = load_state(repo_root) + + # Reset state if task changed or state is too old + should_reset = False + if state.get("task") != task_dir: + should_reset = True + elif state.get("started_at"): + try: + started = datetime.fromisoformat(state["started_at"]) + if (datetime.now() - started).total_seconds() > STATE_TIMEOUT_MINUTES * 60: + should_reset = True + except (ValueError, TypeError): + should_reset = True + + if should_reset: + state = { + "task": task_dir, + "iteration": 0, + "started_at": datetime.now().isoformat(), + } + + # Increment iteration + state["iteration"] = state.get("iteration", 0) + 1 + current_iteration = state["iteration"] + + # Save state + save_state(repo_root, state) + + # Safety check: max iterations + if current_iteration >= MAX_ITERATIONS: + # Allow stop, reset state for next run + state["iteration"] = 0 + save_state(repo_root, state) + output = { + "decision": "allow", + "reason": f"Max iterations ({MAX_ITERATIONS}) reached. Stopping to prevent infinite loop.", + } + print(json.dumps(output, ensure_ascii=False)) + sys.exit(0) + + # Check if verify commands are configured + verify_commands = get_verify_commands(repo_root) + + if verify_commands: + # Use programmatic verification + passed, message = run_verify_commands(repo_root, verify_commands) + + if passed: + # All verify commands passed, allow stop + state["iteration"] = 0 + save_state(repo_root, state) + output = { + "decision": "allow", + "reason": "All verify commands passed. Check phase complete.", + } + print(json.dumps(output, ensure_ascii=False)) + sys.exit(0) + else: + # Verification failed, block stop + output = { + "decision": "block", + "reason": f"Iteration {current_iteration}/{MAX_ITERATIONS}. Verification failed:\n{message}\n\nPlease fix the issues and try again.", + } + print(json.dumps(output, ensure_ascii=False)) + sys.exit(0) + else: + # No verify commands, fall back to completion markers + markers = get_completion_markers(repo_root, task_dir) + all_complete, missing = check_completion(agent_output, markers) + + if all_complete: + # All checks complete, allow stop + state["iteration"] = 0 + save_state(repo_root, state) + output = { + "decision": "allow", + "reason": "All completion markers found. Check phase complete.", + } + print(json.dumps(output, ensure_ascii=False)) + sys.exit(0) + else: + # Missing markers, block stop and continue + output = { + "decision": "block", + "reason": f"""Iteration {current_iteration}/{MAX_ITERATIONS}. Missing completion markers: {", ".join(missing)}. + +IMPORTANT: You must ACTUALLY run the checks, not just output the markers. +- Did you run lint? What was the output? +- Did you run typecheck? What was the output? +- Did they actually pass with zero errors? + +Only output a marker (e.g., LINT_FINISH) AFTER: +1. You have executed the corresponding command +2. The command completed with zero errors +3. You have shown the command output in your response + +Do NOT output markers just to escape the loop. The loop exists to ensure quality.""", + } + print(json.dumps(output, ensure_ascii=False)) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/session-start.py b/.claude/hooks/session-start.py new file mode 100644 index 00000000..eeee0c1a --- /dev/null +++ b/.claude/hooks/session-start.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Session Start Hook - Inject structured context +""" + +# IMPORTANT: Suppress all warnings FIRST +import warnings +warnings.filterwarnings("ignore") + +import json +import os +import subprocess +import sys +from io import StringIO +from pathlib import Path + +# IMPORTANT: Force stdout to use UTF-8 on Windows +# This fixes UnicodeEncodeError when outputting non-ASCII characters +if sys.platform == "win32": + import io as _io + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + elif hasattr(sys.stdout, "detach"): + sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr] + + +def should_skip_injection() -> bool: + return ( + os.environ.get("CLAUDE_NON_INTERACTIVE") == "1" + or os.environ.get("OPENCODE_NON_INTERACTIVE") == "1" + ) + + +def read_file(path: Path, fallback: str = "") -> str: + try: + return path.read_text(encoding="utf-8") + except (FileNotFoundError, PermissionError): + return fallback + + +def run_script(script_path: Path) -> str: + try: + if script_path.suffix == ".py": + # Add PYTHONIOENCODING to force UTF-8 in subprocess + env = os.environ.copy() + env["PYTHONIOENCODING"] = "utf-8" + cmd = [sys.executable, "-W", "ignore", str(script_path)] + else: + env = os.environ + cmd = [str(script_path)] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=5, + cwd=script_path.parent.parent.parent, + env=env, + ) + return result.stdout if result.returncode == 0 else "No context available" + except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError): + return "No context available" + + +def _get_task_status(trellis_dir: Path) -> str: + """Check current task status and return structured status string.""" + current_task_file = trellis_dir / ".current-task" + if not current_task_file.is_file(): + return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on" + + task_ref = current_task_file.read_text(encoding="utf-8").strip() + if not task_ref: + return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on" + + # Resolve task directory + if Path(task_ref).is_absolute(): + task_dir = Path(task_ref) + elif task_ref.startswith(".trellis/"): + task_dir = trellis_dir.parent / task_ref + else: + task_dir = trellis_dir / "tasks" / task_ref + if not task_dir.is_dir(): + return f"Status: STALE POINTER\nTask: {task_ref}\nNext: Task directory not found. Run: python3 ./.trellis/scripts/task.py finish" + + # Read task.json + task_json_path = task_dir / "task.json" + task_data = {} + if task_json_path.is_file(): + try: + task_data = json.loads(task_json_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, PermissionError): + pass + + task_title = task_data.get("title", task_ref) + task_status = task_data.get("status", "unknown") + + if task_status == "completed": + return f"Status: COMPLETED\nTask: {task_title}\nNext: Archive with `python3 ./.trellis/scripts/task.py archive {task_dir.name}` or start a new task" + + # Check if context is configured (jsonl files exist and non-empty) + has_context = False + for jsonl_name in ("implement.jsonl", "check.jsonl", "spec.jsonl"): + jsonl_path = task_dir / jsonl_name + if jsonl_path.is_file() and jsonl_path.stat().st_size > 0: + has_context = True + break + + has_prd = (task_dir / "prd.md").is_file() + + if not has_prd: + return f"Status: NOT READY\nTask: {task_title}\nMissing: prd.md not created\nNext: Write PRD, then research → init-context → start" + + if not has_context: + return f"Status: NOT READY\nTask: {task_title}\nMissing: Context not configured (no jsonl files)\nNext: Complete Phase 2 (research → init-context → start) before implementing" + + return f"Status: READY\nTask: {task_title}\nNext: Continue with implement or check" + + +def main(): + if should_skip_injection(): + sys.exit(0) + + project_dir = Path(os.environ.get("CLAUDE_PROJECT_DIR", ".")).resolve() + trellis_dir = project_dir / ".trellis" + claude_dir = project_dir / ".claude" + + output = StringIO() + + output.write("""<session-context> +You are starting a new session in a Trellis-managed project. +Read and follow all instructions below carefully. +</session-context> + +""") + + output.write("<current-state>\n") + context_script = trellis_dir / "scripts" / "get_context.py" + output.write(run_script(context_script)) + output.write("\n</current-state>\n\n") + + output.write("<workflow>\n") + workflow_content = read_file(trellis_dir / "workflow.md", "No workflow.md found") + output.write(workflow_content) + output.write("\n</workflow>\n\n") + + output.write("<guidelines>\n") + output.write("**Note**: The guidelines below are index files — they list available guideline documents and their locations.\n") + output.write("During actual development, you MUST read the specific guideline files listed in each index's Pre-Development Checklist.\n\n") + + spec_dir = trellis_dir / "spec" + if spec_dir.is_dir(): + for sub in sorted(spec_dir.iterdir()): + if not sub.is_dir() or sub.name.startswith("."): + continue + index_file = sub / "index.md" + if index_file.is_file(): + output.write(f"## {sub.name}\n") + output.write(read_file(index_file)) + output.write("\n\n") + else: + # Check for nested package dirs (monorepo: spec/<pkg>/<layer>/index.md) + for nested in sorted(sub.iterdir()): + if not nested.is_dir(): + continue + nested_index = nested / "index.md" + if nested_index.is_file(): + output.write(f"## {sub.name}/{nested.name}\n") + output.write(read_file(nested_index)) + output.write("\n\n") + + output.write("</guidelines>\n\n") + + output.write("<instructions>\n") + start_md = read_file( + claude_dir / "commands" / "trellis" / "start.md", "No start.md found" + ) + output.write(start_md) + output.write("\n</instructions>\n\n") + + # R2: Check task status and inject structured tag + task_status = _get_task_status(trellis_dir) + output.write(f"<task-status>\n{task_status}\n</task-status>\n\n") + + output.write("""<ready> +Context loaded. Steps 1-3 (workflow, context, guidelines) are already injected above — do NOT re-read them. +Start from Step 4. Wait for user's first message, then follow <instructions> to handle their request. +If there is an active task, ask whether to continue it. +</ready>""") + + result = { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": output.getvalue(), + } + } + + # Output JSON - stdout is already configured for UTF-8 + print(json.dumps(result, ensure_ascii=False), flush=True) + + +if __name__ == "__main__": + main() diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..d6ed2708 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,71 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/session-start.py", + "timeout": 10 + } + ] + }, + { + "matcher": "clear", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/session-start.py", + "timeout": 10 + } + ] + }, + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/session-start.py", + "timeout": 10 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Task", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/inject-subagent-context.py", + "timeout": 30 + } + ] + }, + { + "matcher": "Agent", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/inject-subagent-context.py", + "timeout": 30 + } + ] + } + ], + "SubagentStop": [ + { + "matcher": "check", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/ralph-loop.py", + "timeout": 10 + } + ] + } + ] + }, + "enabledPlugins": {} +} diff --git a/.trellis/.gitignore b/.trellis/.gitignore new file mode 100644 index 00000000..46135ba0 --- /dev/null +++ b/.trellis/.gitignore @@ -0,0 +1,29 @@ +# Developer identity (local only) +.developer + +# Current task pointer (each dev works on different task) +.current-task + +# Ralph Loop state file +.ralph-state.json + +# Agent runtime files +.agents/ +.agent-log +.session-id + +# Task directory runtime files +.plan-log + +# Atomic update temp files +*.tmp + +# Update backup directories +.backup-* + +# Conflict resolution temp files +*.new + +# Python cache +**/__pycache__/ +**/*.pyc diff --git a/.trellis/.template-hashes.json b/.trellis/.template-hashes.json new file mode 100644 index 00000000..a451d294 --- /dev/null +++ b/.trellis/.template-hashes.json @@ -0,0 +1,68 @@ +{ + ".trellis/config.yaml": "fe1fba0961e589c6f49190f5e19d4edb0d5bf894dba8468f06882c6e1c5e2aa1", + ".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c", + ".trellis/scripts/add_session.py": "7c869be8146e6f675bd95e424909ff301ea0a8f8fd82a4f056f6d320e755a406", + ".trellis/scripts/common/__init__.py": "301724230abcce6e9fc99054c12d21c30eea7bc3b330ae6350aa3b6158461273", + ".trellis/scripts/common/cli_adapter.py": "66ef4f75470807b531490a6b6928604eb59781148fe3c5412f39e132ffab0850", + ".trellis/scripts/common/config.py": "909257b442d7d1e7a2596996622c4f2f010d8c1343e1efd088ef8615d99554c7", + ".trellis/scripts/common/developer.py": "69f6145c4c48953677de3ba06f487ba2a1675f4d66153346ab40594bb06a01c9", + ".trellis/scripts/common/git_context.py": "f154d358c858f7bcfc21a03c9b909af3a8dfa20be37b2c5012d84b8e0588b493", + ".trellis/scripts/common/paths.py": "058f333fb80c71c90ddc131742e8e64949c2f1ed07c1254d8f7232506d891ffc", + ".trellis/scripts/common/phase.py": "f9bdd553c7a278b97736b04c066ed06d8baa2ef179ed8219befcf6c27afcc9cd", + ".trellis/scripts/common/registry.py": "6c65db45a487ef839b0a4b5b20abe201547269c20c7257254293a89dc01b56dc", + ".trellis/scripts/common/task_queue.py": "6de22c7731465ee52d2b5cd4853b191d3cf869bf259fbc93079b426ba1c3756c", + ".trellis/scripts/common/task_utils.py": "e19c290d90f9a779db161aeb9fefda27852847fbc67d358d471530b8ede64131", + ".trellis/scripts/common/worktree.py": "434880e02dfa2e92f0c717ed2a28e4cdee681ea10c329a2438d533bdbc612408", + ".trellis/scripts/create_bootstrap.py": "aa5dd1f39a77b2f4bb827fd14ce7a83fb51870e77f556fe508afce3f8eac0b4e", + ".trellis/scripts/get_context.py": "ca5bf9e90bdb1d75d3de182b95f820f9d108ab28793d29097b24fd71315adcf5", + ".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f", + ".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e", + ".trellis/scripts/multi_agent/__init__.py": "af6fceb4d9a64da04be03ba0f5a6daf71066503eca832b8b58d8a7d4b2844fa4", + ".trellis/scripts/multi_agent/cleanup.py": "db50c4fbb32261905a8278c2760b33029f187963cd4e448938e57f3db3facd6c", + ".trellis/scripts/multi_agent/create_pr.py": "6a2423aba5720a2150c32349faa957cdc59c6bb96511e56c79ca08d92d69c666", + ".trellis/scripts/multi_agent/plan.py": "242b870b7667f730c910d629f16d44d5d3fd0a58f6451d9003c175fb2e77cee5", + ".trellis/scripts/multi_agent/start.py": "32ed1a13405b7c71881b2507a79e1a3733bc3fcedbc92fcee0d733ce00d759d0", + ".trellis/scripts/multi_agent/status.py": "5fc46b6d605c69b6044967a6b33ffb0c9d6f99dd919374572ac614222864a811", + ".trellis/scripts/task.py": "ecf52885a698dc93af67fd693825a2f71163ab86b5c2abe76d8aa2e2caa44372", + ".trellis/workflow.md": "9b6d6e8027bd2cf32d9efd7ef77d6524c59fcaa4ad6052f72d028a07a5fd69a7", + ".trellis/worktree.yaml": "c57de79e40d5f748f099625ed4a17d5f0afbf25cac598aced0b3c964e7b7c226", + ".claude/agents/check.md": "7c7400e7ea8bf3f3f879bfa028fd5b4d41673e0150d44c52292161ba33612812", + ".claude/agents/debug.md": "94be0b1cfbae4c64caee4775ef504f43acfcd4a80427a26d6f680ceaddcbee24", + ".claude/agents/dispatch.md": "20e699a87aeb0b046c51d8485e433190916c645e21db9a06f9e468272738347e", + ".claude/agents/implement.md": "d537797d3fa510afdeaa365d43ef897a261e71c9144ef6986b8574be8d09055c", + ".claude/agents/plan.md": "d796f689b8b8945d1809679d0c74475f419325b30f36ef0c59b7fae73386e90b", + ".claude/agents/research.md": "086ae23120151b3591089a4de20fd54e6ae2b89038f5903ee9a52269cd7ded6a", + ".claude/commands/trellis/before-backend-dev.md": "7e35444de2a5779ef39944f17f566ea21d2ed7f4994246f4cfe6ebf9a11dd3e3", + ".claude/commands/trellis/before-frontend-dev.md": "a6225f9d123dbd4a7aec822652030cae50be3f5b308297015e04d42b23a27b2a", + ".claude/commands/trellis/brainstorm.md": "7c7731eda092275a5d87f2569a69584f3c39b544a126a76e727a1e9d250c4a65", + ".claude/commands/trellis/break-loop.md": "ba4dd4022dde1e4bbcfc1cc99e6a118e51b9db95bd962d88f1c29d0c9c433112", + ".claude/commands/trellis/check-backend.md": "4e81a28d681ea770f780df55a212fd504ce21ee49b44ba16023b74b5c243cef3", + ".claude/commands/trellis/check-cross-layer.md": "b9ab24515ead84330d6634f6ad912ca3547db3a36139d62c5688161824097d60", + ".claude/commands/trellis/check-frontend.md": "5e8e3b682032ba0dd6bb843dd4826fff0159f78a7084964ccb119c6cf98b3d91", + ".claude/commands/trellis/create-command.md": "c2825c7941b4ef4a3f3365c4c807ff138096a39aece3d051776f3c11a4e4857d", + ".claude/commands/trellis/finish-work.md": "cc92cad9e94ce1cc4f29e3de16a640db7e9176e3ecfc9c19a566153671ca2168", + ".claude/commands/trellis/integrate-skill.md": "3940442485341832257c595ddfb45582e2d60e5a4716f2bd15b7bce0498b130a", + ".claude/commands/trellis/onboard.md": "a5dbd5db094b13fd006ec856efa53a688e209bcdc3ed1680b63b15f1e3293ab4", + ".claude/commands/trellis/parallel.md": "f4c81fe1a468be214caf362263b14b6a6f40935497363109148cb7b19e644738", + ".claude/commands/trellis/record-session.md": "0c4f61283c2f262c1f9c900d9207309107497d4ac848cca86eb62bc5b7189fe7", + ".claude/commands/trellis/start.md": "2d4259d8d146d32c7b6c33dda36c14da76e1c3f1be35b27dc18e5eb5551c9276", + ".claude/commands/trellis/update-spec.md": "ff4d5a0405a763e61936f5b9df175fd25ea20ec5c20fa999855020ab78a919b6", + ".claude/hooks/inject-subagent-context.py": "75ce4cc175a00f9afa5fe1c80298e29521359ad90a66701c3c1166aa588f3080", + ".claude/hooks/ralph-loop.py": "a367a5dd4f605730cf8157c61658e848176ae480be19029126ff9bbd90a37712", + ".claude/hooks/session-start.py": "5c048949cbf8ac58c7c26ef51cd90bf91454574425f2158f4778c200b8098f53", + ".claude/settings.json": "fdb7fcf660961b4b52f22f08e91f942a193e1a3f5ebbca9cbba21a157d1c359d", + ".claude/settings.local.json": "08bcd54315e5bf82516a005a17a95b5d46b3c40bdfbd5cc7431ff12e17a4fcb9", + ".agents/skills/before-backend-dev/SKILL.md": "4537ccee0071353beee636a052c01642a27a87b6b0a73e7bc872b2501547fa64", + ".agents/skills/before-frontend-dev/SKILL.md": "679c1708a4d9fbad5214db299a38366581684a9383cf51a5d8ac21f890d6ba0d", + ".agents/skills/brainstorm/SKILL.md": "0cabc8e663a871dee6c8bbf7f149fe10f83f39835e66ad0a8d0867049aacb6f8", + ".agents/skills/break-loop/SKILL.md": "b19a47854ca66bde4ee03a30603480b4af2c131d5d81d752d1d28d2ef5131172", + ".agents/skills/check-backend/SKILL.md": "9b312cfd7a07ed036769b387d84d642cd5e20f06b88e7b6a4626705fa8beb6fa", + ".agents/skills/check-cross-layer/SKILL.md": "bc72df11d79a8ee809f45eae120c1cce91ab997541ce30d665af9978c83843f6", + ".agents/skills/check-frontend/SKILL.md": "27b75f9eea472ed104f39a65bb78ae559cfe8730c85e0742e55fd575a4a2f854", + ".agents/skills/create-command/SKILL.md": "5c24ca19c1cec64486f1a147e1dd4a37200270cbf3d0987dc6536f7de85a78f2", + ".agents/skills/finish-work/SKILL.md": "f3f77e3902021bb7d95452a6072ae3f67993bf7b7d0e172e33756a633b654bf2", + ".agents/skills/integrate-skill/SKILL.md": "47b7374345d8a31f9df07c5e8e875ca4fdc30d0cc45860d77df893250e2d97fc", + ".agents/skills/onboard/SKILL.md": "1808f578d21eae3cbcf650d6aa4cf35ac42bf466df740b830593c9bda212d51a", + ".agents/skills/record-session/SKILL.md": "ce27e953630a71ef989c5582790e9c8a600a2614ec668b674816c1daac73ce0a", + ".agents/skills/start/SKILL.md": "788e517f9e57c4ce68497c1cefabd51faa8253c681be7965915f21e6de9c5886" +} \ No newline at end of file diff --git a/.trellis/.version b/.trellis/.version new file mode 100644 index 00000000..81de5c57 --- /dev/null +++ b/.trellis/.version @@ -0,0 +1 @@ +0.3.10 \ No newline at end of file diff --git a/.trellis/config.yaml b/.trellis/config.yaml new file mode 100644 index 00000000..7d18551b --- /dev/null +++ b/.trellis/config.yaml @@ -0,0 +1,33 @@ +# Trellis Configuration +# Project-level settings for the Trellis workflow system +# +# All values have sensible defaults. Only override what you need. + +#------------------------------------------------------------------------------- +# Session Recording +#------------------------------------------------------------------------------- + +# Commit message used when auto-committing journal/index changes +# after running add_session.py +session_commit_message: "chore: record journal" + +# Maximum lines per journal file before rotating to a new one +max_journal_lines: 2000 + +#------------------------------------------------------------------------------- +# Task Lifecycle Hooks +#------------------------------------------------------------------------------- + +# Shell commands to run after task lifecycle events. +# Each hook receives TASK_JSON_PATH environment variable pointing to task.json. +# Hook failures print a warning but do not block the main operation. +# +# hooks: +# after_create: +# - "echo 'Task created'" +# after_start: +# - "echo 'Task started'" +# after_finish: +# - "echo 'Task finished'" +# after_archive: +# - "echo 'Task archived'" diff --git a/.trellis/scripts/__init__.py b/.trellis/scripts/__init__.py new file mode 100755 index 00000000..815a1374 --- /dev/null +++ b/.trellis/scripts/__init__.py @@ -0,0 +1,5 @@ +""" +Trellis Python Scripts + +This module provides Python implementations of Trellis workflow scripts. +""" diff --git a/.trellis/scripts/add_session.py b/.trellis/scripts/add_session.py new file mode 100755 index 00000000..71606e5b --- /dev/null +++ b/.trellis/scripts/add_session.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Add a new session to journal file and update index.md. + +Usage: + python3 add_session.py --title "Title" --commit "hash" --summary "Summary" + echo "content" | python3 add_session.py --title "Title" --commit "hash" +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +from common.paths import ( + FILE_JOURNAL_PREFIX, + get_repo_root, + get_developer, + get_workspace_dir, +) +from common.developer import ensure_developer +from common.config import get_session_commit_message, get_max_journal_lines + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def get_latest_journal_info(dev_dir: Path) -> tuple[Path | None, int, int]: + """Get latest journal file info. + + Returns: + Tuple of (file_path, file_number, line_count). + """ + latest_file: Path | None = None + latest_num = -1 + + for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"): + if not f.is_file(): + continue + + match = re.search(r"(\d+)$", f.stem) + if match: + num = int(match.group(1)) + if num > latest_num: + latest_num = num + latest_file = f + + if latest_file: + lines = len(latest_file.read_text(encoding="utf-8").splitlines()) + return latest_file, latest_num, lines + + return None, 0, 0 + + +def get_current_session(index_file: Path) -> int: + """Get current session number from index.md.""" + if not index_file.is_file(): + return 0 + + content = index_file.read_text(encoding="utf-8") + for line in content.splitlines(): + if "Total Sessions" in line: + match = re.search(r":\s*(\d+)", line) + if match: + return int(match.group(1)) + return 0 + + +def _extract_journal_num(filename: str) -> int: + """Extract journal number from filename for sorting.""" + match = re.search(r"(\d+)", filename) + return int(match.group(1)) if match else 0 + + +def count_journal_files(dev_dir: Path, active_num: int) -> str: + """Count journal files and return table rows.""" + active_file = f"{FILE_JOURNAL_PREFIX}{active_num}.md" + result_lines = [] + + files = sorted( + [f for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md") if f.is_file()], + key=lambda f: _extract_journal_num(f.stem), + reverse=True + ) + + for f in files: + filename = f.name + lines = len(f.read_text(encoding="utf-8").splitlines()) + status = "Active" if filename == active_file else "Archived" + result_lines.append(f"| `{filename}` | ~{lines} | {status} |") + + return "\n".join(result_lines) + + +def create_new_journal_file( + dev_dir: Path, num: int, developer: str, today: str, max_lines: int = 2000, +) -> Path: + """Create a new journal file.""" + prev_num = num - 1 + new_file = dev_dir / f"{FILE_JOURNAL_PREFIX}{num}.md" + + content = f"""# Journal - {developer} (Part {num}) + +> Continuation from `{FILE_JOURNAL_PREFIX}{prev_num}.md` (archived at ~{max_lines} lines) +> Started: {today} + +--- + +""" + new_file.write_text(content, encoding="utf-8") + return new_file + + +def generate_session_content( + session_num: int, + title: str, + commit: str, + summary: str, + extra_content: str, + today: str +) -> str: + """Generate session content.""" + if commit and commit != "-": + commit_table = """| Hash | Message | +|------|---------|""" + for c in commit.split(","): + c = c.strip() + commit_table += f"\n| `{c}` | (see git log) |" + else: + commit_table = "(No commits - planning session)" + + return f""" + +## Session {session_num}: {title} + +**Date**: {today} +**Task**: {title} + +### Summary + +{summary} + +### Main Changes + +{extra_content} + +### Git Commits + +{commit_table} + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete +""" + + +def update_index( + index_file: Path, + dev_dir: Path, + title: str, + commit: str, + new_session: int, + active_file: str, + today: str +) -> bool: + """Update index.md with new session info.""" + # Format commit for display + commit_display = "-" + if commit and commit != "-": + commit_display = re.sub(r"([a-f0-9]{7,})", r"`\1`", commit.replace(",", ", ")) + + # Get file number from active_file name + match = re.search(r"(\d+)", active_file) + active_num = int(match.group(1)) if match else 0 + files_table = count_journal_files(dev_dir, active_num) + + print(f"Updating index.md for session {new_session}...") + print(f" Title: {title}") + print(f" Commit: {commit_display}") + print(f" Active File: {active_file}") + print() + + content = index_file.read_text(encoding="utf-8") + + if "@@@auto:current-status" not in content: + print("Error: Markers not found in index.md. Please ensure markers exist.", file=sys.stderr) + return False + + # Process sections + lines = content.splitlines() + new_lines = [] + + in_current_status = False + in_active_documents = False + in_session_history = False + header_written = False + + for line in lines: + if "@@@auto:current-status" in line: + new_lines.append(line) + in_current_status = True + new_lines.append(f"- **Active File**: `{active_file}`") + new_lines.append(f"- **Total Sessions**: {new_session}") + new_lines.append(f"- **Last Active**: {today}") + continue + + if "@@@/auto:current-status" in line: + in_current_status = False + new_lines.append(line) + continue + + if "@@@auto:active-documents" in line: + new_lines.append(line) + in_active_documents = True + new_lines.append("| File | Lines | Status |") + new_lines.append("|------|-------|--------|") + new_lines.append(files_table) + continue + + if "@@@/auto:active-documents" in line: + in_active_documents = False + new_lines.append(line) + continue + + if "@@@auto:session-history" in line: + new_lines.append(line) + in_session_history = True + header_written = False + continue + + if "@@@/auto:session-history" in line: + in_session_history = False + new_lines.append(line) + continue + + if in_current_status: + continue + + if in_active_documents: + continue + + if in_session_history: + new_lines.append(line) + if re.match(r"^\|\s*-", line) and not header_written: + new_lines.append(f"| {new_session} | {today} | {title} | {commit_display} |") + header_written = True + continue + + new_lines.append(line) + + index_file.write_text("\n".join(new_lines), encoding="utf-8") + print("[OK] Updated index.md successfully!") + return True + + +# ============================================================================= +# Main Function +# ============================================================================= + +def _auto_commit_workspace(repo_root: Path) -> None: + """Stage .trellis/workspace and .trellis/tasks, then commit with a configured message.""" + commit_msg = get_session_commit_message(repo_root) + subprocess.run( + ["git", "add", "-A", ".trellis/workspace", ".trellis/tasks"], + cwd=repo_root, + capture_output=True, + ) + # Check if there are staged changes + result = subprocess.run( + ["git", "diff", "--cached", "--quiet", "--", ".trellis/workspace", ".trellis/tasks"], + cwd=repo_root, + ) + if result.returncode == 0: + print("[OK] No workspace changes to commit.", file=sys.stderr) + return + commit_result = subprocess.run( + ["git", "commit", "-m", commit_msg], + cwd=repo_root, + capture_output=True, + text=True, + ) + if commit_result.returncode == 0: + print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr) + else: + print(f"[WARN] Auto-commit failed: {commit_result.stderr.strip()}", file=sys.stderr) + + +def add_session( + title: str, + commit: str = "-", + summary: str = "(Add summary)", + extra_content: str = "(Add details)", + auto_commit: bool = True, +) -> int: + """Add a new session.""" + repo_root = get_repo_root() + ensure_developer(repo_root) + + developer = get_developer(repo_root) + if not developer: + print("Error: Developer not initialized", file=sys.stderr) + return 1 + + dev_dir = get_workspace_dir(repo_root) + if not dev_dir: + print("Error: Workspace directory not found", file=sys.stderr) + return 1 + + max_lines = get_max_journal_lines(repo_root) + + index_file = dev_dir / "index.md" + today = datetime.now().strftime("%Y-%m-%d") + + journal_file, current_num, current_lines = get_latest_journal_info(dev_dir) + current_session = get_current_session(index_file) + new_session = current_session + 1 + + session_content = generate_session_content( + new_session, title, commit, summary, extra_content, today + ) + content_lines = len(session_content.splitlines()) + + print("========================================", file=sys.stderr) + print("ADD SESSION", file=sys.stderr) + print("========================================", file=sys.stderr) + print("", file=sys.stderr) + print(f"Session: {new_session}", file=sys.stderr) + print(f"Title: {title}", file=sys.stderr) + print(f"Commit: {commit}", file=sys.stderr) + print("", file=sys.stderr) + print(f"Current journal file: {FILE_JOURNAL_PREFIX}{current_num}.md", file=sys.stderr) + print(f"Current lines: {current_lines}", file=sys.stderr) + print(f"New content lines: {content_lines}", file=sys.stderr) + print(f"Total after append: {current_lines + content_lines}", file=sys.stderr) + print("", file=sys.stderr) + + target_file = journal_file + target_num = current_num + + if current_lines + content_lines > max_lines: + target_num = current_num + 1 + print(f"[!] Exceeds {max_lines} lines, creating {FILE_JOURNAL_PREFIX}{target_num}.md", file=sys.stderr) + target_file = create_new_journal_file(dev_dir, target_num, developer, today, max_lines) + print(f"Created: {target_file}", file=sys.stderr) + + # Append session content + if target_file: + with target_file.open("a", encoding="utf-8") as f: + f.write(session_content) + print(f"[OK] Appended session to {target_file.name}", file=sys.stderr) + + print("", file=sys.stderr) + + # Update index.md + active_file = f"{FILE_JOURNAL_PREFIX}{target_num}.md" + if not update_index(index_file, dev_dir, title, commit, new_session, active_file, today): + return 1 + + print("", file=sys.stderr) + print("========================================", file=sys.stderr) + print(f"[OK] Session {new_session} added successfully!", file=sys.stderr) + print("========================================", file=sys.stderr) + print("", file=sys.stderr) + print("Files updated:", file=sys.stderr) + print(f" - {target_file.name if target_file else 'journal'}", file=sys.stderr) + print(" - index.md", file=sys.stderr) + + # Auto-commit workspace changes + if auto_commit: + print("", file=sys.stderr) + _auto_commit_workspace(repo_root) + + return 0 + + +# ============================================================================= +# Main Entry +# ============================================================================= + +def main() -> int: + """CLI entry point.""" + parser = argparse.ArgumentParser( + description="Add a new session to journal file and update index.md" + ) + parser.add_argument("--title", required=True, help="Session title") + parser.add_argument("--commit", default="-", help="Comma-separated commit hashes") + parser.add_argument("--summary", default="(Add summary)", help="Brief summary") + parser.add_argument("--content-file", help="Path to file with detailed content") + parser.add_argument("--no-commit", action="store_true", + help="Skip auto-commit of workspace changes") + + args = parser.parse_args() + + extra_content = "(Add details)" + if args.content_file: + content_path = Path(args.content_file) + if content_path.is_file(): + extra_content = content_path.read_text(encoding="utf-8") + elif not sys.stdin.isatty(): + extra_content = sys.stdin.read() + + return add_session( + args.title, args.commit, args.summary, extra_content, + auto_commit=not args.no_commit, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.trellis/scripts/common/__init__.py b/.trellis/scripts/common/__init__.py new file mode 100755 index 00000000..17729781 --- /dev/null +++ b/.trellis/scripts/common/__init__.py @@ -0,0 +1,82 @@ +""" +Common utilities for Trellis workflow scripts. + +This module provides shared functionality used by other Trellis scripts. +""" + +import io +import sys + +# ============================================================================= +# Windows Encoding Fix (MUST be at top, before any other output) +# ============================================================================= +# On Windows, stdout defaults to the system code page (often GBK/CP936). +# This causes UnicodeEncodeError when printing non-ASCII characters. +# +# Any script that imports from common will automatically get this fix. +# ============================================================================= + + +def _configure_stream(stream: object) -> object: + """Configure a stream for UTF-8 encoding on Windows.""" + # Try reconfigure() first (Python 3.7+, more reliable) + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + return stream + # Fallback: detach and rewrap with TextIOWrapper + elif hasattr(stream, "detach"): + return io.TextIOWrapper( + stream.detach(), # type: ignore[union-attr] + encoding="utf-8", + errors="replace", + ) + return stream + + +if sys.platform == "win32": + sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment] + sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment] + sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment] + + +def configure_encoding() -> None: + """ + Configure stdout/stderr/stdin for UTF-8 encoding on Windows. + + This is automatically called when importing from common, + but can be called manually for scripts that don't import common. + + Safe to call multiple times. + """ + global sys + if sys.platform == "win32": + sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment] + sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment] + sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment] + + +from .paths import ( + DIR_WORKFLOW, + DIR_WORKSPACE, + DIR_TASKS, + DIR_ARCHIVE, + DIR_SPEC, + DIR_SCRIPTS, + FILE_DEVELOPER, + FILE_CURRENT_TASK, + FILE_TASK_JSON, + FILE_JOURNAL_PREFIX, + get_repo_root, + get_developer, + check_developer, + get_tasks_dir, + get_workspace_dir, + get_active_journal_file, + count_lines, + get_current_task, + get_current_task_abs, + set_current_task, + clear_current_task, + has_current_task, + generate_task_date_prefix, +) diff --git a/.trellis/scripts/common/cli_adapter.py b/.trellis/scripts/common/cli_adapter.py new file mode 100755 index 00000000..ce3323b4 --- /dev/null +++ b/.trellis/scripts/common/cli_adapter.py @@ -0,0 +1,628 @@ +""" +CLI Adapter for Multi-Platform Support. + +Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, and Qoder interfaces. + +Supported platforms: +- claude: Claude Code (default) +- opencode: OpenCode +- cursor: Cursor IDE +- iflow: iFlow CLI +- codex: Codex CLI (skills-based) +- kilo: Kilo CLI +- kiro: Kiro Code (skills-based) +- gemini: Gemini CLI +- antigravity: Antigravity (workflow-based) +- qoder: Qoder + +Usage: + from common.cli_adapter import CLIAdapter + + adapter = CLIAdapter("opencode") + cmd = adapter.build_run_command( + agent="dispatch", + session_id="abc123", + prompt="Start the pipeline" + ) +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import ClassVar, Literal + +Platform = Literal[ + "claude", + "opencode", + "cursor", + "iflow", + "codex", + "kilo", + "kiro", + "gemini", + "antigravity", + "qoder", +] + + +@dataclass +class CLIAdapter: + """Adapter for different AI coding CLI tools.""" + + platform: Platform + + # ========================================================================= + # Agent Name Mapping + # ========================================================================= + + # OpenCode has built-in agents that cannot be overridden + # See: https://github.com/sst/opencode/issues/4271 + # Note: Class-level constant, not a dataclass field + _AGENT_NAME_MAP: ClassVar[dict[Platform, dict[str, str]]] = { + "claude": {}, # No mapping needed + "opencode": { + "plan": "trellis-plan", # 'plan' is built-in in OpenCode + }, + } + + def get_agent_name(self, agent: str) -> str: + """Get platform-specific agent name. + + Args: + agent: Original agent name (e.g., 'plan', 'dispatch') + + Returns: + Platform-specific agent name (e.g., 'trellis-plan' for OpenCode) + """ + mapping = self._AGENT_NAME_MAP.get(self.platform, {}) + return mapping.get(agent, agent) + + # ========================================================================= + # Agent Path + # ========================================================================= + + @property + def config_dir_name(self) -> str: + """Get platform-specific config directory name. + + Returns: + Directory name ('.claude', '.opencode', '.cursor', '.iflow', '.agents', '.kilocode', '.kiro', '.gemini', '.agent', or '.qoder') + """ + if self.platform == "opencode": + return ".opencode" + elif self.platform == "cursor": + return ".cursor" + elif self.platform == "iflow": + return ".iflow" + elif self.platform == "codex": + return ".agents" + elif self.platform == "kilo": + return ".kilocode" + elif self.platform == "kiro": + return ".kiro" + elif self.platform == "gemini": + return ".gemini" + elif self.platform == "antigravity": + return ".agent" + elif self.platform == "qoder": + return ".qoder" + else: + return ".claude" + + def get_config_dir(self, project_root: Path) -> Path: + """Get platform-specific config directory. + + Args: + project_root: Project root directory + + Returns: + Path to config directory (.claude, .opencode, .cursor, .iflow, .agents, .kilocode, .kiro, .gemini, .agent, or .qoder) + """ + return project_root / self.config_dir_name + + def get_agent_path(self, agent: str, project_root: Path) -> Path: + """Get path to agent definition file. + + Args: + agent: Agent name (original, before mapping) + project_root: Project root directory + + Returns: + Path to agent .md file + """ + mapped_name = self.get_agent_name(agent) + return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.md" + + def get_commands_path(self, project_root: Path, *parts: str) -> Path: + """Get path to commands directory or specific command file. + + Args: + project_root: Project root directory + *parts: Additional path parts (e.g., 'trellis', 'finish-work.md') + + Returns: + Path to commands directory or file + + Note: + Cursor uses prefix naming: .cursor/commands/trellis-<name>.md + Antigravity uses workflow directory: .agent/workflows/<name>.md + Claude/OpenCode use subdirectory: .claude/commands/trellis/<name>.md + """ + if self.platform in ("antigravity", "kilo"): + workflow_dir = self.get_config_dir(project_root) / "workflows" + if not parts: + return workflow_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + return workflow_dir / filename + return workflow_dir / Path(*parts) + + if not parts: + return self.get_config_dir(project_root) / "commands" + + # Cursor uses prefix naming instead of subdirectory + if self.platform == "cursor" and len(parts) >= 2 and parts[0] == "trellis": + # Convert trellis/<name>.md to trellis-<name>.md + filename = parts[-1] + return ( + self.get_config_dir(project_root) / "commands" / f"trellis-{filename}" + ) + + return self.get_config_dir(project_root) / "commands" / Path(*parts) + + def get_trellis_command_path(self, name: str) -> str: + """Get relative path to a trellis command file. + + Args: + name: Command name without extension (e.g., 'finish-work', 'check-backend') + + Returns: + Relative path string for use in JSONL entries + + Note: + Cursor: .cursor/commands/trellis-<name>.md + Codex: .agents/skills/<name>/SKILL.md + Kiro: .kiro/skills/<name>/SKILL.md + Gemini: .gemini/commands/trellis/<name>.toml + Antigravity: .agent/workflows/<name>.md + Others: .{platform}/commands/trellis/<name>.md + """ + if self.platform == "cursor": + return f".cursor/commands/trellis-{name}.md" + elif self.platform == "codex": + return f".agents/skills/{name}/SKILL.md" + elif self.platform == "kiro": + return f".kiro/skills/{name}/SKILL.md" + elif self.platform == "gemini": + return f".gemini/commands/trellis/{name}.toml" + elif self.platform == "antigravity": + return f".agent/workflows/{name}.md" + elif self.platform == "kilo": + return f".kilocode/workflows/{name}.md" + else: + return f"{self.config_dir_name}/commands/trellis/{name}.md" + + # ========================================================================= + # Environment Variables + # ========================================================================= + + def get_non_interactive_env(self) -> dict[str, str]: + """Get environment variables for non-interactive mode. + + Returns: + Dict of environment variables to set + """ + if self.platform == "opencode": + return {"OPENCODE_NON_INTERACTIVE": "1"} + elif self.platform == "iflow": + return {"IFLOW_NON_INTERACTIVE": "1"} + elif self.platform == "codex": + return {"CODEX_NON_INTERACTIVE": "1"} + elif self.platform == "kiro": + return {"KIRO_NON_INTERACTIVE": "1"} + elif self.platform == "gemini": + return {} # Gemini CLI doesn't have a non-interactive env var + elif self.platform == "antigravity": + return {} + elif self.platform == "qoder": + return {} + else: + return {"CLAUDE_NON_INTERACTIVE": "1"} + + # ========================================================================= + # CLI Command Building + # ========================================================================= + + def build_run_command( + self, + agent: str, + prompt: str, + session_id: str | None = None, + skip_permissions: bool = True, + verbose: bool = True, + json_output: bool = True, + ) -> list[str]: + """Build CLI command for running an agent. + + Args: + agent: Agent name (will be mapped if needed) + prompt: Prompt to send to the agent + session_id: Optional session ID (Claude Code only for creation) + skip_permissions: Whether to skip permission prompts + verbose: Whether to enable verbose output + json_output: Whether to use JSON output format + + Returns: + List of command arguments + """ + mapped_agent = self.get_agent_name(agent) + + if self.platform == "opencode": + cmd = ["opencode", "run"] + cmd.extend(["--agent", mapped_agent]) + + # Note: OpenCode 'run' mode is non-interactive by default + # No equivalent to Claude Code's --dangerously-skip-permissions + # See: https://github.com/anomalyco/opencode/issues/9070 + + if json_output: + cmd.extend(["--format", "json"]) + + if verbose: + cmd.extend(["--log-level", "DEBUG", "--print-logs"]) + + # Note: OpenCode doesn't support --session-id on creation + # Session ID must be extracted from logs after startup + + cmd.append(prompt) + + elif self.platform == "iflow": + cmd = ["iflow", "-p"] + cmd.extend(["-y", "--agent", mapped_agent]) + # iFlow doesn't support --session-id on creation + if verbose: + cmd.append("--verbose") + cmd.append(prompt) + elif self.platform == "codex": + cmd = ["codex", "exec"] + cmd.append(prompt) + elif self.platform == "kiro": + cmd = ["kiro", "run", prompt] + elif self.platform == "gemini": + cmd = ["gemini"] + cmd.append(prompt) + elif self.platform == "antigravity": + raise ValueError( + "Antigravity workflows are UI slash commands; CLI agent run is not supported." + ) + elif self.platform == "qoder": + cmd = ["qodercli", "-p", prompt] + + else: # claude + cmd = ["claude", "-p"] + cmd.extend(["--agent", mapped_agent]) + + if session_id: + cmd.extend(["--session-id", session_id]) + + if skip_permissions: + cmd.append("--dangerously-skip-permissions") + + if json_output: + cmd.extend(["--output-format", "stream-json"]) + + if verbose: + cmd.append("--verbose") + + cmd.append(prompt) + + return cmd + + def build_resume_command(self, session_id: str) -> list[str]: + """Build CLI command for resuming a session. + + Args: + session_id: Session ID to resume (ignored for iFlow) + + Returns: + List of command arguments + """ + if self.platform == "opencode": + return ["opencode", "run", "--session", session_id] + elif self.platform == "iflow": + # iFlow uses -c to continue most recent conversation + # session_id is ignored as iFlow doesn't support session IDs + return ["iflow", "-c"] + elif self.platform == "codex": + return ["codex", "resume", session_id] + elif self.platform == "kiro": + return ["kiro", "resume", session_id] + elif self.platform == "gemini": + return ["gemini", "--resume", session_id] + elif self.platform == "antigravity": + raise ValueError( + "Antigravity workflows are UI slash commands; CLI resume is not supported." + ) + elif self.platform == "qoder": + return ["qodercli", "--resume", session_id] + else: + return ["claude", "--resume", session_id] + + def get_resume_command_str(self, session_id: str, cwd: str | None = None) -> str: + """Get human-readable resume command string. + + Args: + session_id: Session ID to resume + cwd: Optional working directory to cd into + + Returns: + Command string for display + """ + cmd = self.build_resume_command(session_id) + cmd_str = " ".join(cmd) + + if cwd: + return f"cd {cwd} && {cmd_str}" + return cmd_str + + # ========================================================================= + # Platform Detection Helpers + # ========================================================================= + + @property + def is_opencode(self) -> bool: + """Check if platform is OpenCode.""" + return self.platform == "opencode" + + @property + def is_claude(self) -> bool: + """Check if platform is Claude Code.""" + return self.platform == "claude" + + @property + def is_cursor(self) -> bool: + """Check if platform is Cursor.""" + return self.platform == "cursor" + + @property + def is_iflow(self) -> bool: + """Check if platform is iFlow CLI.""" + return self.platform == "iflow" + + @property + def cli_name(self) -> str: + """Get CLI executable name. + + Note: Cursor doesn't have a CLI tool, returns None-like value. + """ + if self.is_opencode: + return "opencode" + elif self.is_cursor: + return "cursor" # Note: Cursor is IDE-only, no CLI + elif self.platform == "iflow": + return "iflow" + elif self.platform == "kiro": + return "kiro" + elif self.platform == "gemini": + return "gemini" + elif self.platform == "antigravity": + return "agy" + elif self.platform == "qoder": + return "qodercli" + else: + return "claude" + + @property + def supports_cli_agents(self) -> bool: + """Check if platform supports running agents via CLI. + + Claude Code, OpenCode, and iFlow support CLI agent execution. + Cursor is IDE-only and doesn't support CLI agents. + """ + return self.platform in ("claude", "opencode", "iflow") + + # ========================================================================= + # Session ID Handling + # ========================================================================= + + @property + def supports_session_id_on_create(self) -> bool: + """Check if platform supports specifying session ID on creation. + + Claude Code: Yes (--session-id) + OpenCode: No (auto-generated, extract from logs) + iFlow: No (no session ID support) + """ + return self.platform == "claude" + + def extract_session_id_from_log(self, log_content: str) -> str | None: + """Extract session ID from log output (OpenCode only). + + OpenCode generates session IDs in format: ses_xxx + + Args: + log_content: Log file content + + Returns: + Session ID if found, None otherwise + """ + import re + + # OpenCode session ID pattern + match = re.search(r"ses_[a-zA-Z0-9]+", log_content) + if match: + return match.group(0) + return None + + +# ============================================================================= +# Factory Function +# ============================================================================= + + +def get_cli_adapter(platform: str = "claude") -> CLIAdapter: + """Get CLI adapter for the specified platform. + + Args: + platform: Platform name ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', or 'qoder') + + Returns: + CLIAdapter instance + + Raises: + ValueError: If platform is not supported + """ + if platform not in ( + "claude", + "opencode", + "cursor", + "iflow", + "codex", + "kilo", + "kiro", + "gemini", + "antigravity", + "qoder", + ): + raise ValueError( + f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', or 'qoder')" + ) + + return CLIAdapter(platform=platform) # type: ignore + + +def detect_platform(project_root: Path) -> Platform: + """Auto-detect platform based on existing config directories. + + Detection order: + 1. TRELLIS_PLATFORM environment variable (if set) + 2. .opencode directory exists → opencode + 3. .iflow directory exists → iflow + 4. .cursor directory exists (without .claude) → cursor + 5. .agents/skills exists and no other platform dirs → codex + 6. .kilocode directory exists → kilo + 7. .kiro/skills exists and no other platform dirs → kiro + 8. .gemini directory exists → gemini + 9. .agent/workflows exists and no other platform dirs → antigravity + 10. .qoder directory exists → qoder + 11. Default → claude + + Args: + project_root: Project root directory + + Returns: + Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', or 'qoder') + """ + import os + + # Check environment variable first + env_platform = os.environ.get("TRELLIS_PLATFORM", "").lower() + if env_platform in ( + "claude", + "opencode", + "cursor", + "iflow", + "codex", + "kilo", + "kiro", + "gemini", + "antigravity", + "qoder", + ): + return env_platform # type: ignore + + # Check for .opencode directory (OpenCode-specific) + # Note: .claude might exist in both platforms during migration + if (project_root / ".opencode").is_dir(): + return "opencode" + + # Check for .iflow directory (iFlow-specific) + # Note: .claude might exist in both platforms during migration + if (project_root / ".iflow").is_dir(): + return "iflow" + + # Check for .cursor directory (Cursor-specific) + # Only detect as cursor if .claude doesn't exist (to avoid confusion) + if (project_root / ".cursor").is_dir() and not (project_root / ".claude").is_dir(): + return "cursor" + + # Check for .gemini directory (Gemini CLI-specific) + if (project_root / ".gemini").is_dir(): + return "gemini" + + # Check for Codex skills directory only when no other platform config exists + other_platform_dirs_codex = ( + ".claude", + ".cursor", + ".iflow", + ".opencode", + ".kilocode", + ".kiro", + ".gemini", + ".agent", + ) + has_other_platform_config = any( + (project_root / directory).is_dir() for directory in other_platform_dirs_codex + ) + if (project_root / ".agents" / "skills").is_dir() and not has_other_platform_config: + return "codex" + + # Check for .kilocode directory (Kilo-specific) + if (project_root / ".kilocode").is_dir(): + return "kilo" + + # Check for Kiro skills directory only when no other platform config exists + other_platform_dirs_kiro = ( + ".claude", + ".cursor", + ".iflow", + ".opencode", + ".agents", + ".kilocode", + ".gemini", + ".agent", + ) + has_other_platform_config = any( + (project_root / directory).is_dir() for directory in other_platform_dirs_kiro + ) + if (project_root / ".kiro" / "skills").is_dir() and not has_other_platform_config: + return "kiro" + + # Check for Antigravity workflow directory only when no other platform config exists + other_platform_dirs_antigravity = ( + ".claude", + ".cursor", + ".iflow", + ".opencode", + ".agents", + ".kilocode", + ".kiro", + ) + has_other_platform_config = any( + (project_root / directory).is_dir() + for directory in other_platform_dirs_antigravity + ) + if ( + project_root / ".agent" / "workflows" + ).is_dir() and not has_other_platform_config: + return "antigravity" + + # Check for .qoder directory (Qoder-specific) + if (project_root / ".qoder").is_dir(): + return "qoder" + + return "claude" + + +def get_cli_adapter_auto(project_root: Path) -> CLIAdapter: + """Get CLI adapter with auto-detected platform. + + Args: + project_root: Project root directory + + Returns: + CLIAdapter instance for detected platform + """ + platform = detect_platform(project_root) + return CLIAdapter(platform=platform) diff --git a/.trellis/scripts/common/config.py b/.trellis/scripts/common/config.py new file mode 100755 index 00000000..601ab320 --- /dev/null +++ b/.trellis/scripts/common/config.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +""" +Trellis configuration reader. + +Reads settings from .trellis/config.yaml with sensible defaults. +""" + +from __future__ import annotations + +from pathlib import Path + +from .paths import DIR_WORKFLOW, get_repo_root +from .worktree import parse_simple_yaml + + +# Defaults +DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal" +DEFAULT_MAX_JOURNAL_LINES = 2000 + +CONFIG_FILE = "config.yaml" + + +def _get_config_path(repo_root: Path | None = None) -> Path: + """Get path to config.yaml.""" + root = repo_root or get_repo_root() + return root / DIR_WORKFLOW / CONFIG_FILE + + +def _load_config(repo_root: Path | None = None) -> dict: + """Load and parse config.yaml. Returns empty dict on any error.""" + config_file = _get_config_path(repo_root) + try: + content = config_file.read_text(encoding="utf-8") + return parse_simple_yaml(content) + except (OSError, IOError): + return {} + + +def get_session_commit_message(repo_root: Path | None = None) -> str: + """Get the commit message for auto-committing session records.""" + config = _load_config(repo_root) + return config.get("session_commit_message", DEFAULT_SESSION_COMMIT_MESSAGE) + + +def get_max_journal_lines(repo_root: Path | None = None) -> int: + """Get the maximum lines per journal file.""" + config = _load_config(repo_root) + value = config.get("max_journal_lines", DEFAULT_MAX_JOURNAL_LINES) + try: + return int(value) + except (ValueError, TypeError): + return DEFAULT_MAX_JOURNAL_LINES + + +def get_hooks(event: str, repo_root: Path | None = None) -> list[str]: + """Get hook commands for a lifecycle event. + + Args: + event: Event name (e.g. "after_create", "after_archive"). + repo_root: Repository root path. + + Returns: + List of shell commands to execute, empty if none configured. + """ + config = _load_config(repo_root) + hooks = config.get("hooks") + if not isinstance(hooks, dict): + return [] + commands = hooks.get(event) + if isinstance(commands, list): + return [str(c) for c in commands] + return [] diff --git a/.trellis/scripts/common/developer.py b/.trellis/scripts/common/developer.py new file mode 100755 index 00000000..7f3cf0ce --- /dev/null +++ b/.trellis/scripts/common/developer.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +""" +Developer management utilities. + +Provides: + init_developer - Initialize developer + ensure_developer - Ensure developer is initialized (exit if not) + show_developer_info - Show developer information +""" + +from __future__ import annotations + +import sys +from datetime import datetime +from pathlib import Path + +from .paths import ( + DIR_WORKFLOW, + DIR_WORKSPACE, + DIR_TASKS, + FILE_DEVELOPER, + FILE_JOURNAL_PREFIX, + get_repo_root, + get_developer, + check_developer, +) + + +# ============================================================================= +# Developer Initialization +# ============================================================================= + +def init_developer(name: str, repo_root: Path | None = None) -> bool: + """Initialize developer. + + Creates: + - .trellis/.developer file with developer info + - .trellis/workspace/<name>/ directory structure + - Initial journal file and index.md + + Args: + name: Developer name. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True on success, False on error. + """ + if not name: + print("Error: developer name is required", file=sys.stderr) + return False + + if repo_root is None: + repo_root = get_repo_root() + + dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER + workspace_dir = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / name + + # Create .developer file + initialized_at = datetime.now().isoformat() + try: + dev_file.write_text( + f"name={name}\ninitialized_at={initialized_at}\n", + encoding="utf-8" + ) + except (OSError, IOError) as e: + print(f"Error: Failed to create .developer file: {e}", file=sys.stderr) + return False + + # Create workspace directory structure + try: + workspace_dir.mkdir(parents=True, exist_ok=True) + except (OSError, IOError) as e: + print(f"Error: Failed to create workspace directory: {e}", file=sys.stderr) + return False + + # Create initial journal file + journal_file = workspace_dir / f"{FILE_JOURNAL_PREFIX}1.md" + if not journal_file.exists(): + today = datetime.now().strftime("%Y-%m-%d") + journal_content = f"""# Journal - {name} (Part 1) + +> AI development session journal +> Started: {today} + +--- + +""" + try: + journal_file.write_text(journal_content, encoding="utf-8") + except (OSError, IOError) as e: + print(f"Error: Failed to create journal file: {e}", file=sys.stderr) + return False + + # Create index.md with markers for auto-update + index_file = workspace_dir / "index.md" + if not index_file.exists(): + index_content = f"""# Workspace Index - {name} + +> Journal tracking for AI development sessions. + +--- + +## Current Status + +<!-- @@@auto:current-status --> +- **Active File**: `journal-1.md` +- **Total Sessions**: 0 +- **Last Active**: - +<!-- @@@/auto:current-status --> + +--- + +## Active Documents + +<!-- @@@auto:active-documents --> +| File | Lines | Status | +|------|-------|--------| +| `journal-1.md` | ~0 | Active | +<!-- @@@/auto:active-documents --> + +--- + +## Session History + +<!-- @@@auto:session-history --> +| # | Date | Title | Commits | +|---|------|-------|---------| +<!-- @@@/auto:session-history --> + +--- + +## Notes + +- Sessions are appended to journal files +- New journal file created when current exceeds 2000 lines +- Use `add_session.py` to record sessions +""" + try: + index_file.write_text(index_content, encoding="utf-8") + except (OSError, IOError) as e: + print(f"Error: Failed to create index.md: {e}", file=sys.stderr) + return False + + print(f"Developer initialized: {name}") + print(f" .developer file: {dev_file}") + print(f" Workspace dir: {workspace_dir}") + + return True + + +def ensure_developer(repo_root: Path | None = None) -> None: + """Ensure developer is initialized, exit if not. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + if repo_root is None: + repo_root = get_repo_root() + + if not check_developer(repo_root): + print("Error: Developer not initialized.", file=sys.stderr) + print(f"Run: python3 ./{DIR_WORKFLOW}/scripts/init_developer.py <your-name>", file=sys.stderr) + sys.exit(1) + + +def show_developer_info(repo_root: Path | None = None) -> None: + """Show developer information. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + + if not developer: + print("Developer: (not initialized)") + else: + print(f"Developer: {developer}") + print(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/") + print(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/") + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + show_developer_info() diff --git a/.trellis/scripts/common/git_context.py b/.trellis/scripts/common/git_context.py new file mode 100755 index 00000000..39b9ff50 --- /dev/null +++ b/.trellis/scripts/common/git_context.py @@ -0,0 +1,641 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Git and Session Context utilities. + +Provides: + output_json - Output context in JSON format + output_text - Output context in text format +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +from .paths import ( + DIR_SCRIPTS, + DIR_SPEC, + DIR_TASKS, + DIR_WORKFLOW, + DIR_WORKSPACE, + FILE_TASK_JSON, + count_lines, + get_active_journal_file, + get_current_task, + get_developer, + get_repo_root, + get_tasks_dir, +) + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def _run_git_command(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]: + """Run a git command and return (returncode, stdout, stderr). + + Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure + consistent output across all platforms (Windows, macOS, Linux). + """ + try: + # Force git to output UTF-8 for consistent cross-platform behavior + git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args + result = subprocess.run( + git_args, + cwd=cwd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + return result.returncode, result.stdout, result.stderr + except Exception as e: + return 1, "", str(e) + + +def _read_json_file(path: Path) -> dict | None: + """Read and parse a JSON file.""" + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +# ============================================================================= +# JSON Output +# ============================================================================= + + +def get_context_json(repo_root: Path | None = None) -> dict: + """Get context as a dictionary. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Context dictionary. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + tasks_dir = get_tasks_dir(repo_root) + journal_file = get_active_journal_file(repo_root) + + journal_lines = 0 + journal_relative = "" + if journal_file and developer: + journal_lines = count_lines(journal_file) + journal_relative = ( + f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}" + ) + + # Git info + _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) + branch = branch_out.strip() or "unknown" + + _, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root) + git_status_count = len([line for line in status_out.splitlines() if line.strip()]) + is_clean = git_status_count == 0 + + # Recent commits + _, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root) + commits = [] + for line in log_out.splitlines(): + if line.strip(): + parts = line.split(" ", 1) + if len(parts) >= 2: + commits.append({"hash": parts[0], "message": parts[1]}) + elif len(parts) == 1: + commits.append({"hash": parts[0], "message": ""}) + + # Tasks + tasks = [] + if tasks_dir.is_dir(): + for d in tasks_dir.iterdir(): + if d.is_dir() and d.name != "archive": + task_json_path = d / FILE_TASK_JSON + if task_json_path.is_file(): + data = _read_json_file(task_json_path) + if data: + tasks.append( + { + "dir": d.name, + "name": data.get("name") or data.get("id") or "unknown", + "status": data.get("status", "unknown"), + "children": data.get("children", []), + "parent": data.get("parent"), + } + ) + + return { + "developer": developer or "", + "git": { + "branch": branch, + "isClean": is_clean, + "uncommittedChanges": git_status_count, + "recentCommits": commits, + }, + "tasks": { + "active": tasks, + "directory": f"{DIR_WORKFLOW}/{DIR_TASKS}", + }, + "journal": { + "file": journal_relative, + "lines": journal_lines, + "nearLimit": journal_lines > 1800, + }, + } + + +def output_json(repo_root: Path | None = None) -> None: + """Output context in JSON format. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + context = get_context_json(repo_root) + print(json.dumps(context, indent=2, ensure_ascii=False)) + + +# ============================================================================= +# Text Output +# ============================================================================= + + +def get_context_text(repo_root: Path | None = None) -> str: + """Get context as formatted text. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Formatted text output. + """ + if repo_root is None: + repo_root = get_repo_root() + + lines = [] + lines.append("========================================") + lines.append("SESSION CONTEXT") + lines.append("========================================") + lines.append("") + + developer = get_developer(repo_root) + + # Developer section + lines.append("## DEVELOPER") + if not developer: + lines.append( + f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>" + ) + return "\n".join(lines) + + lines.append(f"Name: {developer}") + lines.append("") + + # Git status + lines.append("## GIT STATUS") + _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) + branch = branch_out.strip() or "unknown" + lines.append(f"Branch: {branch}") + + _, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root) + status_lines = [line for line in status_out.splitlines() if line.strip()] + status_count = len(status_lines) + + if status_count == 0: + lines.append("Working directory: Clean") + else: + lines.append(f"Working directory: {status_count} uncommitted change(s)") + lines.append("") + lines.append("Changes:") + _, short_out, _ = _run_git_command(["status", "--short"], cwd=repo_root) + for line in short_out.splitlines()[:10]: + lines.append(line) + lines.append("") + + # Recent commits + lines.append("## RECENT COMMITS") + _, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root) + if log_out.strip(): + for line in log_out.splitlines(): + lines.append(line) + else: + lines.append("(no commits)") + lines.append("") + + # Current task + lines.append("## CURRENT TASK") + current_task = get_current_task(repo_root) + if current_task: + current_task_dir = repo_root / current_task + task_json_path = current_task_dir / FILE_TASK_JSON + lines.append(f"Path: {current_task}") + + if task_json_path.is_file(): + data = _read_json_file(task_json_path) + if data: + t_name = data.get("name") or data.get("id") or "unknown" + t_status = data.get("status", "unknown") + t_created = data.get("createdAt", "unknown") + t_desc = data.get("description", "") + + lines.append(f"Name: {t_name}") + lines.append(f"Status: {t_status}") + lines.append(f"Created: {t_created}") + if t_desc: + lines.append(f"Description: {t_desc}") + + # Check for prd.md + prd_file = current_task_dir / "prd.md" + if prd_file.is_file(): + lines.append("") + lines.append("[!] This task has prd.md - read it for task details") + else: + lines.append("(none)") + lines.append("") + + # Active tasks + lines.append("## ACTIVE TASKS") + tasks_dir = get_tasks_dir(repo_root) + task_count = 0 + + # Collect all task data for hierarchy display + all_task_data: dict[str, dict] = {} + if tasks_dir.is_dir(): + for d in sorted(tasks_dir.iterdir()): + if d.is_dir() and d.name != "archive": + dir_name = d.name + t_json = d / FILE_TASK_JSON + status = "unknown" + assignee = "-" + children: list[str] = [] + parent: str | None = None + + if t_json.is_file(): + data = _read_json_file(t_json) + if data: + status = data.get("status", "unknown") + assignee = data.get("assignee", "-") + children = data.get("children", []) + parent = data.get("parent") + + all_task_data[dir_name] = { + "status": status, + "assignee": assignee, + "children": children, + "parent": parent, + } + + def _children_progress(children_list: list[str]) -> str: + if not children_list: + return "" + done = 0 + for c in children_list: + if c in all_task_data and all_task_data[c]["status"] in ("completed", "done"): + done += 1 + return f" [{done}/{len(children_list)} done]" + + def _print_task_tree(name: str, indent: int = 0) -> None: + nonlocal task_count + info = all_task_data[name] + progress = _children_progress(info["children"]) if info["children"] else "" + prefix = " " * indent + lines.append(f"{prefix}- {name}/ ({info['status']}){progress} @{info['assignee']}") + task_count += 1 + for child in info["children"]: + if child in all_task_data: + _print_task_tree(child, indent + 1) + + for dir_name in sorted(all_task_data.keys()): + if not all_task_data[dir_name]["parent"]: + _print_task_tree(dir_name) + + if task_count == 0: + lines.append("(no active tasks)") + lines.append(f"Total: {task_count} active task(s)") + lines.append("") + + # My tasks + lines.append("## MY TASKS (Assigned to me)") + my_task_count = 0 + + if tasks_dir.is_dir(): + for d in sorted(tasks_dir.iterdir()): + if d.is_dir() and d.name != "archive": + t_json = d / FILE_TASK_JSON + if t_json.is_file(): + data = _read_json_file(t_json) + if data: + assignee = data.get("assignee", "") + status = data.get("status", "planning") + + if assignee == developer and status != "done": + title = data.get("title") or data.get("name") or "unknown" + priority = data.get("priority", "P2") + children_list = data.get("children", []) + progress = _children_progress(children_list) if children_list else "" + lines.append(f"- [{priority}] {title} ({status}){progress}") + my_task_count += 1 + + if my_task_count == 0: + lines.append("(no tasks assigned to you)") + lines.append("") + + # Journal file + lines.append("## JOURNAL FILE") + journal_file = get_active_journal_file(repo_root) + if journal_file: + journal_lines = count_lines(journal_file) + relative = f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}" + lines.append(f"Active file: {relative}") + lines.append(f"Line count: {journal_lines} / 2000") + if journal_lines > 1800: + lines.append("[!] WARNING: Approaching 2000 line limit!") + else: + lines.append("No journal file found") + lines.append("") + + # Paths + lines.append("## PATHS") + lines.append(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/") + lines.append(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/") + lines.append(f"Spec: {DIR_WORKFLOW}/{DIR_SPEC}/") + lines.append("") + + lines.append("========================================") + + return "\n".join(lines) + + +def get_context_record_json(repo_root: Path | None = None) -> dict: + """Get record-mode context as a dictionary. + + Focused on: my active tasks, git status, current task. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + tasks_dir = get_tasks_dir(repo_root) + + # Git info + _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) + branch = branch_out.strip() or "unknown" + + _, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root) + git_status_count = len([line for line in status_out.splitlines() if line.strip()]) + + _, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root) + commits = [] + for line in log_out.splitlines(): + if line.strip(): + parts = line.split(" ", 1) + if len(parts) >= 2: + commits.append({"hash": parts[0], "message": parts[1]}) + + # My tasks + my_tasks = [] + all_task_statuses: dict[str, str] = {} + if tasks_dir.is_dir(): + for d in sorted(tasks_dir.iterdir()): + if d.is_dir() and d.name != "archive": + t_json = d / FILE_TASK_JSON + if t_json.is_file(): + data = _read_json_file(t_json) + if data: + all_task_statuses[d.name] = data.get("status", "unknown") + + if tasks_dir.is_dir(): + for d in sorted(tasks_dir.iterdir()): + if d.is_dir() and d.name != "archive": + t_json = d / FILE_TASK_JSON + if t_json.is_file(): + data = _read_json_file(t_json) + if data and data.get("assignee") == developer: + children_list = data.get("children", []) + done = sum(1 for c in children_list if all_task_statuses.get(c) in ("completed", "done")) + my_tasks.append({ + "dir": d.name, + "title": data.get("title") or data.get("name") or "unknown", + "status": data.get("status", "unknown"), + "priority": data.get("priority", "P2"), + "children": children_list, + "childrenDone": done, + "parent": data.get("parent"), + "meta": data.get("meta", {}), + }) + + # Current task + current_task_info = None + current_task = get_current_task(repo_root) + if current_task: + task_json_path = (repo_root / current_task) / FILE_TASK_JSON + if task_json_path.is_file(): + data = _read_json_file(task_json_path) + if data: + current_task_info = { + "path": current_task, + "name": data.get("name") or data.get("id") or "unknown", + "status": data.get("status", "unknown"), + } + + return { + "developer": developer or "", + "git": { + "branch": branch, + "isClean": git_status_count == 0, + "uncommittedChanges": git_status_count, + "recentCommits": commits, + }, + "myTasks": my_tasks, + "currentTask": current_task_info, + } + + +def get_context_text_record(repo_root: Path | None = None) -> str: + """Get context as formatted text for record-session mode. + + Focused output: MY ACTIVE TASKS first (with [!!!] emphasis), + then GIT STATUS, RECENT COMMITS, CURRENT TASK. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Formatted text output for record-session. + """ + if repo_root is None: + repo_root = get_repo_root() + + lines: list[str] = [] + lines.append("========================================") + lines.append("SESSION CONTEXT (RECORD MODE)") + lines.append("========================================") + lines.append("") + + developer = get_developer(repo_root) + if not developer: + lines.append( + f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>" + ) + return "\n".join(lines) + + # MY ACTIVE TASKS — first and prominent + lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})") + lines.append("[!] Review whether any should be archived before recording this session.") + lines.append("") + + tasks_dir = get_tasks_dir(repo_root) + my_task_count = 0 + + # Collect task data for children progress + all_task_statuses: dict[str, str] = {} + if tasks_dir.is_dir(): + for d in sorted(tasks_dir.iterdir()): + if d.is_dir() and d.name != "archive": + t_json = d / FILE_TASK_JSON + if t_json.is_file(): + data = _read_json_file(t_json) + if data: + all_task_statuses[d.name] = data.get("status", "unknown") + + def _record_children_progress(children_list: list[str]) -> str: + if not children_list: + return "" + done = 0 + for c in children_list: + if all_task_statuses.get(c) in ("completed", "done"): + done += 1 + return f" [{done}/{len(children_list)} done]" + + if tasks_dir.is_dir(): + for d in sorted(tasks_dir.iterdir()): + if d.is_dir() and d.name != "archive": + t_json = d / FILE_TASK_JSON + if t_json.is_file(): + data = _read_json_file(t_json) + if data: + assignee = data.get("assignee", "") + status = data.get("status", "planning") + + if assignee == developer: + title = data.get("title") or data.get("name") or "unknown" + priority = data.get("priority", "P2") + children_list = data.get("children", []) + progress = _record_children_progress(children_list) if children_list else "" + lines.append(f"- [{priority}] {title} ({status}){progress} — {d.name}") + my_task_count += 1 + + if my_task_count == 0: + lines.append("(no active tasks assigned to you)") + lines.append("") + + # GIT STATUS + lines.append("## GIT STATUS") + _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) + branch = branch_out.strip() or "unknown" + lines.append(f"Branch: {branch}") + + _, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root) + status_lines = [line for line in status_out.splitlines() if line.strip()] + status_count = len(status_lines) + + if status_count == 0: + lines.append("Working directory: Clean") + else: + lines.append(f"Working directory: {status_count} uncommitted change(s)") + lines.append("") + lines.append("Changes:") + _, short_out, _ = _run_git_command(["status", "--short"], cwd=repo_root) + for line in short_out.splitlines()[:10]: + lines.append(line) + lines.append("") + + # RECENT COMMITS + lines.append("## RECENT COMMITS") + _, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root) + if log_out.strip(): + for line in log_out.splitlines(): + lines.append(line) + else: + lines.append("(no commits)") + lines.append("") + + # CURRENT TASK + lines.append("## CURRENT TASK") + current_task = get_current_task(repo_root) + if current_task: + current_task_dir = repo_root / current_task + task_json_path = current_task_dir / FILE_TASK_JSON + lines.append(f"Path: {current_task}") + + if task_json_path.is_file(): + data = _read_json_file(task_json_path) + if data: + t_name = data.get("name") or data.get("id") or "unknown" + t_status = data.get("status", "unknown") + lines.append(f"Name: {t_name}") + lines.append(f"Status: {t_status}") + else: + lines.append("(none)") + lines.append("") + + lines.append("========================================") + + return "\n".join(lines) + + +def output_text(repo_root: Path | None = None) -> None: + """Output context in text format. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + print(get_context_text(repo_root)) + + +# ============================================================================= +# Main Entry +# ============================================================================= + + +def main() -> None: + """CLI entry point.""" + import argparse + + parser = argparse.ArgumentParser(description="Get Session Context for AI Agent") + parser.add_argument( + "--json", + "-j", + action="store_true", + help="Output in JSON format (works with any --mode)", + ) + parser.add_argument( + "--mode", + "-m", + choices=["default", "record"], + default="default", + help="Output mode: default (full context) or record (for record-session)", + ) + + args = parser.parse_args() + + if args.mode == "record": + if args.json: + print(json.dumps(get_context_record_json(), indent=2, ensure_ascii=False)) + else: + print(get_context_text_record()) + else: + if args.json: + output_json() + else: + output_text() + + +if __name__ == "__main__": + main() diff --git a/.trellis/scripts/common/paths.py b/.trellis/scripts/common/paths.py new file mode 100755 index 00000000..dcbb66b4 --- /dev/null +++ b/.trellis/scripts/common/paths.py @@ -0,0 +1,347 @@ +#!/usr/bin/env python3 +""" +Common path utilities for Trellis workflow. + +Provides: + get_repo_root - Get repository root directory + get_developer - Get developer name + get_workspace_dir - Get developer workspace directory + get_tasks_dir - Get tasks directory + get_active_journal_file - Get current journal file +""" + +from __future__ import annotations + +import re +from datetime import datetime +from pathlib import Path + + +# ============================================================================= +# Path Constants (change here to rename directories) +# ============================================================================= + +# Directory names +DIR_WORKFLOW = ".trellis" +DIR_WORKSPACE = "workspace" +DIR_TASKS = "tasks" +DIR_ARCHIVE = "archive" +DIR_SPEC = "spec" +DIR_SCRIPTS = "scripts" + +# File names +FILE_DEVELOPER = ".developer" +FILE_CURRENT_TASK = ".current-task" +FILE_TASK_JSON = "task.json" +FILE_JOURNAL_PREFIX = "journal-" + + +# ============================================================================= +# Repository Root +# ============================================================================= + +def get_repo_root(start_path: Path | None = None) -> Path: + """Find the nearest directory containing .trellis/ folder. + + This handles nested git repos correctly (e.g., test project inside another repo). + + Args: + start_path: Starting directory to search from. Defaults to current directory. + + Returns: + Path to repository root, or current directory if no .trellis/ found. + """ + current = (start_path or Path.cwd()).resolve() + + while current != current.parent: + if (current / DIR_WORKFLOW).is_dir(): + return current + current = current.parent + + # Fallback to current directory if no .trellis/ found + return Path.cwd().resolve() + + +# ============================================================================= +# Developer +# ============================================================================= + +def get_developer(repo_root: Path | None = None) -> str | None: + """Get developer name from .developer file. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Developer name or None if not initialized. + """ + if repo_root is None: + repo_root = get_repo_root() + + dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER + + if not dev_file.is_file(): + return None + + try: + content = dev_file.read_text(encoding="utf-8") + for line in content.splitlines(): + if line.startswith("name="): + return line.split("=", 1)[1].strip() + except (OSError, IOError): + pass + + return None + + +def check_developer(repo_root: Path | None = None) -> bool: + """Check if developer is initialized. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True if developer is initialized. + """ + return get_developer(repo_root) is not None + + +# ============================================================================= +# Tasks Directory +# ============================================================================= + +def get_tasks_dir(repo_root: Path | None = None) -> Path: + """Get tasks directory path. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to tasks directory. + """ + if repo_root is None: + repo_root = get_repo_root() + return repo_root / DIR_WORKFLOW / DIR_TASKS + + +# ============================================================================= +# Workspace Directory +# ============================================================================= + +def get_workspace_dir(repo_root: Path | None = None) -> Path | None: + """Get developer workspace directory. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to workspace directory or None if developer not set. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + if developer: + return repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer + return None + + +# ============================================================================= +# Journal File +# ============================================================================= + +def get_active_journal_file(repo_root: Path | None = None) -> Path | None: + """Get the current active journal file. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to active journal file or None if not found. + """ + if repo_root is None: + repo_root = get_repo_root() + + workspace_dir = get_workspace_dir(repo_root) + if workspace_dir is None or not workspace_dir.is_dir(): + return None + + latest: Path | None = None + highest = 0 + + for f in workspace_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"): + if not f.is_file(): + continue + + # Extract number from filename + name = f.stem # e.g., "journal-1" + match = re.search(r"(\d+)$", name) + if match: + num = int(match.group(1)) + if num > highest: + highest = num + latest = f + + return latest + + +def count_lines(file_path: Path) -> int: + """Count lines in a file. + + Args: + file_path: Path to file. + + Returns: + Number of lines, or 0 if file doesn't exist. + """ + if not file_path.is_file(): + return 0 + + try: + return len(file_path.read_text(encoding="utf-8").splitlines()) + except (OSError, IOError): + return 0 + + +# ============================================================================= +# Current Task Management +# ============================================================================= + +def _get_current_task_file(repo_root: Path | None = None) -> Path: + """Get .current-task file path. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to .current-task file. + """ + if repo_root is None: + repo_root = get_repo_root() + return repo_root / DIR_WORKFLOW / FILE_CURRENT_TASK + + +def get_current_task(repo_root: Path | None = None) -> str | None: + """Get current task directory path (relative to repo_root). + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Relative path to current task directory or None. + """ + current_file = _get_current_task_file(repo_root) + + if not current_file.is_file(): + return None + + try: + return current_file.read_text(encoding="utf-8").strip() + except (OSError, IOError): + return None + + +def get_current_task_abs(repo_root: Path | None = None) -> Path | None: + """Get current task directory absolute path. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Absolute path to current task directory or None. + """ + if repo_root is None: + repo_root = get_repo_root() + + relative = get_current_task(repo_root) + if relative: + return repo_root / relative + return None + + +def set_current_task(task_path: str, repo_root: Path | None = None) -> bool: + """Set current task. + + Args: + task_path: Task directory path (relative to repo_root). + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True on success, False on error. + """ + if repo_root is None: + repo_root = get_repo_root() + + if not task_path: + return False + + # Verify task directory exists + full_path = repo_root / task_path + if not full_path.is_dir(): + return False + + current_file = _get_current_task_file(repo_root) + + try: + current_file.write_text(task_path, encoding="utf-8") + return True + except (OSError, IOError): + return False + + +def clear_current_task(repo_root: Path | None = None) -> bool: + """Clear current task. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True on success. + """ + current_file = _get_current_task_file(repo_root) + + try: + if current_file.is_file(): + current_file.unlink() + return True + except (OSError, IOError): + return False + + +def has_current_task(repo_root: Path | None = None) -> bool: + """Check if has current task. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True if current task is set. + """ + return get_current_task(repo_root) is not None + + +# ============================================================================= +# Task ID Generation +# ============================================================================= + +def generate_task_date_prefix() -> str: + """Generate task ID based on date (MM-DD format). + + Returns: + Date prefix string (e.g., "01-21"). + """ + return datetime.now().strftime("%m-%d") + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + repo = get_repo_root() + print(f"Repository root: {repo}") + print(f"Developer: {get_developer(repo)}") + print(f"Tasks dir: {get_tasks_dir(repo)}") + print(f"Workspace dir: {get_workspace_dir(repo)}") + print(f"Journal file: {get_active_journal_file(repo)}") + print(f"Current task: {get_current_task(repo)}") diff --git a/.trellis/scripts/common/phase.py b/.trellis/scripts/common/phase.py new file mode 100755 index 00000000..c3a80394 --- /dev/null +++ b/.trellis/scripts/common/phase.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +""" +Phase Management Utilities. + +Centralized phase tracking for multi-agent pipeline. + +Provides: + get_current_phase - Returns current phase number + get_total_phases - Returns total phase count + get_phase_action - Returns action name for phase + get_phase_info - Returns "N/M (action)" format + set_phase - Sets current_phase + advance_phase - Advances to next phase + get_phase_for_action - Returns phase number for action + map_subagent_to_action - Map subagent type to action name + is_phase_completed - Check if phase is completed + is_current_action - Check if at specific action +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +def _read_json_file(path: Path) -> dict | None: + """Read and parse a JSON file.""" + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +def _write_json_file(path: Path, data: dict) -> bool: + """Write dict to JSON file.""" + try: + path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + return True + except (OSError, IOError): + return False + + +# ============================================================================= +# Phase Functions +# ============================================================================= + +def get_current_phase(task_json: Path) -> int: + """Get current phase number. + + Args: + task_json: Path to task.json file. + + Returns: + Current phase number, or 0 if not found. + """ + data = _read_json_file(task_json) + if not data: + return 0 + return data.get("current_phase", 0) or 0 + + +def get_total_phases(task_json: Path) -> int: + """Get total number of phases. + + Args: + task_json: Path to task.json file. + + Returns: + Total phase count, or 0 if not found. + """ + data = _read_json_file(task_json) + if not data: + return 0 + + next_action = data.get("next_action", []) + if isinstance(next_action, list): + return len(next_action) + return 0 + + +def get_phase_action(task_json: Path, phase: int) -> str: + """Get action name for a specific phase. + + Args: + task_json: Path to task.json file. + phase: Phase number. + + Returns: + Action name, or "unknown" if not found. + """ + data = _read_json_file(task_json) + if not data: + return "unknown" + + next_action = data.get("next_action", []) + if isinstance(next_action, list): + for item in next_action: + if isinstance(item, dict) and item.get("phase") == phase: + return item.get("action", "unknown") + return "unknown" + + +def get_phase_info(task_json: Path) -> str: + """Get formatted phase info: "N/M (action)". + + Args: + task_json: Path to task.json file. + + Returns: + Formatted string like "1/4 (implement)". + """ + data = _read_json_file(task_json) + if not data: + return "N/A" + + current_phase = data.get("current_phase", 0) or 0 + total_phases = get_total_phases(task_json) + action_name = get_phase_action(task_json, current_phase) + + if current_phase == 0 or current_phase is None: + return f"0/{total_phases} (pending)" + else: + return f"{current_phase}/{total_phases} ({action_name})" + + +def set_phase(task_json: Path, phase: int) -> bool: + """Set current phase to a specific value. + + Args: + task_json: Path to task.json file. + phase: Phase number to set. + + Returns: + True on success, False on error. + """ + data = _read_json_file(task_json) + if not data: + return False + + data["current_phase"] = phase + return _write_json_file(task_json, data) + + +def advance_phase(task_json: Path) -> bool: + """Advance to next phase. + + Args: + task_json: Path to task.json file. + + Returns: + True on success, False on error or at final phase. + """ + data = _read_json_file(task_json) + if not data: + return False + + current = data.get("current_phase", 0) or 0 + total = get_total_phases(task_json) + next_phase = current + 1 + + if next_phase > total: + return False # Already at final phase + + data["current_phase"] = next_phase + return _write_json_file(task_json, data) + + +def get_phase_for_action(task_json: Path, action: str) -> int: + """Get phase number for a specific action name. + + Args: + task_json: Path to task.json file. + action: Action name. + + Returns: + Phase number, or 0 if not found. + """ + data = _read_json_file(task_json) + if not data: + return 0 + + next_action = data.get("next_action", []) + if isinstance(next_action, list): + for item in next_action: + if isinstance(item, dict) and item.get("action") == action: + return item.get("phase", 0) + return 0 + + +def map_subagent_to_action(subagent_type: str) -> str: + """Map subagent type to action name. + + Used by hooks to determine which action a subagent corresponds to. + + Args: + subagent_type: Subagent type string. + + Returns: + Corresponding action name. + """ + mapping = { + "implement": "implement", + "check": "check", + "debug": "debug", + "research": "research", + } + return mapping.get(subagent_type, subagent_type) + + +def is_phase_completed(task_json: Path, phase: int) -> bool: + """Check if a phase is completed (current_phase > phase). + + Args: + task_json: Path to task.json file. + phase: Phase number to check. + + Returns: + True if phase is completed. + """ + current = get_current_phase(task_json) + return current > phase + + +def is_current_action(task_json: Path, action: str) -> bool: + """Check if we're at a specific action. + + Args: + task_json: Path to task.json file. + action: Action name to check. + + Returns: + True if current phase matches the action. + """ + current = get_current_phase(task_json) + action_phase = get_phase_for_action(task_json, action) + return current == action_phase + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + import sys + + if len(sys.argv) > 1: + path = Path(sys.argv[1]) + print(f"Task JSON: {path}") + print(f"Phase info: {get_phase_info(path)}") + print(f"Current phase: {get_current_phase(path)}") + print(f"Total phases: {get_total_phases(path)}") + else: + print("Usage: python3 phase.py <task.json>") diff --git a/.trellis/scripts/common/registry.py b/.trellis/scripts/common/registry.py new file mode 100755 index 00000000..7f2bc6f3 --- /dev/null +++ b/.trellis/scripts/common/registry.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +""" +Registry utility functions for multi-agent pipeline. + +Provides: + registry_get_file - Get registry file path + registry_get_agent_by_id - Find agent by ID + registry_get_agent_by_worktree - Find agent by worktree path + registry_get_task_dir - Get task dir for a worktree + registry_remove_by_id - Remove agent by ID + registry_remove_by_worktree - Remove agent by worktree path + registry_add_agent - Add agent to registry + registry_search_agent - Search agent by ID or task_dir + registry_list_agents - List all agents +""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path + +from .paths import get_repo_root +from .worktree import get_agents_dir + + +def _read_json_file(path: Path) -> dict | None: + """Read and parse a JSON file.""" + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +def _write_json_file(path: Path, data: dict) -> bool: + """Write dict to JSON file.""" + try: + path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + return True + except (OSError, IOError): + return False + + +# ============================================================================= +# Registry File Access +# ============================================================================= + +def registry_get_file(repo_root: Path | None = None) -> Path | None: + """Get registry file path. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to registry.json, or None if agents dir not found. + """ + if repo_root is None: + repo_root = get_repo_root() + + agents_dir = get_agents_dir(repo_root) + if agents_dir: + return agents_dir / "registry.json" + return None + + +def _ensure_registry(repo_root: Path | None = None) -> Path | None: + """Ensure registry file exists with valid structure. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to registry file, or None if cannot create. + """ + if repo_root is None: + repo_root = get_repo_root() + + registry_file = registry_get_file(repo_root) + if not registry_file: + return None + + agents_dir = registry_file.parent + + try: + agents_dir.mkdir(parents=True, exist_ok=True) + + if not registry_file.exists(): + _write_json_file(registry_file, {"agents": []}) + + return registry_file + except (OSError, IOError): + return None + + +# ============================================================================= +# Agent Lookup +# ============================================================================= + +def registry_get_agent_by_id( + agent_id: str, + repo_root: Path | None = None +) -> dict | None: + """Get agent by ID. + + Args: + agent_id: Agent ID. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Agent dict, or None if not found. + """ + if repo_root is None: + repo_root = get_repo_root() + + registry_file = registry_get_file(repo_root) + if not registry_file or not registry_file.is_file(): + return None + + data = _read_json_file(registry_file) + if not data: + return None + + for agent in data.get("agents", []): + if agent.get("id") == agent_id: + return agent + + return None + + +def registry_get_agent_by_worktree( + worktree_path: str, + repo_root: Path | None = None +) -> dict | None: + """Get agent by worktree path. + + Args: + worktree_path: Worktree path. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Agent dict, or None if not found. + """ + if repo_root is None: + repo_root = get_repo_root() + + registry_file = registry_get_file(repo_root) + if not registry_file or not registry_file.is_file(): + return None + + data = _read_json_file(registry_file) + if not data: + return None + + for agent in data.get("agents", []): + if agent.get("worktree_path") == worktree_path: + return agent + + return None + + +def registry_search_agent( + search: str, + repo_root: Path | None = None +) -> dict | None: + """Search agent by ID or task_dir containing search term. + + Args: + search: Search term. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + First matching agent dict, or None if not found. + """ + if repo_root is None: + repo_root = get_repo_root() + + registry_file = registry_get_file(repo_root) + if not registry_file or not registry_file.is_file(): + return None + + data = _read_json_file(registry_file) + if not data: + return None + + for agent in data.get("agents", []): + # Exact ID match + if agent.get("id") == search: + return agent + # Partial match on task_dir + task_dir = agent.get("task_dir", "") + if search in task_dir: + return agent + + return None + + +def registry_get_task_dir( + worktree_path: str, + repo_root: Path | None = None +) -> str | None: + """Get task directory for a worktree. + + Args: + worktree_path: Worktree path. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Task directory path, or None if not found. + """ + agent = registry_get_agent_by_worktree(worktree_path, repo_root) + if agent: + return agent.get("task_dir") + return None + + +# ============================================================================= +# Agent Modification +# ============================================================================= + +def registry_remove_by_id(agent_id: str, repo_root: Path | None = None) -> bool: + """Remove agent by ID. + + Args: + agent_id: Agent ID. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True on success. + """ + if repo_root is None: + repo_root = get_repo_root() + + registry_file = registry_get_file(repo_root) + if not registry_file or not registry_file.is_file(): + return True # Nothing to remove + + data = _read_json_file(registry_file) + if not data: + return True + + agents = data.get("agents", []) + data["agents"] = [a for a in agents if a.get("id") != agent_id] + + return _write_json_file(registry_file, data) + + +def registry_remove_by_worktree( + worktree_path: str, + repo_root: Path | None = None +) -> bool: + """Remove agent by worktree path. + + Args: + worktree_path: Worktree path. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True on success. + """ + if repo_root is None: + repo_root = get_repo_root() + + registry_file = registry_get_file(repo_root) + if not registry_file or not registry_file.is_file(): + return True # Nothing to remove + + data = _read_json_file(registry_file) + if not data: + return True + + agents = data.get("agents", []) + data["agents"] = [a for a in agents if a.get("worktree_path") != worktree_path] + + return _write_json_file(registry_file, data) + + +def registry_add_agent( + agent_id: str, + worktree_path: str, + pid: int, + task_dir: str, + repo_root: Path | None = None, + platform: str = "claude", +) -> bool: + """Add agent to registry (replaces if same ID exists). + + Args: + agent_id: Agent ID. + worktree_path: Worktree path. + pid: Process ID. + task_dir: Task directory path. + repo_root: Repository root path. Defaults to auto-detected. + platform: Platform used (e.g., 'claude', 'opencode', 'codex', 'kiro', 'antigravity'). Defaults to 'claude'. + + Returns: + True on success. + """ + if repo_root is None: + repo_root = get_repo_root() + + registry_file = _ensure_registry(repo_root) + if not registry_file: + return False + + data = _read_json_file(registry_file) + if not data: + data = {"agents": []} + + # Remove existing agent with same ID + agents = data.get("agents", []) + agents = [a for a in agents if a.get("id") != agent_id] + + # Create new agent record + started_at = datetime.now().isoformat() + new_agent = { + "id": agent_id, + "worktree_path": worktree_path, + "pid": pid, + "started_at": started_at, + "task_dir": task_dir, + "platform": platform, + } + + agents.append(new_agent) + data["agents"] = agents + + return _write_json_file(registry_file, data) + + +def registry_list_agents(repo_root: Path | None = None) -> list[dict]: + """List all agents. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of agent dicts. + """ + if repo_root is None: + repo_root = get_repo_root() + + registry_file = registry_get_file(repo_root) + if not registry_file or not registry_file.is_file(): + return [] + + data = _read_json_file(registry_file) + if not data: + return [] + + return data.get("agents", []) + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + import json as json_mod + + repo = get_repo_root() + print(f"Repository root: {repo}") + print(f"Registry file: {registry_get_file(repo)}") + print() + print("Agents:") + agents = registry_list_agents(repo) + print(json_mod.dumps(agents, indent=2)) diff --git a/.trellis/scripts/common/task_queue.py b/.trellis/scripts/common/task_queue.py new file mode 100755 index 00000000..70378a1d --- /dev/null +++ b/.trellis/scripts/common/task_queue.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +""" +Task queue utility functions. + +Provides: + list_tasks_by_status - List tasks by status + list_pending_tasks - List tasks with pending status + list_tasks_by_assignee - List tasks by assignee + list_my_tasks - List tasks assigned to current developer + get_task_stats - Get P0/P1/P2/P3 counts +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from .paths import ( + FILE_TASK_JSON, + get_repo_root, + get_developer, + get_tasks_dir, +) + + +def _read_json_file(path: Path) -> dict | None: + """Read and parse a JSON file.""" + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +# ============================================================================= +# Public Functions +# ============================================================================= + +def list_tasks_by_status( + filter_status: str | None = None, + repo_root: Path | None = None +) -> list[dict]: + """List tasks by status. + + Args: + filter_status: Optional status filter. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of task info dicts with keys: priority, id, title, status, assignee. + """ + if repo_root is None: + repo_root = get_repo_root() + + tasks_dir = get_tasks_dir(repo_root) + results = [] + + if not tasks_dir.is_dir(): + return results + + for d in tasks_dir.iterdir(): + if not d.is_dir() or d.name == "archive": + continue + + task_json = d / FILE_TASK_JSON + if not task_json.is_file(): + continue + + data = _read_json_file(task_json) + if not data: + continue + + task_id = data.get("id", "") + title = data.get("title") or data.get("name", "") + priority = data.get("priority", "P2") + status = data.get("status", "planning") + assignee = data.get("assignee", "-") + + # Apply filter + if filter_status and status != filter_status: + continue + + results.append({ + "priority": priority, + "id": task_id, + "title": title, + "status": status, + "assignee": assignee, + "dir": d.name, + "children": data.get("children", []), + "parent": data.get("parent"), + }) + + return results + + +def list_pending_tasks(repo_root: Path | None = None) -> list[dict]: + """List pending tasks. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of task info dicts. + """ + return list_tasks_by_status("planning", repo_root) + + +def list_tasks_by_assignee( + assignee: str, + filter_status: str | None = None, + repo_root: Path | None = None +) -> list[dict]: + """List tasks assigned to a specific developer. + + Args: + assignee: Developer name. + filter_status: Optional status filter. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of task info dicts. + """ + if repo_root is None: + repo_root = get_repo_root() + + tasks_dir = get_tasks_dir(repo_root) + results = [] + + if not tasks_dir.is_dir(): + return results + + for d in tasks_dir.iterdir(): + if not d.is_dir() or d.name == "archive": + continue + + task_json = d / FILE_TASK_JSON + if not task_json.is_file(): + continue + + data = _read_json_file(task_json) + if not data: + continue + + task_assignee = data.get("assignee", "-") + + # Apply assignee filter + if task_assignee != assignee: + continue + + task_id = data.get("id", "") + title = data.get("title") or data.get("name", "") + priority = data.get("priority", "P2") + status = data.get("status", "planning") + + # Apply status filter + if filter_status and status != filter_status: + continue + + results.append({ + "priority": priority, + "id": task_id, + "title": title, + "status": status, + "assignee": task_assignee, + "dir": d.name, + "children": data.get("children", []), + "parent": data.get("parent"), + }) + + return results + + +def list_my_tasks( + filter_status: str | None = None, + repo_root: Path | None = None +) -> list[dict]: + """List tasks assigned to current developer. + + Args: + filter_status: Optional status filter. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of task info dicts. + + Raises: + ValueError: If developer not set. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + if not developer: + raise ValueError("Developer not set") + + return list_tasks_by_assignee(developer, filter_status, repo_root) + + +def get_task_stats(repo_root: Path | None = None) -> dict[str, int]: + """Get task statistics. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Dict with keys: P0, P1, P2, P3, Total. + """ + if repo_root is None: + repo_root = get_repo_root() + + tasks_dir = get_tasks_dir(repo_root) + stats = {"P0": 0, "P1": 0, "P2": 0, "P3": 0, "Total": 0} + + if not tasks_dir.is_dir(): + return stats + + for d in tasks_dir.iterdir(): + if not d.is_dir() or d.name == "archive": + continue + + task_json = d / FILE_TASK_JSON + if not task_json.is_file(): + continue + + data = _read_json_file(task_json) + if not data: + continue + + priority = data.get("priority", "P2") + if priority in stats: + stats[priority] += 1 + stats["Total"] += 1 + + return stats + + +def format_task_stats(stats: dict[str, int]) -> str: + """Format task stats as string. + + Args: + stats: Stats dict from get_task_stats. + + Returns: + Formatted string like "P0:0 P1:1 P2:2 P3:0 Total:3". + """ + return f"P0:{stats['P0']} P1:{stats['P1']} P2:{stats['P2']} P3:{stats['P3']} Total:{stats['Total']}" + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + stats = get_task_stats() + print(format_task_stats(stats)) + print() + print("Pending tasks:") + for task in list_pending_tasks(): + print(f" {task['priority']}|{task['id']}|{task['title']}|{task['status']}|{task['assignee']}") diff --git a/.trellis/scripts/common/task_utils.py b/.trellis/scripts/common/task_utils.py new file mode 100755 index 00000000..84df2fab --- /dev/null +++ b/.trellis/scripts/common/task_utils.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +""" +Task utility functions. + +Provides: + is_safe_task_path - Validate task path is safe to operate on + find_task_by_name - Find task directory by name + archive_task_dir - Archive task to monthly directory +""" + +from __future__ import annotations + +import shutil +import sys +from datetime import datetime +from pathlib import Path + +from .paths import get_repo_root + + +# ============================================================================= +# Path Safety +# ============================================================================= + +def is_safe_task_path(task_path: str, repo_root: Path | None = None) -> bool: + """Check if a relative task path is safe to operate on. + + Args: + task_path: Task path (relative to repo_root). + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True if safe, False if dangerous. + """ + if repo_root is None: + repo_root = get_repo_root() + + # Check empty or null + if not task_path or task_path == "null": + print("Error: empty or null task path", file=sys.stderr) + return False + + # Reject absolute paths + if task_path.startswith("/"): + print(f"Error: absolute path not allowed: {task_path}", file=sys.stderr) + return False + + # Reject ".", "..", paths starting with "./" or "../", or containing ".." + if task_path in (".", "..") or task_path.startswith("./") or task_path.startswith("../") or ".." in task_path: + print(f"Error: path traversal not allowed: {task_path}", file=sys.stderr) + return False + + # Final check: ensure resolved path is not the repo root + abs_path = repo_root / task_path + if abs_path.exists(): + try: + resolved = abs_path.resolve() + root_resolved = repo_root.resolve() + if resolved == root_resolved: + print(f"Error: path resolves to repo root: {task_path}", file=sys.stderr) + return False + except (OSError, IOError): + pass + + return True + + +# ============================================================================= +# Task Lookup +# ============================================================================= + +def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None: + """Find task directory by name (exact or suffix match). + + Args: + task_name: Task name to find. + tasks_dir: Tasks directory path. + + Returns: + Absolute path to task directory, or None if not found. + """ + if not task_name or not tasks_dir or not tasks_dir.is_dir(): + return None + + # Try exact match first + exact_match = tasks_dir / task_name + if exact_match.is_dir(): + return exact_match + + # Try suffix match (e.g., "my-task" matches "01-21-my-task") + for d in tasks_dir.iterdir(): + if d.is_dir() and d.name.endswith(f"-{task_name}"): + return d + + return None + + +# ============================================================================= +# Archive Operations +# ============================================================================= + +def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path | None: + """Archive a task directory to archive/{YYYY-MM}/. + + Args: + task_dir_abs: Absolute path to task directory. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to archived directory, or None on error. + """ + if not task_dir_abs.is_dir(): + print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr) + return None + + # Get tasks directory (parent of the task) + tasks_dir = task_dir_abs.parent + archive_dir = tasks_dir / "archive" + year_month = datetime.now().strftime("%Y-%m") + month_dir = archive_dir / year_month + + # Create archive directory + try: + month_dir.mkdir(parents=True, exist_ok=True) + except (OSError, IOError) as e: + print(f"Error: Failed to create archive directory: {e}", file=sys.stderr) + return None + + # Move task to archive + task_name = task_dir_abs.name + dest = month_dir / task_name + + try: + shutil.move(str(task_dir_abs), str(dest)) + except (OSError, IOError, shutil.Error) as e: + print(f"Error: Failed to move task to archive: {e}", file=sys.stderr) + return None + + return dest + + +def archive_task_complete( + task_dir_abs: Path, + repo_root: Path | None = None +) -> dict[str, str]: + """Complete archive workflow: archive directory. + + Args: + task_dir_abs: Absolute path to task directory. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Dict with archive result info. + """ + if not task_dir_abs.is_dir(): + print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr) + return {} + + archive_dest = archive_task_dir(task_dir_abs, repo_root) + if archive_dest: + return {"archived_to": str(archive_dest)} + + return {} + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + from .paths import get_tasks_dir + + repo = get_repo_root() + tasks = get_tasks_dir(repo) + + print(f"Tasks dir: {tasks}") + print(f"is_safe_task_path('.trellis/tasks/test'): {is_safe_task_path('.trellis/tasks/test', repo)}") + print(f"is_safe_task_path('../test'): {is_safe_task_path('../test', repo)}") diff --git a/.trellis/scripts/common/worktree.py b/.trellis/scripts/common/worktree.py new file mode 100755 index 00000000..f9aa4baa --- /dev/null +++ b/.trellis/scripts/common/worktree.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +""" +Worktree utilities for Multi-Agent Pipeline. + +Provides: + get_worktree_config - Get worktree.yaml path + get_worktree_base_dir - Get worktree storage directory + get_worktree_copy_files - Get files to copy list + get_worktree_post_create_hooks - Get post-create hooks + get_agents_dir - Get agents registry directory +""" + +from __future__ import annotations + +from pathlib import Path + +from .paths import ( + DIR_WORKFLOW, + get_repo_root, + get_workspace_dir, +) + + +# ============================================================================= +# YAML Simple Parser (no dependencies) +# ============================================================================= + + +def _unquote(s: str) -> str: + """Remove exactly one layer of matching surrounding quotes. + + Unlike str.strip('"'), this only removes the outermost pair, + preserving any nested quotes inside the value. + + Examples: + _unquote('"hello"') -> 'hello' + _unquote("'hello'") -> 'hello' + _unquote('"echo \\'hi\\'"') -> "echo 'hi'" + _unquote('hello') -> 'hello' + _unquote('"hello\\'') -> '"hello\\'' (mismatched, unchanged) + """ + if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"): + return s[1:-1] + return s + + +def parse_simple_yaml(content: str) -> dict: + """Parse simple YAML with nested dict support (no dependencies). + + Supports: + - key: value (string) + - key: (followed by list items) + - item1 + - item2 + - key: (followed by nested dict) + nested_key: value + nested_key2: + - item + + Uses indentation to detect nesting (2+ spaces deeper = child). + + Args: + content: YAML content string. + + Returns: + Parsed dict (values can be str, list[str], or dict). + """ + lines = content.splitlines() + result: dict = {} + _parse_yaml_block(lines, 0, 0, result) + return result + + +def _parse_yaml_block( + lines: list[str], start: int, min_indent: int, target: dict +) -> int: + """Parse a YAML block into target dict, returning next line index.""" + i = start + current_list: list | None = None + + while i < len(lines): + line = lines[i] + stripped = line.strip() + + # Skip empty lines and comments + if not stripped or stripped.startswith("#"): + i += 1 + continue + + # Calculate indentation + indent = len(line) - len(line.lstrip()) + + # If dedented past our block, we're done + if indent < min_indent: + break + + if stripped.startswith("- "): + if current_list is not None: + current_list.append(_unquote(stripped[2:].strip())) + i += 1 + elif ":" in stripped: + key, _, value = stripped.partition(":") + key = key.strip() + value = _unquote(value.strip()) + current_list = None + + if value: + # key: value + target[key] = value + i += 1 + else: + # key: (no value) — peek ahead to determine list vs nested dict + next_i, next_line = _next_content_line(lines, i + 1) + if next_i >= len(lines): + target[key] = {} + i = next_i + elif next_line.strip().startswith("- "): + # It's a list + current_list = [] + target[key] = current_list + i += 1 + else: + next_indent = len(next_line) - len(next_line.lstrip()) + if next_indent > indent: + # It's a nested dict + nested: dict = {} + target[key] = nested + i = _parse_yaml_block(lines, i + 1, next_indent, nested) + else: + # Empty value, same or less indent follows + target[key] = {} + i += 1 + else: + i += 1 + + return i + + +def _next_content_line(lines: list[str], start: int) -> tuple[int, str]: + """Find the next non-empty, non-comment line.""" + i = start + while i < len(lines): + stripped = lines[i].strip() + if stripped and not stripped.startswith("#"): + return i, lines[i] + i += 1 + return i, "" + + +def _yaml_get_value(config_file: Path, key: str) -> str | None: + """Read simple value from worktree.yaml. + + Args: + config_file: Path to config file. + key: Key to read. + + Returns: + Value string or None. + """ + try: + content = config_file.read_text(encoding="utf-8") + data = parse_simple_yaml(content) + value = data.get(key) + if isinstance(value, str): + return value + except (OSError, IOError): + pass + return None + + +def _yaml_get_list(config_file: Path, section: str) -> list[str]: + """Read list from worktree.yaml. + + Args: + config_file: Path to config file. + section: Section name. + + Returns: + List of items. + """ + try: + content = config_file.read_text(encoding="utf-8") + data = parse_simple_yaml(content) + value = data.get(section) + if isinstance(value, list): + return [str(item) for item in value] + except (OSError, IOError): + pass + return [] + + +# ============================================================================= +# Worktree Configuration +# ============================================================================= + +# Worktree config file relative path (relative to repo root) +WORKTREE_CONFIG_PATH = f"{DIR_WORKFLOW}/worktree.yaml" + + +def get_worktree_config(repo_root: Path | None = None) -> Path: + """Get worktree.yaml config file path. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Absolute path to config file. + """ + if repo_root is None: + repo_root = get_repo_root() + return repo_root / WORKTREE_CONFIG_PATH + + +def get_worktree_base_dir(repo_root: Path | None = None) -> Path: + """Get worktree base directory. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Absolute path to worktree base directory. + """ + if repo_root is None: + repo_root = get_repo_root() + + config = get_worktree_config(repo_root) + worktree_dir = _yaml_get_value(config, "worktree_dir") + + # Default value + if not worktree_dir: + worktree_dir = "../worktrees" + + # Handle relative path + if worktree_dir.startswith("../") or worktree_dir.startswith("./"): + # Relative to repo_root + return repo_root / worktree_dir + else: + # Absolute path + return Path(worktree_dir) + + +def get_worktree_copy_files(repo_root: Path | None = None) -> list[str]: + """Get files to copy list. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of file paths to copy. + """ + if repo_root is None: + repo_root = get_repo_root() + config = get_worktree_config(repo_root) + return _yaml_get_list(config, "copy") + + +def get_worktree_post_create_hooks(repo_root: Path | None = None) -> list[str]: + """Get post_create hooks. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of commands to run. + """ + if repo_root is None: + repo_root = get_repo_root() + config = get_worktree_config(repo_root) + return _yaml_get_list(config, "post_create") + + +# ============================================================================= +# Agents Registry +# ============================================================================= + +def get_agents_dir(repo_root: Path | None = None) -> Path | None: + """Get agents directory for current developer. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Absolute path to agents directory, or None if no workspace. + """ + if repo_root is None: + repo_root = get_repo_root() + + workspace_dir = get_workspace_dir(repo_root) + if workspace_dir: + return workspace_dir / ".agents" + return None + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + repo = get_repo_root() + print(f"Repository root: {repo}") + print(f"Worktree config: {get_worktree_config(repo)}") + print(f"Worktree base dir: {get_worktree_base_dir(repo)}") + print(f"Copy files: {get_worktree_copy_files(repo)}") + print(f"Post create hooks: {get_worktree_post_create_hooks(repo)}") + print(f"Agents dir: {get_agents_dir(repo)}") diff --git a/.trellis/scripts/create_bootstrap.py b/.trellis/scripts/create_bootstrap.py new file mode 100755 index 00000000..201146f6 --- /dev/null +++ b/.trellis/scripts/create_bootstrap.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +""" +Create Bootstrap Task for First-Time Setup. + +Creates a guided task to help users fill in project guidelines +after initializing Trellis for the first time. + +Usage: + python3 create_bootstrap.py [project-type] + +Arguments: + project-type: frontend | backend | fullstack (default: fullstack) + +Prerequisites: + - .trellis/.developer must exist (run init_developer.py first) + +Creates: + .trellis/tasks/00-bootstrap-guidelines/ + - task.json # Task metadata + - prd.md # Task description and guidance +""" + +from __future__ import annotations + +import json +import sys +from datetime import datetime +from pathlib import Path + +from common.paths import ( + DIR_WORKFLOW, + DIR_SCRIPTS, + DIR_TASKS, + get_repo_root, + get_developer, + get_tasks_dir, + set_current_task, +) + + +# ============================================================================= +# Constants +# ============================================================================= + +TASK_NAME = "00-bootstrap-guidelines" + + +# ============================================================================= +# PRD Content +# ============================================================================= + +def write_prd_header() -> str: + """Write PRD header section.""" + return """# Bootstrap: Fill Project Development Guidelines + +## Purpose + +Welcome to Trellis! This is your first task. + +AI agents use `.trellis/spec/` to understand YOUR project's coding conventions. +**Empty templates = AI writes generic code that doesn't match your project style.** + +Filling these guidelines is a one-time setup that pays off for every future AI session. + +--- + +## Your Task + +Fill in the guideline files based on your **existing codebase**. +""" + + +def write_prd_backend_section() -> str: + """Write PRD backend section.""" + return """ + +### Backend Guidelines + +| File | What to Document | +|------|------------------| +| `.trellis/spec/backend/directory-structure.md` | Where different file types go (routes, services, utils) | +| `.trellis/spec/backend/database-guidelines.md` | ORM, migrations, query patterns, naming conventions | +| `.trellis/spec/backend/error-handling.md` | How errors are caught, logged, and returned | +| `.trellis/spec/backend/logging-guidelines.md` | Log levels, format, what to log | +| `.trellis/spec/backend/quality-guidelines.md` | Code review standards, testing requirements | +""" + + +def write_prd_frontend_section() -> str: + """Write PRD frontend section.""" + return """ + +### Frontend Guidelines + +| File | What to Document | +|------|------------------| +| `.trellis/spec/frontend/directory-structure.md` | Component/page/hook organization | +| `.trellis/spec/frontend/component-guidelines.md` | Component patterns, props conventions | +| `.trellis/spec/frontend/hook-guidelines.md` | Custom hook naming, patterns | +| `.trellis/spec/frontend/state-management.md` | State library, patterns, what goes where | +| `.trellis/spec/frontend/type-safety.md` | TypeScript conventions, type organization | +| `.trellis/spec/frontend/quality-guidelines.md` | Linting, testing, accessibility | +""" + + +def write_prd_footer() -> str: + """Write PRD footer section.""" + return """ + +### Thinking Guides (Optional) + +The `.trellis/spec/guides/` directory contains thinking guides that are already +filled with general best practices. You can customize them for your project if needed. + +--- + +## How to Fill Guidelines + +### Principle: Document Reality, Not Ideals + +Write what your codebase **actually does**, not what you wish it did. +AI needs to match existing patterns, not introduce new ones. + +### Steps + +1. **Look at existing code** - Find 2-3 examples of each pattern +2. **Document the pattern** - Describe what you see +3. **Include file paths** - Reference real files as examples +4. **List anti-patterns** - What does your team avoid? + +--- + +## Tips for Using AI + +Ask AI to help analyze your codebase: + +- "Look at my codebase and document the patterns you see" +- "Analyze my code structure and summarize the conventions" +- "Find error handling patterns and document them" + +The AI will read your code and help you document it. + +--- + +## Completion Checklist + +- [ ] Guidelines filled for your project type +- [ ] At least 2-3 real code examples in each guideline +- [ ] Anti-patterns documented + +When done: + +```bash +python3 ./.trellis/scripts/task.py finish +python3 ./.trellis/scripts/task.py archive 00-bootstrap-guidelines +``` + +--- + +## Why This Matters + +After completing this task: + +1. AI will write code that matches your project style +2. Relevant `/trellis:before-*-dev` commands will inject real context +3. `/trellis:check-*` commands will validate against your actual standards +4. Future developers (human or AI) will onboard faster +""" + + +def write_prd(task_dir: Path, project_type: str) -> None: + """Write prd.md file.""" + content = write_prd_header() + + if project_type == "frontend": + content += write_prd_frontend_section() + elif project_type == "backend": + content += write_prd_backend_section() + else: # fullstack + content += write_prd_backend_section() + content += write_prd_frontend_section() + + content += write_prd_footer() + + prd_file = task_dir / "prd.md" + prd_file.write_text(content, encoding="utf-8") + + +# ============================================================================= +# Task JSON +# ============================================================================= + +def write_task_json(task_dir: Path, developer: str, project_type: str) -> None: + """Write task.json file.""" + today = datetime.now().strftime("%Y-%m-%d") + + # Generate subtasks and related files based on project type + if project_type == "frontend": + subtasks = [ + {"name": "Fill frontend guidelines", "status": "pending"}, + {"name": "Add code examples", "status": "pending"}, + ] + related_files = [".trellis/spec/frontend/"] + elif project_type == "backend": + subtasks = [ + {"name": "Fill backend guidelines", "status": "pending"}, + {"name": "Add code examples", "status": "pending"}, + ] + related_files = [".trellis/spec/backend/"] + else: # fullstack + subtasks = [ + {"name": "Fill backend guidelines", "status": "pending"}, + {"name": "Fill frontend guidelines", "status": "pending"}, + {"name": "Add code examples", "status": "pending"}, + ] + related_files = [".trellis/spec/backend/", ".trellis/spec/frontend/"] + + task_data = { + "id": TASK_NAME, + "name": "Bootstrap Guidelines", + "description": "Fill in project development guidelines for AI agents", + "status": "in_progress", + "dev_type": "docs", + "priority": "P1", + "creator": developer, + "assignee": developer, + "createdAt": today, + "completedAt": None, + "commit": None, + "subtasks": subtasks, + "children": [], + "parent": None, + "relatedFiles": related_files, + "notes": f"First-time setup task created by trellis init ({project_type} project)", + "meta": {}, + } + + task_json = task_dir / "task.json" + task_json.write_text(json.dumps(task_data, indent=2, ensure_ascii=False), encoding="utf-8") + + +# ============================================================================= +# Main +# ============================================================================= + +def main() -> int: + """Main entry point.""" + # Parse project type argument + project_type = "fullstack" + if len(sys.argv) > 1: + project_type = sys.argv[1] + + # Validate project type + if project_type not in ("frontend", "backend", "fullstack"): + print(f"Unknown project type: {project_type}, defaulting to fullstack") + project_type = "fullstack" + + repo_root = get_repo_root() + developer = get_developer(repo_root) + + # Check developer initialized + if not developer: + print("Error: Developer not initialized") + print(f"Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <your-name>") + return 1 + + tasks_dir = get_tasks_dir(repo_root) + task_dir = tasks_dir / TASK_NAME + relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{TASK_NAME}" + + # Check if already exists + if task_dir.exists(): + print(f"Bootstrap task already exists: {relative_path}") + return 0 + + # Create task directory + task_dir.mkdir(parents=True, exist_ok=True) + + # Write files + write_task_json(task_dir, developer, project_type) + write_prd(task_dir, project_type) + + # Set as current task + set_current_task(relative_path, repo_root) + + # Silent output - init command handles user-facing messages + # Only output the task path for programmatic use + print(relative_path) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.trellis/scripts/get_context.py b/.trellis/scripts/get_context.py new file mode 100755 index 00000000..bc634631 --- /dev/null +++ b/.trellis/scripts/get_context.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +""" +Get Session Context for AI Agent. + +Usage: + python3 get_context.py Output context in text format + python3 get_context.py --json Output context in JSON format +""" + +from __future__ import annotations + +from common.git_context import main + + +if __name__ == "__main__": + main() diff --git a/.trellis/scripts/get_developer.py b/.trellis/scripts/get_developer.py new file mode 100755 index 00000000..f8a89ebf --- /dev/null +++ b/.trellis/scripts/get_developer.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +""" +Get current developer name. + +This is a wrapper that uses common/paths.py +""" + +from __future__ import annotations + +import sys + +from common.paths import get_developer + + +def main() -> None: + """CLI entry point.""" + developer = get_developer() + if developer: + print(developer) + else: + print("Developer not initialized", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.trellis/scripts/init_developer.py b/.trellis/scripts/init_developer.py new file mode 100755 index 00000000..9fb53f5c --- /dev/null +++ b/.trellis/scripts/init_developer.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +""" +Initialize developer for workflow. + +Usage: + python3 init_developer.py <developer-name> + +This creates: + - .trellis/.developer file with developer info + - .trellis/workspace/<name>/ directory structure +""" + +from __future__ import annotations + +import sys + +from common.paths import ( + DIR_WORKFLOW, + FILE_DEVELOPER, + get_developer, +) +from common.developer import init_developer + + +def main() -> None: + """CLI entry point.""" + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} <developer-name>") + print() + print("Example:") + print(f" {sys.argv[0]} john") + sys.exit(1) + + name = sys.argv[1] + + # Check if already initialized + existing = get_developer() + if existing: + print(f"Developer already initialized: {existing}") + print() + print(f"To reinitialize, remove {DIR_WORKFLOW}/{FILE_DEVELOPER} first") + sys.exit(0) + + if init_developer(name): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.trellis/scripts/multi_agent/__init__.py b/.trellis/scripts/multi_agent/__init__.py new file mode 100755 index 00000000..c7c7e7dd --- /dev/null +++ b/.trellis/scripts/multi_agent/__init__.py @@ -0,0 +1,5 @@ +""" +Multi-Agent Pipeline Scripts. + +This module provides orchestration for multi-agent workflows. +""" diff --git a/.trellis/scripts/multi_agent/cleanup.py b/.trellis/scripts/multi_agent/cleanup.py new file mode 100755 index 00000000..f81e3704 --- /dev/null +++ b/.trellis/scripts/multi_agent/cleanup.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +""" +Multi-Agent Pipeline: Cleanup Worktree. + +Usage: + python3 cleanup.py <branch-name> Remove specific worktree + python3 cleanup.py --list List all worktrees + python3 cleanup.py --merged Remove merged worktrees + python3 cleanup.py --all Remove all worktrees (with confirmation) + +Options: + -y, --yes Skip confirmation prompts + --keep-branch Don't delete the git branch + +This script: +1. Archives task directory to archive/{YYYY-MM}/ +2. Removes agent from registry +3. Removes git worktree +4. Optionally deletes git branch +""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from common.git_context import _run_git_command +from common.paths import get_repo_root +from common.registry import ( + registry_get_file, + registry_get_task_dir, + registry_remove_by_id, + registry_remove_by_worktree, + registry_search_agent, +) +from common.task_utils import ( + archive_task_complete, + is_safe_task_path, +) + +# ============================================================================= +# Colors +# ============================================================================= + + +class Colors: + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + BLUE = "\033[0;34m" + NC = "\033[0m" + + +def log_info(msg: str) -> None: + print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") + + +def log_success(msg: str) -> None: + print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") + + +def log_warn(msg: str) -> None: + print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}") + + +def log_error(msg: str) -> None: + print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") + + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def confirm(prompt: str, skip_confirm: bool) -> bool: + """Ask for confirmation.""" + if skip_confirm: + return True + + if not sys.stdin.isatty(): + log_error("Non-interactive mode detected. Use -y to skip confirmation.") + return False + + response = input(f"{prompt} [y/N] ") + return response.lower() in ("y", "yes") + + +# ============================================================================= +# Commands +# ============================================================================= + + +def cmd_list(repo_root: Path) -> int: + """List worktrees.""" + print(f"{Colors.BLUE}=== Git Worktrees ==={Colors.NC}") + print() + + subprocess.run(["git", "worktree", "list"], cwd=repo_root) + print() + + # Show registry info + registry_file = registry_get_file(repo_root) + if registry_file and registry_file.is_file(): + print(f"{Colors.BLUE}=== Registered Agents ==={Colors.NC}") + print() + + import json + + data = json.loads(registry_file.read_text(encoding="utf-8")) + agents = data.get("agents", []) + + if agents: + for agent in agents: + print( + f" {agent.get('id', '?')}: PID={agent.get('pid', '?')} [{agent.get('worktree_path', '?')}]" + ) + else: + print(" (none)") + print() + + return 0 + + +def archive_task(worktree_path: str, repo_root: Path) -> None: + """Archive task directory.""" + task_dir = registry_get_task_dir(worktree_path, repo_root) + + if not task_dir or not is_safe_task_path(task_dir, repo_root): + return + + task_dir_abs = repo_root / task_dir + if not task_dir_abs.is_dir(): + return + + result = archive_task_complete(task_dir_abs, repo_root) + if "archived_to" in result: + dest = Path(result["archived_to"]) + log_success(f"Archived task: {dest.name} -> archive/{dest.parent.name}/") + + +def cleanup_registry_only(search: str, repo_root: Path, skip_confirm: bool) -> int: + """Cleanup from registry only (no worktree).""" + agent_info = registry_search_agent(search, repo_root) + + if not agent_info: + log_error(f"No agent found in registry matching: {search}") + return 1 + + agent_id = agent_info.get("id", "?") + task_dir = agent_info.get("task_dir", "?") + + print() + print(f"{Colors.BLUE}=== Cleanup Agent (no worktree) ==={Colors.NC}") + print(f" Agent ID: {agent_id}") + print(f" Task Dir: {task_dir}") + print() + + if not confirm("Archive task and remove from registry?", skip_confirm): + log_info("Aborted") + return 0 + + # Archive task directory if exists + if task_dir and is_safe_task_path(task_dir, repo_root): + task_dir_abs = repo_root / task_dir + if task_dir_abs.is_dir(): + result = archive_task_complete(task_dir_abs, repo_root) + if "archived_to" in result: + dest = Path(result["archived_to"]) + log_success( + f"Archived task: {dest.name} -> archive/{dest.parent.name}/" + ) + else: + log_warn("Invalid task_dir in registry, skipping archive") + + # Remove from registry + registry_remove_by_id(agent_id, repo_root) + log_success(f"Removed from registry: {agent_id}") + + log_success("Cleanup complete") + return 0 + + +def cleanup_worktree( + branch: str, repo_root: Path, skip_confirm: bool, keep_branch: bool +) -> int: + """Cleanup single worktree.""" + # Find worktree path for branch + _, worktree_list, _ = _run_git_command( + ["worktree", "list", "--porcelain"], cwd=repo_root + ) + + worktree_path = None + current_worktree = None + + for line in worktree_list.splitlines(): + if line.startswith("worktree "): + current_worktree = line[9:] # Remove "worktree " prefix + elif line.startswith("branch refs/heads/"): + current_branch = line[18:] # Remove "branch refs/heads/" prefix + if current_branch == branch: + worktree_path = current_worktree + break + + if not worktree_path: + # No worktree found, try to cleanup from registry only + log_warn(f"No worktree found for: {branch}") + log_info("Trying to cleanup from registry...") + return cleanup_registry_only(branch, repo_root, skip_confirm) + + print() + print(f"{Colors.BLUE}=== Cleanup Worktree ==={Colors.NC}") + print(f" Branch: {branch}") + print(f" Worktree: {worktree_path}") + print() + + if not confirm("Remove this worktree?", skip_confirm): + log_info("Aborted") + return 0 + + # 1. Archive task + archive_task(worktree_path, repo_root) + + # 2. Remove from registry + registry_remove_by_worktree(worktree_path, repo_root) + log_info("Removed from registry") + + # 3. Remove worktree + log_info("Removing worktree...") + ret, _, _ = _run_git_command( + ["worktree", "remove", worktree_path, "--force"], cwd=repo_root + ) + if ret != 0: + # Try removing directory manually + try: + shutil.rmtree(worktree_path) + except Exception as e: + log_error(f"Failed to remove worktree: {e}") + + log_success("Worktree removed") + + # 4. Delete branch (optional) + if not keep_branch: + log_info("Deleting branch...") + ret, _, _ = _run_git_command(["branch", "-D", branch], cwd=repo_root) + if ret != 0: + log_warn("Could not delete branch (may be checked out elsewhere)") + + log_success(f"Cleanup complete for: {branch}") + return 0 + + +def cmd_merged(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int: + """Cleanup merged worktrees.""" + # Get main branch + _, head_out, _ = _run_git_command( + ["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=repo_root + ) + main_branch = head_out.strip().replace("refs/remotes/origin/", "") or "main" + + print(f"{Colors.BLUE}=== Finding Merged Worktrees ==={Colors.NC}") + print() + + # Get merged branches + _, merged_out, _ = _run_git_command( + ["branch", "--merged", main_branch], cwd=repo_root + ) + merged_branches = [] + for line in merged_out.splitlines(): + branch = line.strip().lstrip("* ") + if branch and branch != main_branch: + merged_branches.append(branch) + + if not merged_branches: + log_info("No merged branches found") + return 0 + + # Get worktree list + _, worktree_list, _ = _run_git_command(["worktree", "list"], cwd=repo_root) + + worktree_branches = [] + for branch in merged_branches: + if f"[{branch}]" in worktree_list: + worktree_branches.append(branch) + print(f" - {branch}") + + if not worktree_branches: + log_info("No merged worktrees found") + return 0 + + print() + if not confirm("Remove these merged worktrees?", skip_confirm): + log_info("Aborted") + return 0 + + for branch in worktree_branches: + cleanup_worktree(branch, repo_root, True, keep_branch) + + return 0 + + +def cmd_all(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int: + """Cleanup all worktrees.""" + print(f"{Colors.BLUE}=== All Worktrees ==={Colors.NC}") + print() + + # Get worktree list + _, worktree_list, _ = _run_git_command( + ["worktree", "list", "--porcelain"], cwd=repo_root + ) + + worktrees = [] + main_worktree = str(repo_root.resolve()) + + for line in worktree_list.splitlines(): + if line.startswith("worktree "): + wt = line[9:] + if wt != main_worktree: + worktrees.append(wt) + + if not worktrees: + log_info("No worktrees to remove") + return 0 + + for wt in worktrees: + print(f" - {wt}") + + print() + print(f"{Colors.RED}WARNING: This will remove ALL worktrees!{Colors.NC}") + + if not confirm("Are you sure?", skip_confirm): + log_info("Aborted") + return 0 + + # Get branch for each worktree + for wt in worktrees: + # Find branch name from worktree list + _, wt_list, _ = _run_git_command(["worktree", "list"], cwd=repo_root) + for line in wt_list.splitlines(): + if wt in line: + # Extract branch from [branch] format + import re + + match = re.search(r"\[([^\]]+)\]", line) + if match: + branch = match.group(1) + cleanup_worktree(branch, repo_root, True, keep_branch) + break + + return 0 + + +# ============================================================================= +# Main +# ============================================================================= + + +def main() -> int: + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Multi-Agent Pipeline: Cleanup Worktree" + ) + parser.add_argument("branch", nargs="?", help="Branch name to cleanup") + parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmation") + parser.add_argument( + "--keep-branch", action="store_true", help="Don't delete git branch" + ) + parser.add_argument("--list", action="store_true", help="List all worktrees") + parser.add_argument("--merged", action="store_true", help="Remove merged worktrees") + parser.add_argument("--all", action="store_true", help="Remove all worktrees") + + args = parser.parse_args() + repo_root = get_repo_root() + + if args.list: + return cmd_list(repo_root) + elif args.merged: + return cmd_merged(repo_root, args.yes, args.keep_branch) + elif args.all: + return cmd_all(repo_root, args.yes, args.keep_branch) + elif args.branch: + return cleanup_worktree(args.branch, repo_root, args.yes, args.keep_branch) + else: + print("""Usage: + python3 cleanup.py <branch-name> Remove specific worktree + python3 cleanup.py --list List all worktrees + python3 cleanup.py --merged Remove merged worktrees + python3 cleanup.py --all Remove all worktrees + +Options: + -y, --yes Skip confirmation + --keep-branch Don't delete git branch +""") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.trellis/scripts/multi_agent/create_pr.py b/.trellis/scripts/multi_agent/create_pr.py new file mode 100755 index 00000000..54df3db6 --- /dev/null +++ b/.trellis/scripts/multi_agent/create_pr.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +""" +Multi-Agent Pipeline: Create PR. + +Usage: + python3 create_pr.py [task-dir] [--dry-run] + +This script: +1. Stages and commits all changes (excluding workspace/) +2. Pushes to origin +3. Creates a Draft PR using `gh pr create` +4. Updates task.json with status="completed", pr_url, and current_phase + +Note: This is the only action that performs git commit, as it's the final +step after all implementation and checks are complete. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from common.git_context import _run_git_command +from common.paths import ( + DIR_WORKFLOW, + FILE_TASK_JSON, + get_current_task, + get_repo_root, +) +from common.phase import get_phase_for_action + +# ============================================================================= +# Colors +# ============================================================================= + + +class Colors: + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + BLUE = "\033[0;34m" + NC = "\033[0m" + + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def _read_json_file(path: Path) -> dict | None: + """Read and parse a JSON file.""" + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +def _write_json_file(path: Path, data: dict) -> bool: + """Write dict to JSON file.""" + try: + path.write_text( + json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8" + ) + return True + except (OSError, IOError): + return False + + +# ============================================================================= +# Main +# ============================================================================= + + +def main() -> int: + """Main entry point.""" + parser = argparse.ArgumentParser(description="Multi-Agent Pipeline: Create PR") + parser.add_argument("dir", nargs="?", help="Task directory") + parser.add_argument( + "--dry-run", action="store_true", help="Show what would be done" + ) + + args = parser.parse_args() + repo_root = get_repo_root() + + # ============================================================================= + # Get Task Directory + # ============================================================================= + target_dir = args.dir + if not target_dir: + # Try to get from .current-task + current_task = get_current_task(repo_root) + if current_task: + target_dir = current_task + + if not target_dir: + print( + f"{Colors.RED}Error: No task directory specified and no current task set{Colors.NC}" + ) + print("Usage: python3 create_pr.py [task-dir] [--dry-run]") + return 1 + + # Support relative paths + if not target_dir.startswith("/"): + target_dir_path = repo_root / target_dir + else: + target_dir_path = Path(target_dir) + + task_json = target_dir_path / FILE_TASK_JSON + if not task_json.is_file(): + print(f"{Colors.RED}Error: task.json not found at {target_dir_path}{Colors.NC}") + return 1 + + # ============================================================================= + # Main + # ============================================================================= + print(f"{Colors.BLUE}=== Create PR ==={Colors.NC}") + if args.dry_run: + print( + f"{Colors.YELLOW}[DRY-RUN MODE] No actual changes will be made{Colors.NC}" + ) + print() + + # Read task config + task_data = _read_json_file(task_json) + if not task_data: + print(f"{Colors.RED}Error: Failed to read task.json{Colors.NC}") + return 1 + + task_name = task_data.get("name", "") + base_branch = task_data.get("base_branch", "main") + scope = task_data.get("scope", "core") + dev_type = task_data.get("dev_type", "feature") + + # Map dev_type to commit prefix + prefix_map = { + "feature": "feat", + "frontend": "feat", + "backend": "feat", + "fullstack": "feat", + "bugfix": "fix", + "fix": "fix", + "refactor": "refactor", + "docs": "docs", + "test": "test", + } + commit_prefix = prefix_map.get(dev_type, "feat") + + print(f"Task: {task_name}") + print(f"Base branch: {base_branch}") + print(f"Scope: {scope}") + print(f"Commit prefix: {commit_prefix}") + print() + + # Get current branch + _, branch_out, _ = _run_git_command(["branch", "--show-current"]) + current_branch = branch_out.strip() + print(f"Current branch: {current_branch}") + + # Check for changes + print(f"{Colors.YELLOW}Checking for changes...{Colors.NC}") + + # Stage changes + _run_git_command(["add", "-A"]) + + # Exclude workspace and temp files + _run_git_command(["reset", f"{DIR_WORKFLOW}/workspace/"]) + _run_git_command(["reset", ".agent-log", ".session-id"]) + + # Check if there are staged changes + ret, _, _ = _run_git_command(["diff", "--cached", "--quiet"]) + has_staged_changes = ret != 0 + + if not has_staged_changes: + print(f"{Colors.YELLOW}No staged changes to commit{Colors.NC}") + + # Check for unpushed commits + ret, log_out, _ = _run_git_command( + ["log", f"origin/{current_branch}..HEAD", "--oneline"] + ) + unpushed = len([line for line in log_out.splitlines() if line.strip()]) + + if unpushed == 0: + if args.dry_run: + _run_git_command(["reset", "HEAD"]) + print(f"{Colors.RED}No changes to create PR{Colors.NC}") + return 1 + + print(f"Found {unpushed} unpushed commit(s)") + else: + # Commit changes + print(f"{Colors.YELLOW}Committing changes...{Colors.NC}") + commit_msg = f"{commit_prefix}({scope}): {task_name}" + + if args.dry_run: + print(f"[DRY-RUN] Would commit with message: {commit_msg}") + print("[DRY-RUN] Staged files:") + _, staged_out, _ = _run_git_command(["diff", "--cached", "--name-only"]) + for line in staged_out.splitlines(): + print(f" - {line}") + else: + _run_git_command(["commit", "-m", commit_msg]) + print(f"{Colors.GREEN}Committed: {commit_msg}{Colors.NC}") + + # Push to remote + print(f"{Colors.YELLOW}Pushing to remote...{Colors.NC}") + if args.dry_run: + print(f"[DRY-RUN] Would push to: origin/{current_branch}") + else: + ret, _, err = _run_git_command(["push", "-u", "origin", current_branch]) + if ret != 0: + print(f"{Colors.RED}Failed to push: {err}{Colors.NC}") + return 1 + print(f"{Colors.GREEN}Pushed to origin/{current_branch}{Colors.NC}") + + # Create PR + print(f"{Colors.YELLOW}Creating PR...{Colors.NC}") + pr_title = f"{commit_prefix}({scope}): {task_name}" + pr_url = "" + + if args.dry_run: + print("[DRY-RUN] Would create PR:") + print(f" Title: {pr_title}") + print(f" Base: {base_branch}") + print(f" Head: {current_branch}") + prd_file = target_dir_path / "prd.md" + if prd_file.is_file(): + print(" Body: (from prd.md)") + pr_url = "https://github.com/example/repo/pull/DRY-RUN" + else: + # Check if PR already exists + result = subprocess.run( + [ + "gh", + "pr", + "list", + "--head", + current_branch, + "--base", + base_branch, + "--json", + "url", + "--jq", + ".[0].url", + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + existing_pr = result.stdout.strip() + + if existing_pr: + print(f"{Colors.YELLOW}PR already exists: {existing_pr}{Colors.NC}") + pr_url = existing_pr + else: + # Read PRD as PR body + pr_body = "" + prd_file = target_dir_path / "prd.md" + if prd_file.is_file(): + pr_body = prd_file.read_text(encoding="utf-8") + + # Create PR + result = subprocess.run( + [ + "gh", + "pr", + "create", + "--draft", + "--base", + base_branch, + "--title", + pr_title, + "--body", + pr_body, + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + + if result.returncode != 0: + print(f"{Colors.RED}Failed to create PR: {result.stderr}{Colors.NC}") + return 1 + + pr_url = result.stdout.strip() + print(f"{Colors.GREEN}PR created: {pr_url}{Colors.NC}") + + # Update task.json + print(f"{Colors.YELLOW}Updating task status...{Colors.NC}") + if args.dry_run: + print("[DRY-RUN] Would update task.json:") + print(" status: completed") + print(f" pr_url: {pr_url}") + print(" current_phase: (set to create-pr phase)") + else: + # Get the phase number for create-pr action + create_pr_phase = get_phase_for_action(task_json, "create-pr") + if not create_pr_phase: + create_pr_phase = 4 # Default fallback + + task_data["status"] = "completed" + task_data["pr_url"] = pr_url + task_data["current_phase"] = create_pr_phase + + _write_json_file(task_json, task_data) + print( + f"{Colors.GREEN}Task status updated to 'completed', phase {create_pr_phase}{Colors.NC}" + ) + + # In dry-run, reset the staging area + if args.dry_run: + _run_git_command(["reset", "HEAD"]) + + print() + print(f"{Colors.GREEN}=== PR Created Successfully ==={Colors.NC}") + print(f"PR URL: {pr_url}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.trellis/scripts/multi_agent/plan.py b/.trellis/scripts/multi_agent/plan.py new file mode 100755 index 00000000..7ce5e6f3 --- /dev/null +++ b/.trellis/scripts/multi_agent/plan.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +""" +Multi-Agent Pipeline: Plan Agent Launcher. + +Usage: python3 plan.py --name <task-name> --type <dev-type> --requirement "<requirement>" + +This script: +1. Creates task directory +2. Starts Plan Agent in background +3. Plan Agent produces fully configured task directory + +After completion, use start.py to launch the Dispatch Agent. + +Prerequisites: + - agents/plan.md must exist (in .claude/, .cursor/, .iflow/, or .opencode/) + - Developer must be initialized +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from common.cli_adapter import get_cli_adapter +from common.paths import get_repo_root +from common.developer import ensure_developer + + +# ============================================================================= +# Colors +# ============================================================================= + +class Colors: + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + BLUE = "\033[0;34m" + NC = "\033[0m" + + +def log_info(msg: str) -> None: + print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") + + +def log_success(msg: str) -> None: + print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") + + +def log_error(msg: str) -> None: + print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") + + +# ============================================================================= +# Constants +# ============================================================================= + +DEFAULT_PLATFORM = "claude" + + +# ============================================================================= +# Main +# ============================================================================= + +def main() -> int: + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Multi-Agent Pipeline: Plan Agent Launcher" + ) + parser.add_argument("--name", "-n", required=True, help="Task name (e.g., user-auth)") + parser.add_argument("--type", "-t", required=True, help="Dev type: backend|frontend|fullstack") + parser.add_argument("--requirement", "-r", required=True, help="Requirement description") + parser.add_argument( + "--platform", "-p", + choices=["claude", "cursor", "iflow", "opencode", "qoder"], + default=DEFAULT_PLATFORM, + help="Platform to use (default: claude)" + ) + + args = parser.parse_args() + + task_name = args.name + dev_type = args.type + requirement = args.requirement + platform = args.platform + + # Initialize CLI adapter + adapter = get_cli_adapter(platform) + + # Validate dev type + if dev_type not in ("backend", "frontend", "fullstack"): + log_error(f"Invalid dev type: {dev_type} (must be: backend, frontend, fullstack)") + return 1 + + project_root = get_repo_root() + + # Check plan agent exists (path varies by platform) + plan_md = adapter.get_agent_path("plan", project_root) + if not plan_md.is_file(): + log_error(f"plan agent not found at {plan_md}") + log_info(f"Platform: {platform}") + return 1 + + ensure_developer(project_root) + + # ============================================================================= + # Step 1: Create Task Directory + # ============================================================================= + print() + print(f"{Colors.BLUE}=== Multi-Agent Pipeline: Plan ==={Colors.NC}") + log_info(f"Task: {task_name}") + log_info(f"Type: {dev_type}") + log_info(f"Requirement: {requirement}") + print() + + log_info("Step 1: Creating task directory...") + + # Import task module to create task + from task import cmd_create + import argparse as ap + + # Create task using task.py's create command + create_args = ap.Namespace( + title=requirement, + slug=task_name, + assignee=None, + priority="P2", + description="" + ) + + # Capture stdout to get task dir + import io + from contextlib import redirect_stdout + + stdout_capture = io.StringIO() + with redirect_stdout(stdout_capture): + ret = cmd_create(create_args) + + if ret != 0: + log_error("Failed to create task directory") + return 1 + + task_dir = stdout_capture.getvalue().strip().split("\n")[-1] + task_dir_abs = project_root / task_dir + + log_success(f"Task directory: {task_dir}") + + # ============================================================================= + # Step 2: Prepare and Start Plan Agent + # ============================================================================= + log_info("Step 2: Starting Plan Agent in background...") + + log_file = task_dir_abs / ".plan-log" + log_file.touch() + + # Get proxy environment variables + https_proxy = os.environ.get("https_proxy", "") + http_proxy = os.environ.get("http_proxy", "") + all_proxy = os.environ.get("all_proxy", "") + + # Start agent in background (cross-platform, no shell script needed) + env = os.environ.copy() + env["PLAN_TASK_NAME"] = task_name + env["PLAN_DEV_TYPE"] = dev_type + env["PLAN_TASK_DIR"] = task_dir + env["PLAN_REQUIREMENT"] = requirement + env["https_proxy"] = https_proxy + env["http_proxy"] = http_proxy + env["all_proxy"] = all_proxy + + # Clear nested-session detection so the new CLI process can start + env.pop("CLAUDECODE", None) + + # Set non-interactive env var based on platform + env.update(adapter.get_non_interactive_env()) + + # Build CLI command using adapter + cli_cmd = adapter.build_run_command( + agent="plan", # Will be mapped to "trellis-plan" for OpenCode + prompt=f"Start planning for task: {task_name}", + skip_permissions=True, + verbose=True, + json_output=True, + ) + + with log_file.open("w") as log_f: + # Use shell=False for cross-platform compatibility + # creationflags for Windows, start_new_session for Unix + popen_kwargs = { + "stdout": log_f, + "stderr": subprocess.STDOUT, + "cwd": str(project_root), + "env": env, + } + if sys.platform == "win32": + popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + popen_kwargs["start_new_session"] = True + + process = subprocess.Popen(cli_cmd, **popen_kwargs) + agent_pid = process.pid + + log_success(f"Plan Agent started (PID: {agent_pid})") + + # ============================================================================= + # Summary + # ============================================================================= + print() + print(f"{Colors.GREEN}=== Plan Agent Running ==={Colors.NC}") + print() + print(f" Task: {task_name}") + print(f" Type: {dev_type}") + print(f" Dir: {task_dir}") + print(f" Log: {log_file}") + print(f" PID: {agent_pid}") + print() + print(f"{Colors.YELLOW}To monitor:{Colors.NC}") + print(f" tail -f {log_file}") + print() + print(f"{Colors.YELLOW}To check status:{Colors.NC}") + print(f" ps -p {agent_pid}") + print(f" ls -la {task_dir}") + print() + print(f"{Colors.YELLOW}After completion, run:{Colors.NC}") + print(f" python3 ./.trellis/scripts/multi_agent/start.py {task_dir}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.trellis/scripts/multi_agent/start.py b/.trellis/scripts/multi_agent/start.py new file mode 100755 index 00000000..40c2747e --- /dev/null +++ b/.trellis/scripts/multi_agent/start.py @@ -0,0 +1,465 @@ +#!/usr/bin/env python3 +""" +Multi-Agent Pipeline: Start Worktree Agent. + +Usage: python3 start.py <task-dir> +Example: python3 start.py .trellis/tasks/01-21-my-task + +This script: +1. Creates worktree (if not exists) with dependency install +2. Copies environment files (from worktree.yaml config) +3. Sets .current-task in worktree +4. Starts claude agent in background +5. Registers agent to registry.json + +Prerequisites: + - task.json must exist with 'branch' field + - agents/dispatch.md must exist (in .claude/, .cursor/, .iflow/, or .opencode/) + +Configuration: .trellis/worktree.yaml +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import uuid +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from common.cli_adapter import CLIAdapter, get_cli_adapter +from common.git_context import _run_git_command +from common.paths import ( + DIR_WORKFLOW, + FILE_CURRENT_TASK, + FILE_TASK_JSON, + get_repo_root, +) +from common.registry import ( + registry_add_agent, + registry_get_file, +) +from common.worktree import ( + get_worktree_base_dir, + get_worktree_config, + get_worktree_copy_files, + get_worktree_post_create_hooks, +) + +# ============================================================================= +# Colors +# ============================================================================= + + +class Colors: + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + BLUE = "\033[0;34m" + NC = "\033[0m" + + +def log_info(msg: str) -> None: + print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") + + +def log_success(msg: str) -> None: + print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") + + +def log_warn(msg: str) -> None: + print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}") + + +def log_error(msg: str) -> None: + print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") + + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def _read_json_file(path: Path) -> dict | None: + """Read and parse a JSON file.""" + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +def _write_json_file(path: Path, data: dict) -> bool: + """Write dict to JSON file.""" + try: + path.write_text( + json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8" + ) + return True + except (OSError, IOError): + return False + + +# ============================================================================= +# Constants +# ============================================================================= + +DEFAULT_PLATFORM = "claude" + + +# ============================================================================= +# Main +# ============================================================================= + + +def main() -> int: + """Main entry point.""" + import argparse + + parser = argparse.ArgumentParser(description="Multi-Agent Pipeline: Start Worktree Agent") + parser.add_argument("task_dir", help="Task directory path") + parser.add_argument( + "--platform", "-p", + choices=["claude", "cursor", "iflow", "opencode", "qoder"], + default=DEFAULT_PLATFORM, + help="Platform to use (default: claude)" + ) + + args = parser.parse_args() + task_dir_arg = args.task_dir + platform = args.platform + + # Initialize CLI adapter + adapter = get_cli_adapter(platform) + + project_root = get_repo_root() + + # Normalize paths + if task_dir_arg.startswith("/"): + task_dir_relative = task_dir_arg[len(str(project_root)) + 1 :] + task_dir_abs = Path(task_dir_arg) + else: + task_dir_relative = task_dir_arg + task_dir_abs = project_root / task_dir_arg + + task_json_path = task_dir_abs / FILE_TASK_JSON + + # ============================================================================= + # Validation + # ============================================================================= + if not task_json_path.is_file(): + log_error(f"task.json not found at {task_json_path}") + return 1 + + dispatch_md = adapter.get_agent_path("dispatch", project_root) + if not dispatch_md.is_file(): + log_error(f"dispatch.md not found at {dispatch_md}") + log_info(f"Platform: {platform}") + return 1 + + config_file = get_worktree_config(project_root) + if not config_file.is_file(): + log_error(f"worktree.yaml not found at {config_file}") + return 1 + + # ============================================================================= + # Read Task Config + # ============================================================================= + print() + print(f"{Colors.BLUE}=== Multi-Agent Pipeline: Start ==={Colors.NC}") + log_info(f"Task: {task_dir_abs}") + + task_data = _read_json_file(task_json_path) + if not task_data: + log_error("Failed to read task.json") + return 1 + + branch = task_data.get("branch") + task_name = task_data.get("name") + task_status = task_data.get("status") + worktree_path = task_data.get("worktree_path") + + # Check if task was rejected + if task_status == "rejected": + log_error("Task was rejected by Plan Agent") + rejected_file = task_dir_abs / "REJECTED.md" + if rejected_file.is_file(): + print() + print(f"{Colors.YELLOW}Rejection reason:{Colors.NC}") + print(rejected_file.read_text(encoding="utf-8")) + print() + log_info( + "To retry, delete this directory and run plan.py again with revised requirements" + ) + return 1 + + # Check if prd.md exists (plan completed successfully) + prd_file = task_dir_abs / "prd.md" + if not prd_file.is_file(): + log_error("prd.md not found - Plan Agent may not have completed") + log_info(f"Check plan log: {task_dir_abs}/.plan-log") + return 1 + + if not branch: + log_error("branch field not set in task.json") + log_info("Please set branch field first, e.g.:") + log_info( + " jq '.branch = \"task/my-task\"' task.json > tmp && mv tmp task.json" + ) + return 1 + + log_info(f"Branch: {branch}") + log_info(f"Name: {task_name}") + + # ============================================================================= + # Step 1: Create Worktree (if not exists) + # ============================================================================= + if not worktree_path or not Path(worktree_path).is_dir(): + log_info("Step 1: Creating worktree...") + + # Record current branch as base_branch (PR target) + _, base_branch_out, _ = _run_git_command( + ["branch", "--show-current"], cwd=project_root + ) + base_branch = base_branch_out.strip() + log_info(f"Base branch (PR target): {base_branch}") + + # Calculate worktree path + worktree_base = get_worktree_base_dir(project_root) + worktree_base.mkdir(parents=True, exist_ok=True) + worktree_base = worktree_base.resolve() + worktree_path_obj = worktree_base / branch + worktree_path = str(worktree_path_obj) + + # Create parent directory + worktree_path_obj.parent.mkdir(parents=True, exist_ok=True) + + # Create branch if not exists + ret, _, _ = _run_git_command( + ["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"], + cwd=project_root, + ) + if ret == 0: + log_info("Branch exists, checking out...") + ret, _, err = _run_git_command( + ["worktree", "add", worktree_path, branch], cwd=project_root + ) + else: + log_info(f"Creating new branch: {branch}") + ret, _, err = _run_git_command( + ["worktree", "add", "-b", branch, worktree_path], cwd=project_root + ) + + if ret != 0: + log_error(f"Failed to create worktree: {err}") + return 1 + + log_success(f"Worktree created: {worktree_path}") + + # Update task.json with worktree_path and base_branch + task_data["worktree_path"] = worktree_path + task_data["base_branch"] = base_branch + _write_json_file(task_json_path, task_data) + + # ----- Copy environment files ----- + log_info("Copying environment files...") + copy_list = get_worktree_copy_files(project_root) + copy_count = 0 + + for item in copy_list: + if not item: + continue + + source = project_root / item + target = Path(worktree_path) / item + + if source.is_file(): + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(str(source), str(target)) + copy_count += 1 + + if copy_count > 0: + log_success(f"Copied {copy_count} file(s)") + + # ----- Copy task directory (may not be committed yet) ----- + log_info("Copying task directory...") + task_target_dir = Path(worktree_path) / task_dir_relative + task_target_dir.parent.mkdir(parents=True, exist_ok=True) + if task_target_dir.exists(): + shutil.rmtree(str(task_target_dir)) + shutil.copytree(str(task_dir_abs), str(task_target_dir)) + log_success("Task directory copied to worktree") + + # ----- Run post_create hooks ----- + log_info("Running post_create hooks...") + post_create = get_worktree_post_create_hooks(project_root) + hook_count = 0 + + for cmd in post_create: + if not cmd: + continue + + log_info(f" Running: {cmd}") + ret = subprocess.run(cmd, shell=True, cwd=worktree_path) + if ret.returncode != 0: + log_error(f"Hook failed: {cmd}") + return 1 + hook_count += 1 + + if hook_count > 0: + log_success(f"Ran {hook_count} hook(s)") + else: + log_info(f"Step 1: Using existing worktree: {worktree_path}") + + # ============================================================================= + # Step 2: Set .current-task in Worktree + # ============================================================================= + log_info("Step 2: Setting current task in worktree...") + + worktree_workflow_dir = Path(worktree_path) / DIR_WORKFLOW + worktree_workflow_dir.mkdir(parents=True, exist_ok=True) + + current_task_file = worktree_workflow_dir / FILE_CURRENT_TASK + current_task_file.write_text(task_dir_relative, encoding="utf-8") + log_success(f"Current task set: {task_dir_relative}") + + # ============================================================================= + # Step 3: Prepare and Start Claude Agent + # ============================================================================= + log_info(f"Step 3: Starting {adapter.cli_name} agent...") + + # Update task status + task_data["status"] = "in_progress" + _write_json_file(task_json_path, task_data) + + log_file = Path(worktree_path) / ".agent-log" + session_id_file = Path(worktree_path) / ".session-id" + + log_file.touch() + + # Generate session ID for resume support (Claude Code only) + # OpenCode generates its own session ID, we'll extract it from logs later + if adapter.supports_session_id_on_create: + session_id = str(uuid.uuid4()).lower() + session_id_file.write_text(session_id, encoding="utf-8") + log_info(f"Session ID: {session_id}") + else: + session_id = None # Will be extracted from logs after startup + log_info("Session ID will be extracted from logs after startup") + + # Get proxy environment variables + https_proxy = os.environ.get("https_proxy", "") + http_proxy = os.environ.get("http_proxy", "") + all_proxy = os.environ.get("all_proxy", "") + + # Start agent in background (cross-platform, no shell script needed) + env = os.environ.copy() + env["https_proxy"] = https_proxy + env["http_proxy"] = http_proxy + env["all_proxy"] = all_proxy + + # Clear nested-session detection so the new CLI process can start + # (when this script runs inside a Claude Code session, CLAUDECODE=1 is inherited) + env.pop("CLAUDECODE", None) + + # Set non-interactive env var based on platform + env.update(adapter.get_non_interactive_env()) + + # Build CLI command using adapter + # Note: Use explicit prompt to avoid confusion with CI/CD pipelines + # Also remind the model to follow its agent definition for better cross-model compatibility + cli_cmd = adapter.build_run_command( + agent="dispatch", + prompt="Follow your agent instructions to execute the task workflow. Start by reading .trellis/.current-task to get the task directory, then execute each action in task.json next_action array in order.", + session_id=session_id if adapter.supports_session_id_on_create else None, + skip_permissions=True, + verbose=True, + json_output=True, + ) + + with log_file.open("w") as log_f: + # Use shell=False for cross-platform compatibility + # creationflags for Windows, start_new_session for Unix + popen_kwargs = { + "stdout": log_f, + "stderr": subprocess.STDOUT, + "cwd": worktree_path, + "env": env, + } + if sys.platform == "win32": + popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + popen_kwargs["start_new_session"] = True + + process = subprocess.Popen(cli_cmd, **popen_kwargs) + agent_pid = process.pid + + log_success(f"Agent started with PID: {agent_pid}") + + # For OpenCode: extract session ID from logs after startup + if not adapter.supports_session_id_on_create: + import time + log_info("Waiting for session ID from logs...") + # Wait a bit for the log to have session ID + for _ in range(10): # Try for up to 5 seconds + time.sleep(0.5) + try: + log_content = log_file.read_text(encoding="utf-8", errors="replace") + session_id = adapter.extract_session_id_from_log(log_content) + if session_id: + session_id_file.write_text(session_id, encoding="utf-8") + log_success(f"Session ID extracted: {session_id}") + break + except Exception: + pass + else: + log_warn("Could not extract session ID from logs") + session_id = "unknown" + + # ============================================================================= + # Step 4: Register to Registry (in main repo, not worktree) + # ============================================================================= + log_info("Step 4: Registering agent to registry...") + + # Generate agent ID + task_id = task_data.get("id") + if not task_id: + task_id = branch.replace("/", "-") + + registry_add_agent( + task_id, worktree_path, agent_pid, task_dir_relative, project_root, platform + ) + + log_success(f"Agent registered: {task_id}") + + # ============================================================================= + # Summary + # ============================================================================= + print() + print(f"{Colors.GREEN}=== Agent Started ==={Colors.NC}") + print() + print(f" ID: {task_id}") + print(f" PID: {agent_pid}") + print(f" Session: {session_id}") + print(f" Worktree: {worktree_path}") + print(f" Task: {task_dir_relative}") + print(f" Log: {log_file}") + print(f" Registry: {registry_get_file(project_root)}") + print() + print(f"{Colors.YELLOW}To monitor:{Colors.NC} tail -f {log_file}") + print(f"{Colors.YELLOW}To stop:{Colors.NC} kill {agent_pid}") + if session_id and session_id != "unknown": + resume_cmd = adapter.get_resume_command_str(session_id, cwd=worktree_path) + print(f"{Colors.YELLOW}To resume:{Colors.NC} {resume_cmd}") + else: + print(f"{Colors.YELLOW}To resume:{Colors.NC} (session ID not available)") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.trellis/scripts/multi_agent/status.py b/.trellis/scripts/multi_agent/status.py new file mode 100755 index 00000000..e83ac60a --- /dev/null +++ b/.trellis/scripts/multi_agent/status.py @@ -0,0 +1,817 @@ +#!/usr/bin/env python3 +""" +Multi-Agent Pipeline: Status Monitor. + +Usage: + python3 status.py Show summary of all tasks (default) + python3 status.py -a <assignee> Filter tasks by assignee + python3 status.py --list List all worktrees and agents + python3 status.py --detail <task> Detailed task status + python3 status.py --watch <task> Watch agent log in real-time + python3 status.py --log <task> Show recent log entries + python3 status.py --registry Show agent registry +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import time +from datetime import datetime +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from common.cli_adapter import get_cli_adapter +from common.developer import ensure_developer +from common.paths import ( + FILE_TASK_JSON, + get_repo_root, + get_tasks_dir, +) +from common.phase import get_phase_info +from common.task_queue import format_task_stats, get_task_stats +from common.worktree import get_agents_dir + +# ============================================================================= +# Colors +# ============================================================================= + + +class Colors: + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + BLUE = "\033[0;34m" + CYAN = "\033[0;36m" + DIM = "\033[2m" + NC = "\033[0m" + + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def _read_json_file(path: Path) -> dict | None: + """Read and parse a JSON file.""" + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +def is_running(pid: int | str | None) -> bool: + """Check if PID is running.""" + if not pid: + return False + try: + pid_int = int(pid) + os.kill(pid_int, 0) + return True + except (ProcessLookupError, ValueError, PermissionError, TypeError): + return False + + +def status_color(status: str) -> str: + """Get status color.""" + colors = { + "completed": Colors.GREEN, + "in_progress": Colors.BLUE, + "planning": Colors.YELLOW, + } + return colors.get(status, Colors.DIM) + + +def get_registry_file(repo_root: Path) -> Path | None: + """Get registry file path.""" + agents_dir = get_agents_dir(repo_root) + if agents_dir: + return agents_dir / "registry.json" + return None + + +def find_agent(search: str, repo_root: Path) -> dict | None: + """Find agent by task name or ID.""" + registry_file = get_registry_file(repo_root) + if not registry_file or not registry_file.is_file(): + return None + + data = _read_json_file(registry_file) + if not data: + return None + + for agent in data.get("agents", []): + # Exact ID match + if agent.get("id") == search: + return agent + # Partial match on task_dir + task_dir = agent.get("task_dir", "") + if search in task_dir: + return agent + + return None + + +def calc_elapsed(started: str | None) -> str: + """Calculate elapsed time from ISO timestamp.""" + if not started: + return "N/A" + + try: + # Parse ISO format + if "+" in started: + started = started.split("+")[0] + if "T" in started: + start_dt = datetime.fromisoformat(started) + else: + return "N/A" + + now = datetime.now() + elapsed = (now - start_dt).total_seconds() + + if elapsed < 60: + return f"{int(elapsed)}s" + elif elapsed < 3600: + mins = int(elapsed // 60) + secs = int(elapsed % 60) + return f"{mins}m {secs}s" + else: + hours = int(elapsed // 3600) + mins = int((elapsed % 3600) // 60) + return f"{hours}h {mins}m" + except (ValueError, TypeError): + return "N/A" + + +def count_modified_files(worktree: str) -> int: + """Count modified files in worktree.""" + if not Path(worktree).is_dir(): + return 0 + + try: + result = subprocess.run( + ["git", "status", "--short"], + cwd=worktree, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + return len([line for line in result.stdout.splitlines() if line.strip()]) + except Exception: + return 0 + + +def tail_follow(file_path: Path) -> None: + """Follow a file like 'tail -f', cross-platform compatible.""" + with open(file_path, "r", encoding="utf-8", errors="replace") as f: + # Seek to end of file + f.seek(0, 2) + + while True: + line = f.readline() + if line: + print(line, end="", flush=True) + else: + time.sleep(0.1) + + +def get_last_tool(log_file: Path, platform: str = "claude") -> str | None: + """Get the last tool call from agent log. + + Supports both Claude Code and OpenCode log formats. + + Claude Code format: + {"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Read"}]}} + + OpenCode format: + {"type": "tool_use", "tool": "bash", "state": {"status": "completed"}} + """ + if not log_file.is_file(): + return None + + try: + lines = log_file.read_text(encoding="utf-8").splitlines() + for line in reversed(lines[-100:]): + try: + data = json.loads(line) + + if platform == "opencode": + # OpenCode format: {"type": "tool_use", "tool": "bash", ...} + if data.get("type") == "tool_use": + return data.get("tool") + else: + # Claude Code format: {"type": "assistant", "message": {"content": [...]}} + if data.get("type") == "assistant": + content = data.get("message", {}).get("content", []) + for item in content: + if item.get("type") == "tool_use": + return item.get("name") + except json.JSONDecodeError: + continue + except Exception: + pass + return None + + +def get_last_message(log_file: Path, max_len: int = 100, platform: str = "claude") -> str | None: + """Get the last assistant text from agent log. + + Supports both Claude Code and OpenCode log formats. + + Claude Code format: + {"type": "assistant", "message": {"content": [{"type": "text", "text": "..."}]}} + + OpenCode format: + {"type": "text", "text": "..."} + """ + if not log_file.is_file(): + return None + + try: + lines = log_file.read_text(encoding="utf-8").splitlines() + for line in reversed(lines[-100:]): + try: + data = json.loads(line) + + if platform == "opencode": + # OpenCode format: {"type": "text", "text": "..."} + if data.get("type") == "text": + text = data.get("text", "") + if text: + return text[:max_len] + else: + # Claude Code format: {"type": "assistant", "message": {"content": [...]}} + if data.get("type") == "assistant": + content = data.get("message", {}).get("content", []) + for item in content: + if item.get("type") == "text": + text = item.get("text", "") + if text: + return text[:max_len] + except json.JSONDecodeError: + continue + except Exception: + pass + return None + + +# ============================================================================= +# Commands +# ============================================================================= + + +def cmd_help() -> int: + """Show help.""" + print("""Multi-Agent Pipeline: Status Monitor + +Usage: + python3 status.py Show summary of all tasks + python3 status.py -a <assignee> Filter tasks by assignee + python3 status.py --list List all worktrees and agents + python3 status.py --detail <task> Detailed task status + python3 status.py --progress <task> Quick progress view with recent activity + python3 status.py --watch <task> Watch agent log in real-time + python3 status.py --log <task> Show recent log entries + python3 status.py --registry Show agent registry + +Examples: + python3 status.py -a taosu + python3 status.py --detail my-task + python3 status.py --progress my-task + python3 status.py --watch 01-16-worktree-support + python3 status.py --log worktree-support +""") + return 0 + + +def cmd_list(repo_root: Path) -> int: + """List worktrees and agents.""" + print(f"{Colors.BLUE}=== Git Worktrees ==={Colors.NC}") + print() + + subprocess.run(["git", "worktree", "list"], cwd=repo_root) + print() + + print(f"{Colors.BLUE}=== Registered Agents ==={Colors.NC}") + print() + + registry_file = get_registry_file(repo_root) + if not registry_file or not registry_file.is_file(): + print(" (no registry found)") + return 0 + + data = _read_json_file(registry_file) + if not data or not data.get("agents"): + print(" (no agents registered)") + return 0 + + for agent in data["agents"]: + agent_id = agent.get("id", "?") + pid = agent.get("pid") + wt = agent.get("worktree_path", "?") + started = agent.get("started_at", "?") + + if is_running(pid): + status_icon = f"{Colors.GREEN}●{Colors.NC}" + else: + status_icon = f"{Colors.RED}○{Colors.NC}" + + print(f" {status_icon} {agent_id} (PID: {pid})") + print(f" {Colors.DIM}Worktree: {wt}{Colors.NC}") + print(f" {Colors.DIM}Started: {started}{Colors.NC}") + print() + + return 0 + + +def cmd_summary(repo_root: Path, filter_assignee: str | None = None) -> int: + """Show summary of all tasks.""" + ensure_developer(repo_root) + + tasks_dir = get_tasks_dir(repo_root) + if not tasks_dir.is_dir(): + print("No tasks directory found") + return 0 + + registry_file = get_registry_file(repo_root) + + # Count running agents + running_count = 0 + total_agents = 0 + + if registry_file and registry_file.is_file(): + data = _read_json_file(registry_file) + if data: + agents = data.get("agents", []) + total_agents = len(agents) + for agent in agents: + if is_running(agent.get("pid")): + running_count += 1 + + # Task queue stats + task_stats = get_task_stats(repo_root) + + print(f"{Colors.BLUE}=== Multi-Agent Status ==={Colors.NC}") + print( + f" Agents: {Colors.GREEN}{running_count}{Colors.NC} running / {total_agents} registered" + ) + print(f" Tasks: {format_task_stats(task_stats)}") + print() + + # Process tasks + running_tasks = [] + stopped_tasks = [] + regular_tasks = [] + + registry_data = ( + _read_json_file(registry_file) + if registry_file and registry_file.is_file() + else None + ) + + for d in sorted(tasks_dir.iterdir()): + if not d.is_dir() or d.name == "archive": + continue + + name = d.name + task_json = d / FILE_TASK_JSON + status = "unknown" + assignee = "unassigned" + priority = "P2" + + if task_json.is_file(): + data = _read_json_file(task_json) + if data: + status = data.get("status", "unknown") + assignee = data.get("assignee", "unassigned") + priority = data.get("priority", "P2") + + # Filter by assignee + if filter_assignee and assignee != filter_assignee: + continue + + # Check agent status + agent_info = None + if registry_data: + for agent in registry_data.get("agents", []): + if name in agent.get("task_dir", ""): + agent_info = agent + break + + if agent_info: + pid = agent_info.get("pid") + worktree = agent_info.get("worktree_path", "") + started = agent_info.get("started_at") + agent_platform = agent_info.get("platform", "claude") + + if is_running(pid): + # Running agent + task_dir_rel = agent_info.get("task_dir", "") + worktree_task_json = Path(worktree) / task_dir_rel / "task.json" + phase_source = task_json + if worktree_task_json.is_file(): + phase_source = worktree_task_json + + phase_info_str = get_phase_info(phase_source) + elapsed = calc_elapsed(started) + modified = count_modified_files(worktree) + + worktree_data = _read_json_file(phase_source) + branch = worktree_data.get("branch", "N/A") if worktree_data else "N/A" + + log_file = Path(worktree) / ".agent-log" + last_tool = get_last_tool(log_file, platform=agent_platform) + + running_tasks.append( + { + "name": name, + "priority": priority, + "assignee": assignee, + "phase_info": phase_info_str, + "elapsed": elapsed, + "branch": branch, + "modified": modified, + "last_tool": last_tool, + "pid": pid, + } + ) + else: + # Stopped agent + task_dir_rel = agent_info.get("task_dir", "") + worktree_task_json = Path(worktree) / task_dir_rel / "task.json" + worktree_status = "unknown" + + if worktree_task_json.is_file(): + wt_data = _read_json_file(worktree_task_json) + if wt_data: + worktree_status = wt_data.get("status", "unknown") + + session_id_file = Path(worktree) / ".session-id" + log_file = Path(worktree) / ".agent-log" + + stopped_tasks.append( + { + "name": name, + "worktree": worktree, + "status": worktree_status, + "session_id_file": session_id_file, + "log_file": log_file, + "platform": agent_info.get("platform", "claude"), + } + ) + else: + # Regular task + regular_tasks.append( + { + "name": name, + "status": status, + "priority": priority, + "assignee": assignee, + } + ) + + # Output running agents + if running_tasks: + print(f"{Colors.CYAN}Running Agents:{Colors.NC}") + for t in running_tasks: + priority_color = ( + Colors.RED + if t["priority"] == "P0" + else (Colors.YELLOW if t["priority"] == "P1" else Colors.BLUE) + ) + print( + f"{Colors.GREEN}▶{Colors.NC} {Colors.CYAN}{t['name']}{Colors.NC} {Colors.GREEN}[running]{Colors.NC} {priority_color}[{t['priority']}]{Colors.NC} @{t['assignee']}" + ) + print(f" Phase: {t['phase_info']}") + print(f" Elapsed: {t['elapsed']}") + print(f" Branch: {Colors.DIM}{t['branch']}{Colors.NC}") + print(f" Modified: {t['modified']} file(s)") + if t["last_tool"]: + print(f" Activity: {Colors.YELLOW}{t['last_tool']}{Colors.NC}") + print(f" PID: {Colors.DIM}{t['pid']}{Colors.NC}") + print() + + # Output stopped agents + if stopped_tasks: + print(f"{Colors.RED}Stopped Agents:{Colors.NC}") + for t in stopped_tasks: + if t["status"] == "completed": + print( + f"{Colors.GREEN}✓{Colors.NC} {t['name']} {Colors.GREEN}[completed]{Colors.NC}" + ) + else: + if t["session_id_file"].is_file(): + session_id = ( + t["session_id_file"].read_text(encoding="utf-8").strip() + ) + last_msg = get_last_message(t["log_file"], 150, platform=t.get("platform", "claude")) + print( + f"{Colors.RED}○{Colors.NC} {t['name']} {Colors.RED}[stopped]{Colors.NC}" + ) + if last_msg: + print(f'{Colors.DIM}"{last_msg}"{Colors.NC}') + # Use CLI adapter for platform-specific resume command + adapter = get_cli_adapter(t.get("platform", "claude")) + resume_cmd = adapter.get_resume_command_str(session_id, cwd=t["worktree"]) + print(f"{Colors.YELLOW}{resume_cmd}{Colors.NC}") + else: + print( + f"{Colors.RED}○{Colors.NC} {t['name']} {Colors.RED}[stopped]{Colors.NC} {Colors.DIM}(no session-id){Colors.NC}" + ) + print() + + # Separator + if (running_tasks or stopped_tasks) and regular_tasks: + print(f"{Colors.DIM}───────────────────────────────────────{Colors.NC}") + print() + + # Output regular tasks grouped by assignee + if regular_tasks: + # Sort by assignee, priority, status + regular_tasks.sort( + key=lambda x: ( + x["assignee"], + {"P0": 0, "P1": 1, "P2": 2, "P3": 3}.get(x["priority"], 2), + {"in_progress": 0, "planning": 1, "completed": 2}.get(x["status"], 1), + ) + ) + + current_assignee = None + for t in regular_tasks: + if t["assignee"] != current_assignee: + if current_assignee is not None: + print() + print(f"{Colors.CYAN}@{t['assignee']}:{Colors.NC}") + current_assignee = t["assignee"] + + color = status_color(t["status"]) + priority_color = ( + Colors.RED + if t["priority"] == "P0" + else (Colors.YELLOW if t["priority"] == "P1" else Colors.BLUE) + ) + print( + f" {color}●{Colors.NC} {t['name']} ({t['status']}) {priority_color}[{t['priority']}]{Colors.NC}" + ) + + if running_tasks: + print() + print(f"{Colors.DIM}─────────────────────────────────────{Colors.NC}") + print(f"{Colors.DIM}Use --progress <name> for quick activity view{Colors.NC}") + print(f"{Colors.DIM}Use --detail <name> for more info{Colors.NC}") + + print() + return 0 + + +def cmd_detail(target: str, repo_root: Path) -> int: + """Show detailed task status.""" + agent = find_agent(target, repo_root) + if not agent: + print(f"Agent not found: {target}") + return 1 + + agent_id = agent.get("id", "?") + pid = agent.get("pid") + worktree = agent.get("worktree_path", "?") + task_dir = agent.get("task_dir", "?") + started = agent.get("started_at", "?") + platform = agent.get("platform", "claude") + + # Check for session-id + session_id = "" + session_id_file = Path(worktree) / ".session-id" + if session_id_file.is_file(): + session_id = session_id_file.read_text(encoding="utf-8").strip() + + print(f"{Colors.BLUE}=== Agent Detail: {agent_id} ==={Colors.NC}") + print() + print(f" ID: {agent_id}") + print(f" PID: {pid}") + print(f" Session: {session_id or 'N/A'}") + print(f" Worktree: {worktree}") + print(f" Task Dir: {task_dir}") + print(f" Started: {started}") + print() + + # Status + if is_running(pid): + print(f" Status: {Colors.GREEN}Running{Colors.NC}") + else: + print(f" Status: {Colors.RED}Stopped{Colors.NC}") + if session_id: + print() + # Use CLI adapter for platform-specific resume command + adapter = get_cli_adapter(platform) + resume_cmd = adapter.get_resume_command_str(session_id, cwd=worktree) + print(f" {Colors.YELLOW}Resume:{Colors.NC} {resume_cmd}") + + # Task info + task_json = repo_root / task_dir / "task.json" + if task_json.is_file(): + print() + print(f"{Colors.BLUE}=== Task Info ==={Colors.NC}") + print() + data = _read_json_file(task_json) + if data: + print(f" Status: {data.get('status', 'unknown')}") + print(f" Branch: {data.get('branch', 'N/A')}") + print(f" Base Branch: {data.get('base_branch', 'N/A')}") + + # Git changes + if Path(worktree).is_dir(): + print() + print(f"{Colors.BLUE}=== Git Changes ==={Colors.NC}") + print() + + result = subprocess.run( + ["git", "status", "--short"], + cwd=worktree, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + changes = result.stdout.strip() + if changes: + for line in changes.splitlines()[:10]: + print(f" {line}") + total = len(changes.splitlines()) + if total > 10: + print(f" ... and {total - 10} more") + else: + print(" (no changes)") + + print() + return 0 + + +def cmd_watch(target: str, repo_root: Path) -> int: + """Watch agent log in real-time.""" + agent = find_agent(target, repo_root) + if not agent: + print(f"Agent not found: {target}") + return 1 + + worktree = agent.get("worktree_path", "") + log_file = Path(worktree) / ".agent-log" + + if not log_file.is_file(): + print(f"Log file not found: {log_file}") + return 1 + + print(f"{Colors.BLUE}Watching:{Colors.NC} {log_file}") + print(f"{Colors.DIM}Press Ctrl+C to stop{Colors.NC}") + print() + + try: + tail_follow(log_file) + except KeyboardInterrupt: + print() # Clean newline after Ctrl+C + return 0 + + +def cmd_log(target: str, repo_root: Path) -> int: + """Show recent log entries.""" + agent = find_agent(target, repo_root) + if not agent: + print(f"Agent not found: {target}") + return 1 + + worktree = agent.get("worktree_path", "") + platform = agent.get("platform", "claude") + log_file = Path(worktree) / ".agent-log" + + if not log_file.is_file(): + print(f"Log file not found: {log_file}") + return 1 + + print(f"{Colors.BLUE}=== Recent Log: {target} ==={Colors.NC}") + print(f"{Colors.DIM}Platform: {platform}{Colors.NC}") + print() + + lines = log_file.read_text(encoding="utf-8").splitlines() + for line in lines[-50:]: + try: + data = json.loads(line) + msg_type = data.get("type", "") + + if platform == "opencode": + # OpenCode format + if msg_type == "text": + text = data.get("text", "") + if text: + display = text[:300] + if len(text) > 300: + display += "..." + print(f"{Colors.BLUE}[TEXT]{Colors.NC} {display}") + elif msg_type == "tool_use": + tool_name = data.get("tool", "unknown") + status = data.get("state", {}).get("status", "") + print(f"{Colors.YELLOW}[TOOL]{Colors.NC} {tool_name} ({status})") + elif msg_type == "step_start": + print(f"{Colors.CYAN}[STEP]{Colors.NC} Start") + elif msg_type == "step_finish": + reason = data.get("reason", "") + print(f"{Colors.CYAN}[STEP]{Colors.NC} Finish ({reason})") + elif msg_type == "error": + error_msg = data.get("message", "") + print(f"{Colors.RED}[ERROR]{Colors.NC} {error_msg}") + else: + # Claude Code format + if msg_type == "system": + subtype = data.get("subtype", "") + print(f"{Colors.CYAN}[SYSTEM]{Colors.NC} {subtype}") + elif msg_type == "user": + content = data.get("message", {}).get("content", "") + if content: + print(f"{Colors.GREEN}[USER]{Colors.NC} {content[:200]}") + elif msg_type == "assistant": + content = data.get("message", {}).get("content", []) + if content: + item = content[0] + text = item.get("text") + tool = item.get("name") + if text: + display = text[:300] + if len(text) > 300: + display += "..." + print(f"{Colors.BLUE}[ASSISTANT]{Colors.NC} {display}") + elif tool: + print(f"{Colors.YELLOW}[TOOL]{Colors.NC} {tool}") + elif msg_type == "result": + tool_name = data.get("tool", "unknown") + print(f"{Colors.DIM}[RESULT]{Colors.NC} {tool_name} completed") + except json.JSONDecodeError: + continue + + return 0 + + +def cmd_registry(repo_root: Path) -> int: + """Show agent registry.""" + registry_file = get_registry_file(repo_root) + + print(f"{Colors.BLUE}=== Agent Registry ==={Colors.NC}") + print() + print(f"File: {registry_file}") + print() + + if registry_file and registry_file.is_file(): + data = _read_json_file(registry_file) + if data: + print(json.dumps(data, indent=2)) + else: + print("(registry not found)") + + return 0 + + +# ============================================================================= +# Main +# ============================================================================= + + +def main() -> int: + """Main entry point.""" + parser = argparse.ArgumentParser(description="Multi-Agent Pipeline: Status Monitor") + parser.add_argument("-a", "--assignee", help="Filter by assignee") + parser.add_argument( + "--list", action="store_true", help="List all worktrees and agents" + ) + parser.add_argument("--detail", metavar="TASK", help="Detailed task status") + parser.add_argument("--progress", metavar="TASK", help="Quick progress view") + parser.add_argument("--watch", metavar="TASK", help="Watch agent log") + parser.add_argument("--log", metavar="TASK", help="Show recent log entries") + parser.add_argument("--registry", action="store_true", help="Show agent registry") + parser.add_argument("target", nargs="?", help="Target task") + + args = parser.parse_args() + repo_root = get_repo_root() + + if args.list: + return cmd_list(repo_root) + elif args.detail: + return cmd_detail(args.detail, repo_root) + elif args.progress: + return cmd_detail(args.progress, repo_root) # Similar to detail + elif args.watch: + return cmd_watch(args.watch, repo_root) + elif args.log: + return cmd_log(args.log, repo_root) + elif args.registry: + return cmd_registry(repo_root) + elif args.target: + return cmd_detail(args.target, repo_root) + else: + return cmd_summary(repo_root, args.assignee) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.trellis/scripts/task.py b/.trellis/scripts/task.py new file mode 100755 index 00000000..29f614ca --- /dev/null +++ b/.trellis/scripts/task.py @@ -0,0 +1,1370 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Task Management Script for Multi-Agent Pipeline. + +Usage: + python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] + python3 task.py init-context <dir> <type> # Initialize jsonl files + python3 task.py add-context <dir> <file> <path> [reason] # Add jsonl entry + python3 task.py validate <dir> # Validate jsonl files + python3 task.py list-context <dir> # List jsonl entries + python3 task.py start <dir> # Set as current task + python3 task.py finish # Clear current task + python3 task.py set-branch <dir> <branch> # Set git branch + python3 task.py set-base-branch <dir> <branch> # Set PR target branch + python3 task.py set-scope <dir> <scope> # Set scope for PR title + python3 task.py create-pr [dir] [--dry-run] # Create PR from task + python3 task.py archive <task-name> # Archive completed task + python3 task.py list # List active tasks + python3 task.py list-archive [month] # List archived tasks + python3 task.py add-subtask <parent-dir> <child-dir> # Link child to parent + python3 task.py remove-subtask <parent-dir> <child-dir> # Unlink child from parent +""" + +from __future__ import annotations + +import sys + +# IMPORTANT: Force stdout to use UTF-8 on Windows +# This fixes UnicodeEncodeError when outputting non-ASCII characters +if sys.platform == "win32": + import io as _io + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + elif hasattr(sys.stdout, "detach"): + sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr] + +import argparse +import json +import re +import sys +from datetime import datetime +from pathlib import Path + +from common.cli_adapter import get_cli_adapter_auto +from common.git_context import _run_git_command +from common.paths import ( + DIR_WORKFLOW, + DIR_TASKS, + DIR_SPEC, + DIR_ARCHIVE, + FILE_TASK_JSON, + get_repo_root, + get_developer, + get_tasks_dir, + get_current_task, + set_current_task, + clear_current_task, + generate_task_date_prefix, +) +from common.task_utils import ( + find_task_by_name, + archive_task_complete, +) +from common.config import get_hooks + + +# ============================================================================= +# Colors +# ============================================================================= + +class Colors: + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + BLUE = "\033[0;34m" + CYAN = "\033[0;36m" + NC = "\033[0m" + + +def colored(text: str, color: str) -> str: + """Apply color to text.""" + return f"{color}{text}{Colors.NC}" + + +# ============================================================================= +# Lifecycle Hooks +# ============================================================================= + +def _run_hooks(event: str, task_json_path: Path, repo_root: Path) -> None: + """Run lifecycle hooks for an event. + + Args: + event: Event name (e.g. "after_create"). + task_json_path: Absolute path to the task's task.json. + repo_root: Repository root for cwd and config lookup. + """ + import os + import subprocess + + commands = get_hooks(event, repo_root) + if not commands: + return + + env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)} + + for cmd in commands: + try: + result = subprocess.run( + cmd, + shell=True, + cwd=repo_root, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if result.returncode != 0: + print( + colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW), + file=sys.stderr, + ) + if result.stderr.strip(): + print(f" {result.stderr.strip()}", file=sys.stderr) + except Exception as e: + print( + colored(f"[WARN] Hook error ({event}): {cmd} — {e}", Colors.YELLOW), + file=sys.stderr, + ) + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def _read_json_file(path: Path) -> dict | None: + """Read and parse a JSON file.""" + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +def _write_json_file(path: Path, data: dict) -> bool: + """Write dict to JSON file.""" + try: + path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + return True + except (OSError, IOError): + return False + + +def _slugify(title: str) -> str: + """Convert title to slug (only works with ASCII).""" + result = title.lower() + result = re.sub(r"[^a-z0-9]", "-", result) + result = re.sub(r"-+", "-", result) + result = result.strip("-") + return result + + +def _resolve_task_dir(target_dir: str, repo_root: Path) -> Path: + """Resolve task directory to absolute path. + + Supports: + - Absolute path: /path/to/task + - Relative path: .trellis/tasks/01-31-my-task + - Task name: my-task (uses find_task_by_name for lookup) + """ + if not target_dir: + return Path() + + # Absolute path + if target_dir.startswith("/"): + return Path(target_dir) + + # Relative path (contains path separator or starts with .trellis) + if "/" in target_dir or target_dir.startswith(".trellis"): + return repo_root / target_dir + + # Task name - try to find in tasks directory + tasks_dir = get_tasks_dir(repo_root) + found = find_task_by_name(target_dir, tasks_dir) + if found: + return found + + # Fallback to treating as relative path + return repo_root / target_dir + + +# ============================================================================= +# JSONL Default Content Generators +# ============================================================================= + +def get_implement_base() -> list[dict]: + """Get base implement context entries.""" + return [ + {"file": f"{DIR_WORKFLOW}/workflow.md", "reason": "Project workflow and conventions"}, + ] + + +def get_implement_backend() -> list[dict]: + """Get backend implement context entries.""" + return [ + {"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/backend/index.md", "reason": "Backend development guide"}, + ] + + +def get_implement_frontend() -> list[dict]: + """Get frontend implement context entries.""" + return [ + {"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/frontend/index.md", "reason": "Frontend development guide"}, + ] + + +def get_check_context(dev_type: str, repo_root: Path) -> list[dict]: + """Get check context entries.""" + adapter = get_cli_adapter_auto(repo_root) + + entries = [ + {"file": adapter.get_trellis_command_path("finish-work"), "reason": "Finish work checklist"}, + ] + + if dev_type in ("backend", "fullstack"): + entries.append({"file": adapter.get_trellis_command_path("check-backend"), "reason": "Backend check spec"}) + if dev_type in ("frontend", "fullstack"): + entries.append({"file": adapter.get_trellis_command_path("check-frontend"), "reason": "Frontend check spec"}) + + return entries + + +def get_debug_context(dev_type: str, repo_root: Path) -> list[dict]: + """Get debug context entries.""" + adapter = get_cli_adapter_auto(repo_root) + + entries: list[dict] = [] + + if dev_type in ("backend", "fullstack"): + entries.append({"file": adapter.get_trellis_command_path("check-backend"), "reason": "Backend check spec"}) + if dev_type in ("frontend", "fullstack"): + entries.append({"file": adapter.get_trellis_command_path("check-frontend"), "reason": "Frontend check spec"}) + + return entries + + +def _write_jsonl(path: Path, entries: list[dict]) -> None: + """Write entries to JSONL file.""" + lines = [json.dumps(entry, ensure_ascii=False) for entry in entries] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +# ============================================================================= +# Task Operations +# ============================================================================= + +def ensure_tasks_dir(repo_root: Path) -> Path: + """Ensure tasks directory exists.""" + tasks_dir = get_tasks_dir(repo_root) + archive_dir = tasks_dir / "archive" + + if not tasks_dir.exists(): + tasks_dir.mkdir(parents=True) + print(colored(f"Created tasks directory: {tasks_dir}", Colors.GREEN), file=sys.stderr) + + if not archive_dir.exists(): + archive_dir.mkdir(parents=True) + + return tasks_dir + + +# ============================================================================= +# Command: create +# ============================================================================= + +def cmd_create(args: argparse.Namespace) -> int: + """Create a new task.""" + repo_root = get_repo_root() + + if not args.title: + print(colored("Error: title is required", Colors.RED), file=sys.stderr) + return 1 + + # Default assignee to current developer + assignee = args.assignee + if not assignee: + assignee = get_developer(repo_root) + if not assignee: + print(colored("Error: No developer set. Run init_developer.py first or use --assignee", Colors.RED), file=sys.stderr) + return 1 + + ensure_tasks_dir(repo_root) + + # Get current developer as creator + creator = get_developer(repo_root) or assignee + + # Generate slug if not provided + slug = args.slug or _slugify(args.title) + if not slug: + print(colored("Error: could not generate slug from title", Colors.RED), file=sys.stderr) + return 1 + + # Create task directory with MM-DD-slug format + tasks_dir = get_tasks_dir(repo_root) + date_prefix = generate_task_date_prefix() + dir_name = f"{date_prefix}-{slug}" + task_dir = tasks_dir / dir_name + task_json_path = task_dir / FILE_TASK_JSON + + if task_dir.exists(): + print(colored(f"Warning: Task directory already exists: {dir_name}", Colors.YELLOW), file=sys.stderr) + else: + task_dir.mkdir(parents=True) + + today = datetime.now().strftime("%Y-%m-%d") + + # Record current branch as base_branch (PR target) + _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) + current_branch = branch_out.strip() or "main" + + task_data = { + "id": slug, + "name": slug, + "title": args.title, + "description": args.description or "", + "status": "planning", + "dev_type": None, + "scope": None, + "priority": args.priority, + "creator": creator, + "assignee": assignee, + "createdAt": today, + "completedAt": None, + "branch": None, + "base_branch": current_branch, + "worktree_path": None, + "current_phase": 0, + "next_action": [ + {"phase": 1, "action": "implement"}, + {"phase": 2, "action": "check"}, + {"phase": 3, "action": "finish"}, + {"phase": 4, "action": "create-pr"}, + ], + "commit": None, + "pr_url": None, + "subtasks": [], + "children": [], + "parent": None, + "relatedFiles": [], + "notes": "", + "meta": {}, + } + + _write_json_file(task_json_path, task_data) + + # Handle --parent: establish bidirectional link + if args.parent: + parent_dir = _resolve_task_dir(args.parent, repo_root) + parent_json_path = parent_dir / FILE_TASK_JSON + if not parent_json_path.is_file(): + print(colored(f"Warning: Parent task.json not found: {args.parent}", Colors.YELLOW), file=sys.stderr) + else: + parent_data = _read_json_file(parent_json_path) + if parent_data: + # Add child to parent's children list + parent_children = parent_data.get("children", []) + if dir_name not in parent_children: + parent_children.append(dir_name) + parent_data["children"] = parent_children + _write_json_file(parent_json_path, parent_data) + + # Set parent in child's task.json + task_data["parent"] = parent_dir.name + _write_json_file(task_json_path, task_data) + + print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr) + + print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr) + print("", file=sys.stderr) + print(colored("Next steps:", Colors.BLUE), file=sys.stderr) + print(" 1. Create prd.md with requirements", file=sys.stderr) + print(" 2. Run: python3 task.py init-context <dir> <dev_type>", file=sys.stderr) + print(" 3. Run: python3 task.py start <dir>", file=sys.stderr) + print("", file=sys.stderr) + + # Output relative path for script chaining + print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}") + + _run_hooks("after_create", task_json_path, repo_root) + return 0 + + +# ============================================================================= +# Command: init-context +# ============================================================================= + +def cmd_init_context(args: argparse.Namespace) -> int: + """Initialize JSONL context files for a task.""" + repo_root = get_repo_root() + target_dir = _resolve_task_dir(args.dir, repo_root) + dev_type = args.type + + if not dev_type: + print(colored("Error: Missing arguments", Colors.RED)) + print("Usage: python3 task.py init-context <task-dir> <dev_type>") + print(" dev_type: backend | frontend | fullstack | test | docs") + return 1 + + if not target_dir.is_dir(): + print(colored(f"Error: Directory not found: {target_dir}", Colors.RED)) + return 1 + + print(colored("=== Initializing Agent Context Files ===", Colors.BLUE)) + print(f"Target dir: {target_dir}") + print(f"Dev type: {dev_type}") + print() + + # implement.jsonl + print(colored("Creating implement.jsonl...", Colors.CYAN)) + implement_entries = get_implement_base() + if dev_type in ("backend", "test"): + implement_entries.extend(get_implement_backend()) + elif dev_type == "frontend": + implement_entries.extend(get_implement_frontend()) + elif dev_type == "fullstack": + implement_entries.extend(get_implement_backend()) + implement_entries.extend(get_implement_frontend()) + + implement_file = target_dir / "implement.jsonl" + _write_jsonl(implement_file, implement_entries) + print(f" {colored('✓', Colors.GREEN)} {len(implement_entries)} entries") + + # check.jsonl + print(colored("Creating check.jsonl...", Colors.CYAN)) + check_entries = get_check_context(dev_type, repo_root) + check_file = target_dir / "check.jsonl" + _write_jsonl(check_file, check_entries) + print(f" {colored('✓', Colors.GREEN)} {len(check_entries)} entries") + + # debug.jsonl + print(colored("Creating debug.jsonl...", Colors.CYAN)) + debug_entries = get_debug_context(dev_type, repo_root) + debug_file = target_dir / "debug.jsonl" + _write_jsonl(debug_file, debug_entries) + print(f" {colored('✓', Colors.GREEN)} {len(debug_entries)} entries") + + print() + print(colored("✓ All context files created", Colors.GREEN)) + print() + print(colored("Next steps:", Colors.BLUE)) + print(" 1. Add task-specific specs: python3 task.py add-context <dir> <jsonl> <path>") + print(" 2. Set as current: python3 task.py start <dir>") + + return 0 + + +# ============================================================================= +# Command: add-context +# ============================================================================= + +def cmd_add_context(args: argparse.Namespace) -> int: + """Add entry to JSONL context file.""" + repo_root = get_repo_root() + target_dir = _resolve_task_dir(args.dir, repo_root) + + jsonl_name = args.file + path = args.path + reason = args.reason or "Added manually" + + if not target_dir.is_dir(): + print(colored(f"Error: Directory not found: {target_dir}", Colors.RED)) + return 1 + + # Support shorthand + if not jsonl_name.endswith(".jsonl"): + jsonl_name = f"{jsonl_name}.jsonl" + + jsonl_file = target_dir / jsonl_name + full_path = repo_root / path + + entry_type = "file" + if full_path.is_dir(): + entry_type = "directory" + if not path.endswith("/"): + path = f"{path}/" + elif not full_path.is_file(): + print(colored(f"Error: Path not found: {path}", Colors.RED)) + return 1 + + # Check if already exists + if jsonl_file.is_file(): + content = jsonl_file.read_text(encoding="utf-8") + if f'"{path}"' in content: + print(colored(f"Warning: Entry already exists for {path}", Colors.YELLOW)) + return 0 + + # Add entry + entry: dict + if entry_type == "directory": + entry = {"file": path, "type": "directory", "reason": reason} + else: + entry = {"file": path, "reason": reason} + + with jsonl_file.open("a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + + print(colored(f"Added {entry_type}: {path}", Colors.GREEN)) + return 0 + + +# ============================================================================= +# Command: validate +# ============================================================================= + +def cmd_validate(args: argparse.Namespace) -> int: + """Validate JSONL context files.""" + repo_root = get_repo_root() + target_dir = _resolve_task_dir(args.dir, repo_root) + + if not target_dir.is_dir(): + print(colored("Error: task directory required", Colors.RED)) + return 1 + + print(colored("=== Validating Context Files ===", Colors.BLUE)) + print(f"Target dir: {target_dir}") + print() + + total_errors = 0 + for jsonl_name in ["implement.jsonl", "check.jsonl", "debug.jsonl"]: + jsonl_file = target_dir / jsonl_name + errors = _validate_jsonl(jsonl_file, repo_root) + total_errors += errors + + print() + if total_errors == 0: + print(colored("✓ All validations passed", Colors.GREEN)) + return 0 + else: + print(colored(f"✗ Validation failed ({total_errors} errors)", Colors.RED)) + return 1 + + +def _validate_jsonl(jsonl_file: Path, repo_root: Path) -> int: + """Validate a single JSONL file.""" + file_name = jsonl_file.name + errors = 0 + + if not jsonl_file.is_file(): + print(f" {colored(f'{file_name}: not found (skipped)', Colors.YELLOW)}") + return 0 + + line_num = 0 + for line in jsonl_file.read_text(encoding="utf-8").splitlines(): + line_num += 1 + if not line.strip(): + continue + + try: + data = json.loads(line) + except json.JSONDecodeError: + print(f" {colored(f'{file_name}:{line_num}: Invalid JSON', Colors.RED)}") + errors += 1 + continue + + file_path = data.get("file") + entry_type = data.get("type", "file") + + if not file_path: + print(f" {colored(f'{file_name}:{line_num}: Missing file field', Colors.RED)}") + errors += 1 + continue + + full_path = repo_root / file_path + if entry_type == "directory": + if not full_path.is_dir(): + print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}") + errors += 1 + else: + if not full_path.is_file(): + print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}") + errors += 1 + + if errors == 0: + print(f" {colored(f'{file_name}: ✓ ({line_num} entries)', Colors.GREEN)}") + else: + print(f" {colored(f'{file_name}: ✗ ({errors} errors)', Colors.RED)}") + + return errors + + +# ============================================================================= +# Command: list-context +# ============================================================================= + +def cmd_list_context(args: argparse.Namespace) -> int: + """List JSONL context entries.""" + repo_root = get_repo_root() + target_dir = _resolve_task_dir(args.dir, repo_root) + + if not target_dir.is_dir(): + print(colored("Error: task directory required", Colors.RED)) + return 1 + + print(colored("=== Context Files ===", Colors.BLUE)) + print() + + for jsonl_name in ["implement.jsonl", "check.jsonl", "debug.jsonl"]: + jsonl_file = target_dir / jsonl_name + if not jsonl_file.is_file(): + continue + + print(colored(f"[{jsonl_name}]", Colors.CYAN)) + + count = 0 + for line in jsonl_file.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + + try: + data = json.loads(line) + except json.JSONDecodeError: + continue + + count += 1 + file_path = data.get("file", "?") + entry_type = data.get("type", "file") + reason = data.get("reason", "-") + + if entry_type == "directory": + print(f" {colored(f'{count}.', Colors.GREEN)} [DIR] {file_path}") + else: + print(f" {colored(f'{count}.', Colors.GREEN)} {file_path}") + print(f" {colored('→', Colors.YELLOW)} {reason}") + + print() + + return 0 + + +# ============================================================================= +# Command: start / finish +# ============================================================================= + +def cmd_start(args: argparse.Namespace) -> int: + """Set current task.""" + repo_root = get_repo_root() + task_input = args.dir + + if not task_input: + print(colored("Error: task directory or name required", Colors.RED)) + return 1 + + # Resolve task directory (supports task name, relative path, or absolute path) + full_path = _resolve_task_dir(task_input, repo_root) + + if not full_path.is_dir(): + print(colored(f"Error: Task not found: {task_input}", Colors.RED)) + print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.trellis/tasks/01-31-my-task')") + return 1 + + # Convert to relative path for storage + try: + task_dir = str(full_path.relative_to(repo_root)) + except ValueError: + task_dir = str(full_path) + + if set_current_task(task_dir, repo_root): + print(colored(f"✓ Current task set to: {task_dir}", Colors.GREEN)) + print() + print(colored("The hook will now inject context from this task's jsonl files.", Colors.BLUE)) + + task_json_path = full_path / FILE_TASK_JSON + _run_hooks("after_start", task_json_path, repo_root) + return 0 + else: + print(colored("Error: Failed to set current task", Colors.RED)) + return 1 + + +def cmd_finish(args: argparse.Namespace) -> int: + """Clear current task.""" + repo_root = get_repo_root() + current = get_current_task(repo_root) + + if not current: + print(colored("No current task set", Colors.YELLOW)) + return 0 + + # Resolve task.json path before clearing + task_json_path = repo_root / current / FILE_TASK_JSON + + clear_current_task(repo_root) + print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN)) + + if task_json_path.is_file(): + _run_hooks("after_finish", task_json_path, repo_root) + return 0 + + +# ============================================================================= +# Command: archive +# ============================================================================= + +def cmd_archive(args: argparse.Namespace) -> int: + """Archive completed task.""" + repo_root = get_repo_root() + task_name = args.name + + if not task_name: + print(colored("Error: Task name is required", Colors.RED), file=sys.stderr) + return 1 + + tasks_dir = get_tasks_dir(repo_root) + + # Find task directory + task_dir = find_task_by_name(task_name, tasks_dir) + + if not task_dir or not task_dir.is_dir(): + print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr) + print("Active tasks:", file=sys.stderr) + cmd_list(argparse.Namespace(mine=False, status=None)) + return 1 + + dir_name = task_dir.name + task_json_path = task_dir / FILE_TASK_JSON + + # Update status before archiving + today = datetime.now().strftime("%Y-%m-%d") + if task_json_path.is_file(): + data = _read_json_file(task_json_path) + if data: + data["status"] = "completed" + data["completedAt"] = today + _write_json_file(task_json_path, data) + + # Handle subtask relationships on archive + task_parent = data.get("parent") + task_children = data.get("children", []) + + # If this is a child, remove from parent's children list + if task_parent: + parent_dir = find_task_by_name(task_parent, tasks_dir) + if parent_dir: + parent_json = parent_dir / FILE_TASK_JSON + if parent_json.is_file(): + parent_data = _read_json_file(parent_json) + if parent_data: + parent_children = parent_data.get("children", []) + if dir_name in parent_children: + parent_children.remove(dir_name) + parent_data["children"] = parent_children + _write_json_file(parent_json, parent_data) + + # If this is a parent, clear parent field in all children + if task_children: + for child_name in task_children: + child_dir_path = find_task_by_name(child_name, tasks_dir) + if child_dir_path: + child_json = child_dir_path / FILE_TASK_JSON + if child_json.is_file(): + child_data = _read_json_file(child_json) + if child_data: + child_data["parent"] = None + _write_json_file(child_json, child_data) + + # Clear if current task + current = get_current_task(repo_root) + if current and dir_name in current: + clear_current_task(repo_root) + + # Archive + result = archive_task_complete(task_dir, repo_root) + if "archived_to" in result: + archive_dest = Path(result["archived_to"]) + year_month = archive_dest.parent.name + print(colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN), file=sys.stderr) + + # Auto-commit unless --no-commit + if not getattr(args, "no_commit", False): + _auto_commit_archive(dir_name, repo_root) + + # Return the archive path + print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}") + + # Run hooks with the archived path + archived_json = archive_dest / FILE_TASK_JSON + _run_hooks("after_archive", archived_json, repo_root) + return 0 + + return 1 + + +def _auto_commit_archive(task_name: str, repo_root: Path) -> None: + """Stage .trellis/tasks/ changes and commit after archive.""" + tasks_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}" + _run_git_command(["add", "-A", tasks_rel], cwd=repo_root) + + # Check if there are staged changes + rc, _, _ = _run_git_command( + ["diff", "--cached", "--quiet", "--", tasks_rel], cwd=repo_root + ) + if rc == 0: + print("[OK] No task changes to commit.", file=sys.stderr) + return + + commit_msg = f"chore(task): archive {task_name}" + rc, _, err = _run_git_command(["commit", "-m", commit_msg], cwd=repo_root) + if rc == 0: + print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr) + else: + print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr) + + +# ============================================================================= +# Command: add-subtask +# ============================================================================= + +def cmd_add_subtask(args: argparse.Namespace) -> int: + """Link a child task to a parent task.""" + repo_root = get_repo_root() + + parent_dir = _resolve_task_dir(args.parent_dir, repo_root) + child_dir = _resolve_task_dir(args.child_dir, repo_root) + + parent_json_path = parent_dir / FILE_TASK_JSON + child_json_path = child_dir / FILE_TASK_JSON + + if not parent_json_path.is_file(): + print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) + return 1 + + if not child_json_path.is_file(): + print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) + return 1 + + parent_data = _read_json_file(parent_json_path) + child_data = _read_json_file(child_json_path) + + if not parent_data or not child_data: + print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) + return 1 + + # Check if child already has a parent + existing_parent = child_data.get("parent") + if existing_parent: + print(colored(f"Error: Child task already has a parent: {existing_parent}", Colors.RED), file=sys.stderr) + return 1 + + # Add child to parent's children list + parent_children = parent_data.get("children", []) + child_dir_name = child_dir.name + if child_dir_name not in parent_children: + parent_children.append(child_dir_name) + parent_data["children"] = parent_children + + # Set parent in child's task.json + child_data["parent"] = parent_dir.name + + # Write both + _write_json_file(parent_json_path, parent_data) + _write_json_file(child_json_path, child_data) + + print(colored(f"Linked: {child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr) + return 0 + + +# ============================================================================= +# Command: remove-subtask +# ============================================================================= + +def cmd_remove_subtask(args: argparse.Namespace) -> int: + """Unlink a child task from a parent task.""" + repo_root = get_repo_root() + + parent_dir = _resolve_task_dir(args.parent_dir, repo_root) + child_dir = _resolve_task_dir(args.child_dir, repo_root) + + parent_json_path = parent_dir / FILE_TASK_JSON + child_json_path = child_dir / FILE_TASK_JSON + + if not parent_json_path.is_file(): + print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) + return 1 + + if not child_json_path.is_file(): + print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) + return 1 + + parent_data = _read_json_file(parent_json_path) + child_data = _read_json_file(child_json_path) + + if not parent_data or not child_data: + print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) + return 1 + + # Remove child from parent's children list + parent_children = parent_data.get("children", []) + child_dir_name = child_dir.name + if child_dir_name in parent_children: + parent_children.remove(child_dir_name) + parent_data["children"] = parent_children + + # Clear parent in child's task.json + child_data["parent"] = None + + # Write both + _write_json_file(parent_json_path, parent_data) + _write_json_file(child_json_path, child_data) + + print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr) + return 0 + + +# ============================================================================= +# Command: list +# ============================================================================= + +def _get_children_progress(children: list[str], tasks_dir: Path) -> str: + """Get children progress summary like '[2/3 done]'.""" + if not children: + return "" + done_count = 0 + total = len(children) + for child_name in children: + child_dir = tasks_dir / child_name + child_json = child_dir / FILE_TASK_JSON + if child_json.is_file(): + data = _read_json_file(child_json) + if data: + status = data.get("status", "") + if status in ("completed", "done"): + done_count += 1 + return f" [{done_count}/{total} done]" + + +def cmd_list(args: argparse.Namespace) -> int: + """List active tasks.""" + repo_root = get_repo_root() + tasks_dir = get_tasks_dir(repo_root) + current_task = get_current_task(repo_root) + developer = get_developer(repo_root) + filter_mine = args.mine + filter_status = args.status + + if filter_mine: + if not developer: + print(colored("Error: No developer set. Run init_developer.py first", Colors.RED), file=sys.stderr) + return 1 + print(colored(f"My tasks (assignee: {developer}):", Colors.BLUE)) + else: + print(colored("All active tasks:", Colors.BLUE)) + print() + + # First pass: collect all task data and identify parent/child relationships + all_tasks: dict[str, dict] = {} + if tasks_dir.is_dir(): + for d in sorted(tasks_dir.iterdir()): + if not d.is_dir() or d.name == "archive": + continue + + dir_name = d.name + task_json = d / FILE_TASK_JSON + status = "unknown" + assignee = "-" + children: list[str] = [] + parent: str | None = None + + if task_json.is_file(): + data = _read_json_file(task_json) + if data: + status = data.get("status", "unknown") + assignee = data.get("assignee", "-") + children = data.get("children", []) + parent = data.get("parent") + + all_tasks[dir_name] = { + "status": status, + "assignee": assignee, + "children": children, + "parent": parent, + } + + # Second pass: display tasks hierarchically + count = 0 + + def _print_task(dir_name: str, indent: int = 0) -> None: + nonlocal count + info = all_tasks[dir_name] + status = info["status"] + assignee = info["assignee"] + children = info["children"] + + # Apply --mine filter + if filter_mine and assignee != developer: + return + + # Apply --status filter + if filter_status and status != filter_status: + return + + relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}" + marker = "" + if relative_path == current_task: + marker = f" {colored('<- current', Colors.GREEN)}" + + # Children progress + progress = _get_children_progress(children, tasks_dir) if children else "" + + prefix = " " * indent + " - " + + if filter_mine: + print(f"{prefix}{dir_name}/ ({status}){progress}{marker}") + else: + print(f"{prefix}{dir_name}/ ({status}){progress} [{colored(assignee, Colors.CYAN)}]{marker}") + count += 1 + + # Print children indented + for child_name in children: + if child_name in all_tasks: + _print_task(child_name, indent + 1) + + # Display only top-level tasks (those without a parent) + for dir_name in sorted(all_tasks.keys()): + info = all_tasks[dir_name] + if not info["parent"]: + _print_task(dir_name) + + if count == 0: + if filter_mine: + print(" (no tasks assigned to you)") + else: + print(" (no active tasks)") + + print() + print(f"Total: {count} task(s)") + return 0 + + +# ============================================================================= +# Command: list-archive +# ============================================================================= + +def cmd_list_archive(args: argparse.Namespace) -> int: + """List archived tasks.""" + repo_root = get_repo_root() + tasks_dir = get_tasks_dir(repo_root) + archive_dir = tasks_dir / "archive" + month = args.month + + print(colored("Archived tasks:", Colors.BLUE)) + print() + + if month: + month_dir = archive_dir / month + if month_dir.is_dir(): + print(f"[{month}]") + for d in sorted(month_dir.iterdir()): + if d.is_dir(): + print(f" - {d.name}/") + else: + print(f" No archives for {month}") + else: + if archive_dir.is_dir(): + for month_dir in sorted(archive_dir.iterdir()): + if month_dir.is_dir(): + month_name = month_dir.name + count = sum(1 for d in month_dir.iterdir() if d.is_dir()) + print(f"[{month_name}] - {count} task(s)") + + return 0 + + +# ============================================================================= +# Command: set-branch +# ============================================================================= + +def cmd_set_branch(args: argparse.Namespace) -> int: + """Set git branch for task.""" + repo_root = get_repo_root() + target_dir = _resolve_task_dir(args.dir, repo_root) + branch = args.branch + + if not branch: + print(colored("Error: Missing arguments", Colors.RED)) + print("Usage: python3 task.py set-branch <task-dir> <branch-name>") + return 1 + + task_json = target_dir / FILE_TASK_JSON + if not task_json.is_file(): + print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) + return 1 + + data = _read_json_file(task_json) + if not data: + return 1 + + data["branch"] = branch + _write_json_file(task_json, data) + + print(colored(f"✓ Branch set to: {branch}", Colors.GREEN)) + print() + print(colored("Now you can start the multi-agent pipeline:", Colors.BLUE)) + print(f" python3 ./.trellis/scripts/multi_agent/start.py {args.dir}") + return 0 + + +# ============================================================================= +# Command: set-base-branch +# ============================================================================= + +def cmd_set_base_branch(args: argparse.Namespace) -> int: + """Set the base branch (PR target) for task.""" + repo_root = get_repo_root() + target_dir = _resolve_task_dir(args.dir, repo_root) + base_branch = args.base_branch + + if not base_branch: + print(colored("Error: Missing arguments", Colors.RED)) + print("Usage: python3 task.py set-base-branch <task-dir> <base-branch>") + print("Example: python3 task.py set-base-branch <dir> develop") + print() + print("This sets the target branch for PR (the branch your feature will merge into).") + return 1 + + task_json = target_dir / FILE_TASK_JSON + if not task_json.is_file(): + print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) + return 1 + + data = _read_json_file(task_json) + if not data: + return 1 + + data["base_branch"] = base_branch + _write_json_file(task_json, data) + + print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN)) + print(f" PR will target: {base_branch}") + return 0 + + +# ============================================================================= +# Command: set-scope +# ============================================================================= + +def cmd_set_scope(args: argparse.Namespace) -> int: + """Set scope for PR title.""" + repo_root = get_repo_root() + target_dir = _resolve_task_dir(args.dir, repo_root) + scope = args.scope + + if not scope: + print(colored("Error: Missing arguments", Colors.RED)) + print("Usage: python3 task.py set-scope <task-dir> <scope>") + return 1 + + task_json = target_dir / FILE_TASK_JSON + if not task_json.is_file(): + print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) + return 1 + + data = _read_json_file(task_json) + if not data: + return 1 + + data["scope"] = scope + _write_json_file(task_json, data) + + print(colored(f"✓ Scope set to: {scope}", Colors.GREEN)) + return 0 + + +# ============================================================================= +# Command: create-pr (delegates to multi-agent script) +# ============================================================================= + +def cmd_create_pr(args: argparse.Namespace) -> int: + """Create PR from task - delegates to multi_agent/create_pr.py.""" + import subprocess + script_dir = Path(__file__).parent + create_pr_script = script_dir / "multi_agent" / "create_pr.py" + + cmd = [sys.executable, str(create_pr_script)] + if args.dir: + cmd.append(args.dir) + if args.dry_run: + cmd.append("--dry-run") + + result = subprocess.run(cmd) + return result.returncode + + +# ============================================================================= +# Help +# ============================================================================= + +def show_usage() -> None: + """Show usage help.""" + print("""Task Management Script for Multi-Agent Pipeline + +Usage: + python3 task.py create <title> Create new task directory + python3 task.py create <title> --parent <dir> Create task as child of parent + python3 task.py init-context <dir> <dev_type> Initialize jsonl files + python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl + python3 task.py validate <dir> Validate jsonl files + python3 task.py list-context <dir> List jsonl entries + python3 task.py start <dir> Set as current task + python3 task.py finish Clear current task + python3 task.py set-branch <dir> <branch> Set git branch for multi-agent + python3 task.py set-scope <dir> <scope> Set scope for PR title + python3 task.py create-pr [dir] [--dry-run] Create PR from task + python3 task.py archive <task-name> Archive completed task + python3 task.py add-subtask <parent> <child> Link child task to parent + python3 task.py remove-subtask <parent> <child> Unlink child from parent + python3 task.py list [--mine] [--status <status>] List tasks + python3 task.py list-archive [YYYY-MM] List archived tasks + +Arguments: + dev_type: backend | frontend | fullstack | test | docs + +List options: + --mine, -m Show only tasks assigned to current developer + --status, -s <s> Filter by status (planning, in_progress, review, completed) + +Examples: + python3 task.py create "Add login feature" --slug add-login + python3 task.py create "Child task" --slug child --parent .trellis/tasks/01-21-parent + python3 task.py init-context .trellis/tasks/01-21-add-login backend + python3 task.py add-context <dir> implement .trellis/spec/backend/auth.md "Auth guidelines" + python3 task.py set-branch <dir> task/add-login + python3 task.py start .trellis/tasks/01-21-add-login + python3 task.py create-pr # Uses current task + python3 task.py create-pr <dir> --dry-run # Preview without changes + python3 task.py finish + python3 task.py archive add-login + python3 task.py add-subtask parent-task child-task # Link existing tasks + python3 task.py remove-subtask parent-task child-task + python3 task.py list # List all active tasks + python3 task.py list --mine # List my tasks only + python3 task.py list --mine --status in_progress # List my in-progress tasks +""") + + +# ============================================================================= +# Main Entry +# ============================================================================= + +def main() -> int: + """CLI entry point.""" + parser = argparse.ArgumentParser( + description="Task Management Script for Multi-Agent Pipeline", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + subparsers = parser.add_subparsers(dest="command", help="Commands") + + # create + p_create = subparsers.add_parser("create", help="Create new task") + p_create.add_argument("title", help="Task title") + p_create.add_argument("--slug", "-s", help="Task slug") + p_create.add_argument("--assignee", "-a", help="Assignee developer") + p_create.add_argument("--priority", "-p", default="P2", help="Priority (P0-P3)") + p_create.add_argument("--description", "-d", help="Task description") + p_create.add_argument("--parent", help="Parent task directory (establishes subtask link)") + + # init-context + p_init = subparsers.add_parser("init-context", help="Initialize context files") + p_init.add_argument("dir", help="Task directory") + p_init.add_argument("type", help="Dev type: backend|frontend|fullstack|test|docs") + + # add-context + p_add = subparsers.add_parser("add-context", help="Add context entry") + p_add.add_argument("dir", help="Task directory") + p_add.add_argument("file", help="JSONL file (implement|check|debug)") + p_add.add_argument("path", help="File path to add") + p_add.add_argument("reason", nargs="?", help="Reason for adding") + + # validate + p_validate = subparsers.add_parser("validate", help="Validate context files") + p_validate.add_argument("dir", help="Task directory") + + # list-context + p_listctx = subparsers.add_parser("list-context", help="List context entries") + p_listctx.add_argument("dir", help="Task directory") + + # start + p_start = subparsers.add_parser("start", help="Set current task") + p_start.add_argument("dir", help="Task directory") + + # finish + subparsers.add_parser("finish", help="Clear current task") + + # set-branch + p_branch = subparsers.add_parser("set-branch", help="Set git branch") + p_branch.add_argument("dir", help="Task directory") + p_branch.add_argument("branch", help="Branch name") + + # set-base-branch + p_base = subparsers.add_parser("set-base-branch", help="Set PR target branch") + p_base.add_argument("dir", help="Task directory") + p_base.add_argument("base_branch", help="Base branch name (PR target)") + + # set-scope + p_scope = subparsers.add_parser("set-scope", help="Set scope") + p_scope.add_argument("dir", help="Task directory") + p_scope.add_argument("scope", help="Scope name") + + # create-pr + p_pr = subparsers.add_parser("create-pr", help="Create PR") + p_pr.add_argument("dir", nargs="?", help="Task directory") + p_pr.add_argument("--dry-run", action="store_true", help="Dry run mode") + + # archive + p_archive = subparsers.add_parser("archive", help="Archive task") + p_archive.add_argument("name", help="Task name") + p_archive.add_argument("--no-commit", action="store_true", help="Skip auto git commit after archive") + + # list + p_list = subparsers.add_parser("list", help="List tasks") + p_list.add_argument("--mine", "-m", action="store_true", help="My tasks only") + p_list.add_argument("--status", "-s", help="Filter by status") + + # add-subtask + p_addsub = subparsers.add_parser("add-subtask", help="Link child task to parent") + p_addsub.add_argument("parent_dir", help="Parent task directory") + p_addsub.add_argument("child_dir", help="Child task directory") + + # remove-subtask + p_rmsub = subparsers.add_parser("remove-subtask", help="Unlink child task from parent") + p_rmsub.add_argument("parent_dir", help="Parent task directory") + p_rmsub.add_argument("child_dir", help="Child task directory") + + # list-archive + p_listarch = subparsers.add_parser("list-archive", help="List archived tasks") + p_listarch.add_argument("month", nargs="?", help="Month (YYYY-MM)") + + args = parser.parse_args() + + if not args.command: + show_usage() + return 1 + + commands = { + "create": cmd_create, + "init-context": cmd_init_context, + "add-context": cmd_add_context, + "validate": cmd_validate, + "list-context": cmd_list_context, + "start": cmd_start, + "finish": cmd_finish, + "set-branch": cmd_set_branch, + "set-base-branch": cmd_set_base_branch, + "set-scope": cmd_set_scope, + "create-pr": cmd_create_pr, + "archive": cmd_archive, + "add-subtask": cmd_add_subtask, + "remove-subtask": cmd_remove_subtask, + "list": cmd_list, + "list-archive": cmd_list_archive, + } + + if args.command in commands: + return commands[args.command](args) + else: + show_usage() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.trellis/spec/README.md b/.trellis/spec/README.md new file mode 100644 index 00000000..d9788f93 --- /dev/null +++ b/.trellis/spec/README.md @@ -0,0 +1,111 @@ +# util-dfm 开发规范 + +> util-dfm 是 Deepin 文件管理器的工具包,包含四个独立的库模块。 + +**项目**: util-dfm (libdfm-*) +**版本**: Qt5+Qt6 双版本支持 +**语言**: C++17 + +--- + +## 库模块 + +util-dfm 包含以下四个独立库,每个库有不同的技术栈和编码规范: + +| 库 | 技术栈 | 错误处理 | 目录 | +|-----|-------|---------|------| +| **dfm-io** | GIO/GLib + Qt | GError + DFMIOErrorCode | [dfm-io/](dfm-io/) | +| **dfm-mount** | UDisks2 + GDBus + GIO | DeviceError (多来源转换) | [dfm-mount/](dfm-mount/) | +| **dfm-search** | Lucene++ + Qt | std::error_code + ErrorCategory | [dfm-search/](dfm-search/) | +| **dfm-burn** | xorriso + Qt | lastError() + errorMsg 字符串 | [dfm-burn/](dfm-burn/) | + +--- + +## 规范索引 + +### 库特定规范 + +| 层级 | 文档 | 描述 | +|------|------|------| +| **dfm-io** | [error-handling.md](dfm-io/error-handling.md) | GError 转换、DFMIOErrorCode | +| **dfm-io** | [naming-conventions.md](dfm-io/naming-conventions.md) | 命名约定 | +| **dfm-io** | [memory-management.md](dfm-io/memory-management.md) | P-impl、智能指针、g_autoptr | +| **dfm-mount** | [error-handling.md](dfm-mount/error-handling.md) | UDisks2/ GIO/GDBus 错误转换 | +| **dfm-mount** | [naming-conventions.md](dfm-mount/naming-conventions.md) | 命名约定 | +| **dfm-mount** | [memory-management.md](dfm-mount/memory-management.md) | GLib autoptr 管理模式 | +| **dfm-search** | [error-handling.md](dfm-search/error-handling.md) | std::error_code 使用指南 | +| **dfm-search** | [naming-conventions.md](dfm-search/naming-conventions.md) | m_ 前缀命名规范 | +| **dfm-search** | [memory-management.md](dfm-search/memory-management.md) | std::unique_ptr、PIMPL | +| **dfm-burn** | [error-handling.md](dfm-burn/error-handling.md) | lastError() 模式 | +| **dfm-burn** | [naming-conventions.md](dfm-burn/naming-conventions.md) | 命名约定 | +| **dfm-burn** | [memory-management.md](dfm-burn/memory-management.md) | QScopedPointer、QSharedData | + +### 通用共享规范 + +| 层级 | 文档 | 描述 | +|------|------|------| +| **shared** | [cpp-conventions.md](shared/cpp-conventions.md) | C++17 编码约定、RAII | +| **shared** | [git-conventions.md](shared/git-conventions.md) | Git 提交约定 | +| **shared** | [internationalization.md](shared/internationalization.md) | 国际化规范 | +| **shared** | [deepin-terminology.md](shared/deepin-terminology.md) | 品牌术语规范 | +| **shared** | [dbus-conventions.md](shared/dbus-conventions.md) | DBus 接口规范 | + +### 思考指南 + +| 层级 | 文档 | 描述 | +|------|------|------| +| **guides** | [root-cause-analysis.md](guides/root-cause-analysis.md) | 5-Why 根因分析 | + +### 常见问题 + +| 层级 | 文档 | 描述 | +|------|------|------| +| **big-question** | [gthread-ui-thread-safety.md](big-question/gthread-ui-thread-safety.md) | 跨线程更新 UI | +| **big-question** | [gvfs-mount-path-issues.md](big-question/gvfs-mount-path-issues.md) | GVfs 挂载路径问题 | +| **big-question** | [dbus-async-vs-sync.md](big-question/dbus-async-vs-sync.md) | DBus 异步/同步选择 | + +### 代码审查 + +| 层级 | 文档 | 描述 | +|------|------|------| +| **review** | [code-review-standards.md](review/code-review-standards.md) | 代码审查标准 | +| **review** | [reference/](review/reference/) | 架构、安全、性能审查 | + +--- + +## 快速开始 + +### 选择库开发 + +**开发前必须确认**:你在为哪个库开发? + +| 如果你在开发... | 请阅读... | +|----------------|----------| +| dfm-io (文件 I/O) | [dfm-io/index.md](dfm-io/index.md) | +| dfm-mount (设备挂载) | [dfm-mount/index.md](dfm-mount/index.md) | +| dfm-search (文件搜索) | [dfm-search/index.md](dfm-search/index.md) | +| dfm-burn (光盘刻录) | [dfm-burn/index.md](dfm-burn/index.md) | + +### 重要注意事项 + +1. **每个库的规范不同**:不要假设所有库使用相同的模式 + - dfm-io 使用 `GError` 和 `g_autoptr` + - dfm-search 使用 `std::error_code` + - dfm-burn 使用简单的 `lastError()` 模式 + +2. **禁用 signals/slots 关键字**:所有库使用 `Q_SIGNALS`/`Q_SLOTS` 宏 + +3. **Qt5/Qt6 双版本**:代码必须同时支持 + +--- + +## 核心规则摘要 + +| 规则 | 说明 | +|------|------| +| **库特定规范优先** | 遵循具体库的开发规范,而非通用规范 | +| **禁用 signals/slots** | 使用 `Q_SIGNALS`/`Q_SLOTS` 宏 | +| **错误处理** | 按库约定:GError / std::error_code / lastError() | +| **内存管理** | 按库约定:g_autoptr / std::unique_ptr / QScopedPointer | +| **国际化** | 用户文本使用 `tr()` | +| **线程安全** | 跨线程用 `QueuedConnection`,同步方法检查主线程 | diff --git a/.trellis/spec/backend/architecture-decisions.md b/.trellis/spec/backend/architecture-decisions.md new file mode 100644 index 00000000..fae9a850 --- /dev/null +++ b/.trellis/spec/backend/architecture-decisions.md @@ -0,0 +1,195 @@ +# DDE 架构决策 + +> 基于 DDE 系统集成的架构决策和核心规则。 + +--- + +## 概览 + +本指南定义 DDE 桌面应用开发中的关键架构决策和核心规则。 + +--- + +## 核心原则 + +1. **DTK 优先于 Qt 原生** +2. **GIO 优先于 QFile(网络协议)** +3. **DBus 事件驱动优先于轮询** +4. **Polkit 鉴权优先于 sudo** +5. **Qt6 优先,V20 回退到 Qt5** + +--- + +## 决策表 + +| 场景 | 决策 | 参考文档 | +|------|------|----------| +| 显示主窗口 | `DMainWindow` 而非 `QMainWindow` | [DTK 组件指南](reference/dtk-widgets-guide.md) | +| 访问 `smb://` 路径 | GIO `GFile` 而非 `QFile` | [GVfs/GIO 集成](reference/gvfs-gio-integration.md) | +| 获取电池/网络状态 | DBus 信号监听而非轮询 | [DBus 服务使用](reference/dbus-service-usage.md) | +| 修改 `/etc` 配置 | Polkit + Helper 而非 sudo | [Polkit 认证流程](reference/polkit-auth-workflow.md) | +| 深色/浅色主题适配 | `applicationPalette()` 而非硬编码 | [DTK 组件指南](reference/dtk-widgets-guide.md) | +| 大文件 I/O | QtConcurrent 后台线程 | [GVfs/GIO 集成](reference/gvfs-gio-integration.md) | + +--- + +## 核心规则 + +### 规则 1: DTK 组件(强制性) + +| 推荐使用 | 禁止使用 | +|---------|---------| +| `DMainWindow` | `QMainWindow` | +| `DDialog` | `QDialog` | +| `DMessageBox` | `QMessageBox` | +| `DWidget` | `QWidget` | + +```cpp +// ✅ 正确 +#include <DMainWindow> +#include <DMessageBox> +DWIDGET_USE_NAMESPACE + +DMainWindow *window = new DMainWindow(); +DMessageBox::information(this, "Title", "Message"); + +// ❌ 禁止 +QMainWindow *window = new QMainWindow(); +``` + +**主题**: 使用 `DGuiApplicationHelper::instance()->applicationPalette()`,从不硬编码颜色。 + +📖 **详情**: [reference/dtk-widgets-guide.md](reference/dtk-widgets-guide.md) + +### 规则 2: GVfs/GIO for 网络协议 + +```cpp +// ✅ 正确: smb://, mtp://, dav:// +#include <gio/gio.h> +GFile *file = g_file_new_for_uri("smb://server/share/file.pdf"); +GFileInputStream *input = g_file_read(file, nullptr, &error); + +// ❌ 禁止 +QFile file("smb://server/share/file.pdf"); // 失败 +``` + +**异步 I/O**: 始终对文件操作使用 `QtConcurrent::run()`。 + +📖 **详情**: [reference/gvfs-gio-integration.md](reference/gvfs-gio-integration.md) + +### 规则 3: DBus 事件驱动 + +```cpp +// ✅ 正确: 监听信号 +QDBusConnection::systemBus().connect( + "org.freedesktop.UPower", + "/org/freedesktop/UPower", + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + this, SLOT(handleEvent(QString, QVariantMap))); + +// ❌ 禁止: 轮询 +QTimer *timer = new QTimer(); +connect(timer, &QTimer::timeout, this, [](){ + QFile::read("/sys/class/power_supply/BAT0/capacity"); +}); +``` + +📖 **详情**: [reference/dbus-service-usage.md](reference/dbus-service-usage.md) + +### 规则 4: Polkit 认证 + +```cpp +// ✅ 正确 +PolkitQt1::Authority::instance()->checkAuthorizationSync( + "org.deepin.dde.policy.authentication", + PolkitQt1::UnixProcessSubject(QCoreApplication::applicationPid()), + PolkitQt1::Authority::AllowUserInteraction); + +// ❌ 禁止 +sudo ./application // 安全风险 +``` + +📖 **详情**: [reference/polkit-auth-workflow.md](reference/polkit-auth-workflow.md) + +--- + +## CMake 配置 + +参考 `cmake-conventions.md` 获取完整的 Qt6/Qt5 双版本支持。 + +```cmake +find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core) +if(QT_VERSION_MAJOR EQUAL 6) + set(DTK_VERSION_MAJOR 6) +else() + set(DTK_VERSION_MAJOR "") +endif() + +find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Gui Widgets) +find_package(Dtk${DTK_VERSION_MAJOR}Widget REQUIRED) + +target_link_libraries(app PRIVATE + Qt${QT_VERSION_MAJOR}::Widgets + Dtk${DTK_VERSION_MAJOR}::Widget +) +``` + +--- + +## Debian Control 文件 + +参考 `git-conventions.md` 获取文件约定。 + +| 文件 | Deepin | Qt | +|------|--------|-----| +| `debian/control` | V25 | Qt6 | +| `debian/control.1` | V20 | Qt5 | + +**依赖 (V25/Qt6)**: +``` +Build-Depends: + qt6-base-dev | qtbase5-dev, + libdtk6widget-dev | libdtkwidget-dev, + libpolkit-qt6-1-dev | libpolkit-qt5-1-dev +``` + +--- + +## 常见陷阱 + +| 陷阱 | 后果 | 修复 | +|------|------|------| +| GVfs 挂载点 `/run/user/UID/gvfs/` | 路径随会话变化 | → 使用 GIO URI API | +| DBus 总线类型错误 | 连接失败 | → System vs Session | +| Polkit helper 未验证输入 | 命令注入风险 | → 严格验证路径 | +| 非主线程更新 UI | 崩溃 | → 信号槽 (QueuedConnection) | +| 忽略 `GError` | 无法诊断问题 | → 检查 error 参数 | + +--- + +## 代码审查清单 + +📖 **完整清单**: [reference/code-review-checklist.md](reference/code-review-checklist.md) + +**快速检查**: +```bash +# 检查 DTK 组件使用 +grep -r "QMainWindow\|QDialog\|QMessageBox" src/ + +# 检查硬编码颜色 +grep -rE "#[0-9a-fA-F]{6}" src/ + +# 检查 GIO 使用 +grep -r "g_file_new_for_uri" src/ +``` + +--- + +## 参考文档 + +- [DTK 组件指南](reference/dtk-widgets-guide.md) - 控件使用、主题适配 +- [GVfs/GIO 集成](reference/gvfs-gio-integration.md) - 文件操作、异步 I/O +- [DBus 服务使用](reference/dbus-service-usage.md) - 系统服务集成 +- [Polkit 认证流程](reference/polkit-auth-workflow.md) - 权限控制、Helper 模式 +- [代码审查清单](reference/code-review-checklist.md) - 审查清单 diff --git a/.trellis/spec/backend/architecture-design.md b/.trellis/spec/backend/architecture-design.md new file mode 100644 index 00000000..08418cfc --- /dev/null +++ b/.trellis/spec/backend/architecture-design.md @@ -0,0 +1,293 @@ +# 架构设计规范 + +> DDE 应用架构设计的规范和约束。 + +--- + +## 概览 + +本指南定义功能开发阶段的架构分析要求,遵循 DDE 约定和最佳实践。 + +--- + +## 架构设计原则 + +### 1. 分层架构 + +``` +┌─────────────────────────────────────┐ +│ UI Layer (DTK Widgets) │ +│ - DMainWindow, DDialog, DListView │ +└─────────────────────────────────────┘ + ↕ Signals/Slots +┌─────────────────────────────────────┐ +│ Business Logic Layer │ +│ - Controllers, Services, Models │ +└─────────────────────────────────────┘ + ↕ +┌─────────────────────────────────────┐ +│ Data Access Layer │ +│ - GIO/GVfs, DBus, QSettings │ +└─────────────────────────────────────┘ +``` + +### 2. 命名空间约定 + +```cpp +// 基础层 - dfmbase +namespace dfmbase { + namespace interfaces { ... } + namespace widgets { ... } + namespace utils { ... } +} + +// 框架层 - dpf (Deepin Plugin Framework) +namespace dpf { + namespace event { ... } + namespace lifecycle { ... } +} + +// 扩展层 - Extension +namespace Extension { + namespace menu { ... } + namespace emblemicon { ... } +} +``` + +### 3. 插件化架构 + +参考 `plugin-architecture.md` 获取完整的插件系统规范。 + +--- + +## DDE 约定遵循 + +### 必须使用的组件 + +| 场景 | 必须使用 | 禁止使用 | +|------|---------|---------| +| 主窗口 | `DMainWindow` | `QMainWindow` | +| 对话框 | `DDialog` | `QDialog` | +| 消息框 | `DMessageBox` | `QMessageBox` | +| 基础组件 | `DWidget` | `QWidget` | +| 列表视图 | `DListView` | `QListView` | +| 输入框 | `DLineEdit` | `QLineEdit` | + +### 主题适配 + +```cpp +// ✅ 正确 +QPalette palette = DGuiApplicationHelper::instance()->applicationPalette(); +widget->setPalette(palette); + +// ❌ 错误 +widget->setStyleSheet("color: #333333;"); +``` + +### 国际化 + +```cpp +// 所有面向用户的文本必须使用 tr() +button->setText(tr("Save")); +dialog->setTitle(tr("Confirm")); + +// CMake 添加 TS 文件 +find_package(Qt6LinguistTools REQUIRED) +qt_add_translations(app TS_FILES translations/app_zh_CN.ts) +``` + +--- + +## 接口定义 + +### 模型接口 + +```cpp +// 优先使用 QAbstractListModel/QAbstractTableModel +class FileListModel : public QAbstractListModel +{ + Q_OBJECT +public: + enum Roles { + FilePathRole = Qt::UserRole + 1, + FileNameRole, + FileSizeRole + }; + + explicit FileListModel(QObject *parent = nullptr); + + // 必须实现的虚函数 + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash<int, QByteArray> roleNames() const override; +}; +``` + +### 服务接口 + +```cpp +// 定义清晰的服务接口 +class IFileService { +public: + virtual ~IFileService() = default; + + virtual Result copyFile(const QString &source, const QString &dest) = 0; + virtual Result moveFile(const QString &source, const QString &dest) = 0; + virtual Result deleteFile(const QString &path) = 0; +}; +``` + +--- + +## 模块结构 + +### 标准模块目录 + +``` +src/ +├── widgets/ # UI 组件 +├── controllers/ # 控制器 +├── models/ # 数据模型 +├── services/ # 服务 +├── utils/ # 工具函数 +└── interfaces/ # 接口定义 +``` + +### Pimpl 模式实现 + +```cpp +// interface.h +class MyWidget : public DWidget { + Q_OBJECT +public: + explicit MyWidget(QWidget *parent = nullptr); + ~MyWidget() override; + + void setValue(int value); + int value() const; + +private: + class Private; + QScopedPointer<Private> d; +}; + +// impl.cpp +class MyWidget::Private { +public: + int value = 0; + DLabel *label = nullptr; + DLineEdit *lineEdit = nullptr; +}; + +MyWidget::MyWidget(QWidget *parent) + : DWidget(parent) + , d(new Private()) +{ + d->label = new DLabel(this); + d->lineEdit = new DLineEdit(this); +} +``` + +--- + +## 并发和线程 + +### 后台任务模式 + +```cpp +// 使用 QtConcurrent 执行耗时操作 +void Controller::loadLargeFile(const QString &path) { + QFuture<Result> future = QtConcurrent::run([path]() { + return FileService::loadFile(path); + }); + + QFutureWatcher<Result> *watcher = new QFutureWatcher<Result>(this); + connect(watcher, &QFutureWatcher<Result>::finished, this, [this, watcher]() { + Result result = watcher->result(); + handleLoadComplete(result); + watcher->deleteLater(); + }); + watcher->setFuture(future); +} +``` + +### 线程安全信号 + +```cpp +// 跨线程通信必须使用 QueuedConnection +connect(worker, &Worker::progress, ui, &UI::updateProgress, + Qt::QueuedConnection); +``` + +--- + +## 错误处理 + +### Result 模式 + +```cpp +struct Result { + bool success = false; + QString error; + QVariant data; + + static Result ok(const QVariant &data = {}) { + return {true, QString(), data}; + } + + static Result fail(const QString &error) { + return {false, error, QVariant()}; + } +}; + +// 使用 +Result result = service.copyFile(source, dest); +if (!result.success) { + DDialog dialog; + dialog.setTitle(tr("Error")); + dialog.setMessage(result.error); + dialog.exec(); +} +``` + +### 异常处理 + +```cpp +// DDE 应用优先使用错误码而非异常 +// 如果使用异常,确保捕获所有路径 +try { + // 操作 +} catch (const std::exception &e) { + qWarning() << "Exception:" << e.what(); + // 显示错误对话框 +} +``` + +--- + +## 实施计划 (WBS) + +### 1. 需求分析 +- [ ] 收集功能需求 +- [ ] 识别 DDE 相关约束 +- [ ] 评估技术可行性 + +### 2. 架构设计 +- [ ] 设计模块结构 +- [ ] 定义接口契约 +- [ ] 规划数据流 + +### 3. 原型设计 +- [ ] 创建 UI 原型 +- [ ] 验证 DTK 组件选择 +- [ ] 用户确认 + +### 4. 详细设计 +- [ ] 设计数据库/存储 +- [ ] 设计 API/DBus 接口 +- [ ] 设计测试策略 + +### 5. 实施计划 +- [ ] 任务分解 +- [ ] 依赖关系 +- [ ] 时间估算 diff --git a/.trellis/spec/backend/bug-fix-standards.md b/.trellis/spec/backend/bug-fix-standards.md new file mode 100644 index 00000000..865e9b68 --- /dev/null +++ b/.trellis/spec/backend/bug-fix-standards.md @@ -0,0 +1,223 @@ +# Bug 修复规范 + +> DDE 应用 Bug 修复的规范和约束。 + +--- + +## 概览 + +本指南定义 Bug 修复的标准流程和约束,遵循最小修改原则。 + +--- + +## 核心原则 + +### 最小修改原则 + +Bug 修复必须遵循最小修改范围: +- 只修复问题本身,不重构周边代码 +- 不添加新功能 +- 保持现有代码风格 +- 添加注释说明修复原因 + +```cpp +// ❌ 错误: 重构整段代码 +void processFile() { + // 完全重写的代码... +} + +// ✅ 正确: 只修复 Bug +void processFile() { + // ... 原有代码 ... + if (file.isOpen()) { // 添加缺失的检查 + file.write(data); + } + // ... 原有代码 ... +} +``` + +### 根因分析优先 + +在修复之前必须找到根本原因。使用 5-Why 方法系统性分析。 + +--- + + +## 修复流程 + +### 1. 信息收集 + +- 收集 Bug 描述、复现步骤、期望结果 +- 获取日志、截图和环境信息 +- 确认影响范围和优先级 + +### 2. 根因分析 + +使用 `guides/root-cause-analysis.md` 中的 5-Why 方法: + +| Level | Question | Answer | Evidence | +|-------|----------|--------|----------| +| Why 1 | 为什么会出现这个现象? | [直接原因] | [证据] | +| Why 2 | 为什么会有这个直接原因? | [深层原因] | [证据] | +| Why 3 | 为什么允许这个条件? | [系统原因] | [证据] | +| Why 4 | 为什么设计允许这个问题? | [设计原因] | [证据] | +| Why 5 | 根本原因是什么? | [根本原因] | [证据] | + +### 3. 设计修复方案 + +- 针对根因设计最小修复 +- 评估修复的影响范围 +- 用户确认方案 + +### 4. 实施修改 + +- 在最小范围内修改代码 +- 添加注释说明修复原因 +- 确保不引入新问题 + +### 5. 验证 + +- 编译通过,无警告 +- 运行相关测试 +- 手动验证修复效果 + +--- + +## 修复合约 + +### 必须做 + +- [ ] 找到根本原因(5-Why 分析) +- [ ] 设计最小修复方案 +- [ ] 添加修复注释 +- [ ] 验证编译和测试 +- [ ] 手动验证修复效果 + +### 禁止做 + +- [ ] 重构周边代码 +- [ ] 添加新功能 +- [ ] 修改不相关的逻辑 +- [ ] 改变现有接口签名 +- [ ] 跳过根因分析 + +--- + +## 常见修复模式 + +### 边界条件修复 + +```cpp +// ❌ 错误: 没有检查边界 +QString getFileContent(const QString &path) { + QFile file(path); + return file.readAll(); // 如果文件不存在会失败 +} + +// ✅ 正确: 添加边界检查 +QString getFileContent(const QString &path) { + QFile file(path); + if (!file.exists()) { + qWarning() << "File not found:" << path; + return QString(); + } + if (!file.open(QIODevice::ReadOnly)) { + qWarning() << "Failed to open file:" << path; + return QString(); + } + return file.readAll(); +} +``` + +### 空指针修复 + +```cpp +// ❌ 错误: 可能空指针 +void processItem(Item *item) { + qDebug() << item->name(); // 如果 item 为空会崩溃 +} + +// ✅ 正确: 添加空指针检查 +void processItem(Item *item) { + if (!item) { + qWarning() << "Item is null"; + return; + } + qDebug() << item->name(); +} +``` + +### 信号槽连接修复 + +```cpp +// ❌ 错误: 跨线程没有指定连接类型 +connect(worker, &Worker::signal, uiWidget, &Widget::slot); + +// ✅ 正确: 使用 QueuedConnection +connect(worker, &Worker::signal, uiWidget, &Widget::slot, + Qt::QueuedConnection); +``` + +### 内存泄漏修复 + +```cpp +// ❌ 错误: 没有父对象 +DWidget *widget = new DWidget(); // 泄漏 + +// ✅ 正确: 设置父对象 +DWidget *widget = new DWidget(parentWidget); +``` + +--- + +## 注释规范 + +修复必须添加注释说明原因: + +```cpp +// Fix: 添加文件存在性检查,防止空指针崩溃 +// Bug: #12345 - 当文件不存在时崩溃 +if (!file.exists()) { + return false; +} + +// Fix: 使用 QueuedConnection 跨线程通信 +// Bug: #12346 - Worker 线程直接更新 UI 导致崩溃 +connect(worker, &Worker::progress, ui, &UI::updateProgress, + Qt::QueuedConnection); +``` + +--- + +## 测试验证 + +### 单元测试 + +为修复添加回归测试: + +```cpp +void TestBugFix::testFileNotExistence() { + // Bug: #12345 - 文件不存在时崩溃 + QString content = FileService::getContent("/nonexistent/file"); + QCOMPARE(content, QString()); +} +``` + +### 手动验证 + +- [ ] 按照 Bug 复现步骤验证 +- [ ] 检查修复不引入新问题 +- [ ] 验证相关功能正常 + +--- + +## 修复完成标准 + +- [ ] 根因分析完成(5-Why 表格) +- [ ] 修复方案用户确认 +- [ ] 代码修改最小化 +- [ ] 添加修复注释 +- [ ] 编译通过,无警告 +- [ ] 单元测试通过 +- [ ] 手动验证修复效果 +- [ ] 相关人员审查通过 diff --git a/.trellis/spec/backend/cmake-conventions.md b/.trellis/spec/backend/cmake-conventions.md new file mode 100644 index 00000000..0ae1ab15 --- /dev/null +++ b/.trellis/spec/backend/cmake-conventions.md @@ -0,0 +1,289 @@ +# CMake 约定 + +> DDE 应用的 CMake 构建约定和 Qt6/Qt5 双版本支持。 + +--- + +## 概览 + +本指南定义 DDE 应用 CMake 构建的标准约定,支持 Qt6/Qt5 双版本。 + +--- + +## Qt6/Qt5 双版本支持 + +### 基础配置 + +```cmake +# 最低 CMake 版本 +cmake_minimum_required(VERSION 3.10) + +# 项目信息 +project(MyApp VERSION 1.0.0) + +# 启用 C++17 +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# Qt 版本检测 +find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core) +if(QT_VERSION_MAJOR EQUAL 6) + set(DTK_VERSION_MAJOR 6) + set(QT_VERSION_MAJOR 6) +else() + set(DTK_VERSION_MAJOR "") + set(QT_VERSION_MAJOR 5) +endif() + +message(STATUS "Qt version: ${QT_VERSION_MAJOR}") +message(STATUS "DTK version: ${DTK_VERSION_MAJOR}") + +# 查找 Qt 和 DTK +find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS + Core + Gui + Widgets + Concurrent + DBus + Sql + Network +) + +find_package(Dtk${DTK_VERSION_MAJOR}Widget REQUIRED) +``` + +--- + +## 目标配置 + +### 可执行文件 + +```cmake +# 主可执行文件 +add_executable(myapp + src/main.cpp + src/mainwindow.cpp + src/mainwindow.h +) + +target_link_libraries(myapp PRIVATE + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Gui + Qt${QT_VERSION_MAJOR}::Widgets + Qt${QT_VERSION_MAJOR}::Concurrent + Qt${QT_VERSION_MAJOR}::DBus + Qt${QT_VERSION_MAJOR}::Sql + Qt${QT_VERSION_MAJOR}::Network + Dtk${DTK_VERSION_MAJOR}::Widget +) + +# 编译定义 +target_compile_definitions(myapp PRIVATE + QT_DISABLE_DEPRECATED_BEFORE=0x050F00 +) + +# 使用 DTK 命名空间 +target_compile_definitions(myapp PRIVATE + DWIDGET_USE_NAMESPACE +) +``` + +### 库 + +```cmake +# 共享库 +add_library(mylib SHARED + src/myclass.cpp + src/myclass.h +) + +# 创建别名便于引用 +add_library(MyLib::mylib ALIAS mylib) + +target_link_libraries(mylib PUBLIC + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Widgets + Dtk${DTK_VERSION_MAJOR}::Widget +) + +# 导出符号 +set_target_properties(mylib PROPERTIES + VERSION ${PROJECT_VERSION} + SOVERSION ${PROJECT_VERSION_MAJOR} + PUBLIC_HEADER "${CMAKE_CURRENT_SOURCE_DIR}/include/*.h" +) +``` + +--- + +## 编译选项 + +### Debug 分支默认启用测试 + +```cmake +# Debug 模式启用测试选项 +option(OPT_ENABLE_BUILD_UT "Enable unit tests" ON) +option(OPT_ENABLE_BUILD_TESTS "Enable build tests" OFF) +option(OPT_DISABLE_QDEBUG "Disable qDebug macro" OFF) + +if(OPT_ENABLE_BUILD_UT) + enable_testing() + add_subdirectory(tests) +endif() + +if(OPT_DISABLE_QDEBUG) + add_definitions(-DQT_NO_DEBUG_OUTPUT) +endif() +``` + +### 警告级别 + +```cmake +if(MSVC) + add_compile_options(/W4) +else() + add_compile_options(-Wall -Wextra -Wpedantic) +endif() +``` + +--- + +## 目录约定 + +### 标准布局 + +``` +project/ +├── CMakeLists.txt +├── src/ +│ ├── main.cpp +│ ├── mainwindow.cpp +│ └── mainwindow.h +├── include/ +│ └── myapp/ +│ └── myclass.h +├── tests/ +│ └── CMakeLists.txt +├── assets/ +│ ├── icons/ +│ └── translations/ +└── cmake/ + └── DFMCommon.cmake +``` + +### CMake 模块 + +```cmake +# cmake/DFMCommon.cmake +function(dtk_add_executable name) + add_executable(${name} ${ARGN}) + target_link_libraries(${name} PRIVATE + Qt${QT_VERSION_MAJOR}::Widgets + Dtk${DTK_VERSION_MAJOR}::Widget + ) + target_compile_definitions(${name} PRIVATE DWIDGET_USE_NAMESPACE) +endfunction() +``` + +--- + +## Debian Control 约定 + +### Qt6 (V25) + +``` +Source: myapp +Maintainer: Deepin Developers <deepin@uniontech.com> + +Build-Depends: + debhelper-compat (=13), + qt6-base-dev, + qt6-tools-dev, + libdtk6widget-dev (>= 6.0.0), + libdtk6core-dev (>= 6.0.0), + libpolkit-qt6-1-dev, + libglib2.0-dev +``` + +### Qt5 (V20) + +``` +Source: myapp +Maintainer: Deepin Developers <deepin@uniontech.com> + +Build-Depends: + debhelper-compat (=12), + qtbase5-dev, + qttools5-dev, + libdtkwidget-dev (>= 2.0.0), + libdtkcore-dev (>= 2.0.0), + libpolkit-qt5-1-dev, + libglib2.0-dev +``` + +--- + +## MOC/UIC/RCC 处理 + +CMake 自动处理 Qt 元对象编译: + +```cmake +# CMake 会自动处理 +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTOUIC ON) +set(CMAKE_AUTORCC ON) + +# 如果需要自定义选项 +set_property(SOURCE myclass.h PROPERTY AUTOMOC_OPTIONS "-MURI=my.uri") +``` + +--- + +## 安装规则 + +```cmake +# 安装可执行文件 +install(TARGETS myapp + RUNTIME DESTINATION bin +) + +# 安装库 +install(TARGETS mylib + LIBRARY DESTINATION lib + ARCHIVE DESTINATION lib +) + +# 安装头文件 +install(DIRECTORY include/myapp + DESTINATION include +) + +# 安装翻译文件 +install(FILES assets/translations/*.qm + DESTINATION share/${PROJECT_NAME}/translations +) + +# 安装桌面文件 +install(FILES assets/myapp.desktop + DESTINATION share/applications +) + +# 安装图标 +install(FILES assets/icons/myapp.png + DESTINATION share/icons/hicolor/48x48/apps +) +``` + +--- + +## 快速参考 + +| 任务 | 命令 | +|------|------| +| Qt6/Qt5 双版本 | `find_package(QT NAMES Qt6 Qt5 REQUIRED)` | +| DTK Widget | `find_package(Dtk${DTK_VERSION_MAJOR}Widget REQUIRED)` | +| DTK 命名空间 | `target_compile_definitions(... DWIDGET_USE_NAMESPACE)` | +| 测试 | `enable_testing()` + `add_subdirectory(tests)` | +| 禁用 qDebug | `-DQT_NO_DEBUG_OUTPUT` | +| 安装到 bin | `install(TARGETS myapp RUNTIME DESTINATION bin)` | diff --git a/.trellis/spec/backend/index.md b/.trellis/spec/backend/index.md new file mode 100644 index 00000000..a6517e5a --- /dev/null +++ b/.trellis/spec/backend/index.md @@ -0,0 +1,89 @@ +# 后端编码层 + +> DDE 应用后端开发规范。 + +--- + +## 技术栈 + +- Qt6 / Qt5 (CMake 双版本支持) +- DTK Widget 6 / DTK Widget (对应) +- C++17 +- CMake 3.10+ + +--- + +## 规范索引 + +| 文档 | 描述 | 何时阅读 | +|------|------|----------| +| [qt-dtk-guide.md](qt-dtk-guide.md) | Qt/DTK 编码模式、信号槽、DTK 组件 | 所有后端开发 | +| [architecture-decisions.md](architecture-decisions.md) | DDE 架构决策、核心规则 | 架构设计 | +| [architecture-design.md](architecture-design.md) | 架构设计规范、模块结构 | 功能开发 | +| [test-standards.md](test-standards.md) | Qt Test 测试规范 | 编写测试 | +| [bug-fix-standards.md](bug-fix-standards.md) | Bug 修复规范、最小修改原则 | 修复 Bug | +| [cmake-conventions.md](cmake-conventions.md) | CMake 约定、Qt6/Qt5 双版本 | 配置构建 | +| [plugin-architecture.md](plugin-architecture.md) | 插件架构、元数据格式 | 插件开发 | + +--- + +## 开发前检查清单 + +在编写后端代码前: + +- [x] 阅读 [qt-dtk-guide.md](qt-dtk-guide.md) 了解 Qt/DTK 编码模式 +- [x] 阅读 [architecture-decisions.md](architecture-decisions.md) 了解 DDE 核心规则 +- [ ] 确定使用的组件优先级(DTK 优先于 Qt 原生) +- [ ] 检查是否涉及数据库 → 参考 DB Schema 变更流程 +- [ ] 确定是否需要插件化 → 参考 [plugin-architecture.md](plugin-architecture.md) +- [ ] 检查是否涉及系统集成 → 参考 reference/ 文档 + +--- + +## 质量检查 + +代码完成后,验证: + +- [ ] 运行 `cpplint` 和 `clang-tidy` 检查 +- [ ] 构建 CMake 无警告 +- [ ] DTK 组件正确使用(非 Qt 原生) +- [ ] 内存管理正确(无泄漏) +- [ ] 线程安全(UI 更新在主线程) +- [ ] 国际化(用户文本使用 tr()) +- [ ] 单元测试覆盖率 >80% + +--- + +## 技术参考 + +- [reference/dtk-widgets-guide.md](reference/dtk-widgets-guide.md) - DTK 组件使用指南 +- [reference/gvfs-gio-integration.md](reference/gvfs-gio-integration.md) - GVfs/GIO 文件操作 +- [reference/dbus-service-usage.md](reference/dbus-service-usage.md) - DBus 服务集成 +- [reference/polkit-auth-workflow.md](reference/polkit-auth-workflow.md) - Polkit 权限控制 + +--- + +## 核心规则摘要 + +| 规则 | 要求 | +|------|------| +| DTK 组件 | 禁止 QMainWindow、QDialog、QMessageBox | +| 主题适配 | 禁止硬编码颜色 | +| 国际化 | 用户文本必须用 tr() | +| 线程安全 | 跨线程用 QueuedConnection | +| 内存管理 | 对象必须有父对象或所有者 | + +--- + +## 快速导航 + +### 按任务 + +| 任务 | 文档 | +|------|------| +| 创建主窗口 | [qt-dtk-guide.md](qt-dtk-guide.md#DMainWindow) | +| 使用对话框 | [qt-dtk-guide.md](qt-dtk-guide.md#DDialog) | +| 信号槽连接 | [qt-dtk-guide.md](qt-dtk-guide.md#信号槽模式) | +| 内存管理 | [qt-dtk-guide.md](qt-dtk-guide.md#内存管理模式) | +| 测试 | [test-standards.md](test-standards.md) | +| Bug 修复 | [bug-fix-standards.md](bug-fix-standards.md) | diff --git a/.trellis/spec/backend/plugin-architecture.md b/.trellis/spec/backend/plugin-architecture.md new file mode 100644 index 00000000..6b784124 --- /dev/null +++ b/.trellis/spec/backend/plugin-architecture.md @@ -0,0 +1,293 @@ +# 插件架构 + +> DDE 应用的插件化架构规范。 + +--- + +## 概览 + +本指南定义 DDE 应用的插件化架构,参考 dde-file-manager 插件系统。 + +--- + +## 插件系统架构 + +### 分层架构 + +``` +┌─────────────────────────────────────┐ +│ Application Core │ +├─────────────────────────────────────┤ +│ Plugin Framework (dpf) │ +├─────────────────────────────────────┤ +│ Plugin Interfaces │ +├─────────────────────────────────────┤ +│ Plugins │ +│ ├── Common │ +│ ├── Specific (filemanager...) │ +│ └── Custom │ +└─────────────────────────────────────┘ +``` + +### 核心组件 + +- **Plugin Framework (dpf)** - 插件加载和生命周期管理 +- **Plugin Interfaces** - 定义插件契约 +- **Plugins** - 实现具体功能 + +--- + +## 插件元数据 + +### JSON 元数据格式 + +```json +{ + "Name": "plugin-name", + "Version": "1.0.0", + "Depends": [ + { + "Name": "core", + "Version": "1.0.0" + } + ], + "Description": "Plugin description", + "Author": "author", + "Enabled": true +} +``` + +### 必须字段 + +| 字段 | 类型 | 必需 | 描述 | +|------|------|------|------| +| `Name` | string | 是 | 插件唯一标识 | +| `Version` | string | 是 | 语义化版本 | +| `Depends` | array | 否 | 依赖的其他插件 | + +--- + +## 插件生命周期 + +### 状态机 + +``` +Invalid → Reading → Readed → Loading → Loaded → Initialized → Started + ↓ ↓ ↓ ↓ + ←─────────←────────←────────←──────────────────────── + Stopped → Shutdown +``` + +### 状态说明 + +| 状态 | 描述 | +|------|------| +| `Invalid` | 插件元数据无效 | +| `Reading` | 正在读取元数据 | +| `Readed` | 元数据已读取 | +| `Loading` | 正在加载插件 | +| `Loaded` | 插件已加载(库) | +| `Initialized` | 插件已初始化 | +| `Started` | 插件正在运行 | +| `Stopped` | 插件已停止 | +| `Shutdown` | 插件已关闭 | + +--- + +## 插件接口定义 + +### 基础插件接口 + +```cpp +#include <QObject> +#include <dpf/plugin.hpp> + +class MyPlugin : public QObject, dpf::PluginInterface +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "org.deepin.plugin.MyPlugin") + Q_INTERFACES(dpf::PluginInterface) + +public: + explicit MyPlugin(QObject *parent = nullptr); + ~MyPlugin() override; + + // 插件接口 + bool initialize() override; + void start() override; + void stop() override; +}; +``` + +### 扩展点接口 + +```cpp +// 定义扩展点接口 +class IFileOperation : public QObject +{ + Q_OBJECT +public: + virtual ~IFileOperation() = default; + + virtual bool canHandle(const QString &path) = 0; + virtual bool copy(const QString &source, const QString &dest) = 0; + virtual bool move(const QString &source, const QString &dest) = 0; + virtual bool remove(const QString &path) = 0; +}; + +// 实现扩展点 +class LocalFilePlugin : public IFileOperation +{ + Q_OBJECT +public: + bool canHandle(const QString &path) override; + bool copy(const QString &source, const QString &dest) override; + bool move(const QString &source, const QString &dest) override; + bool remove(const QString &path) override; +}; +``` + +--- + +## 插件类型 + +### Common 插件 + +跨应用通用插件: +- 书签 +- 菜单 +- 标签 +- 图标 + +### Application 特定插件 + +特定应用的专用插件: +- filemanager - 文件管理器 +- filedialog - 文件对话框 +- desktop - 桌面 +- daemon - 守护进程 + +--- + +## CMake 配置 + +### 插件 CMakeLists.txt + +```cmake +# 插件库 +add_library(myplugin SHARED + src/myplugin.cpp + src/myplugin.h +) + +target_link_libraries(myplugin PRIVATE + Qt${QT_VERSION_MAJOR}::Core + dtk6::core + dfm6-framework +) + +# 插件元数据 +set(PLUGIN_METADATA_FILE "${CMAKE_CURRENT_SOURCE_DIR}/plugin.json") +set_target_properties(myplugin PROPERTIES + PLUGIN_METADATA "${PLUGIN_METADATA_FILE}" +) + +# 安装到插件目录 +install(TARGETS myplugin + LIBRARY DESTINATION ${PLUGIN_INSTALL_DIR}/myplugin +) + +# 安装元数据 +install(FILES ${PLUGIN_METADATA_FILE} + DESTINATION ${PLUGIN_INSTALL_DIR}/myplugin +) +``` + +--- + +## 命名空间约定 + +### 插件命名空间 + +```cpp +// 遵循下划线分隔的命名空间 +namespace filemanager { +namespace details { + // 内部实现 +} +namespace plugin { + // 插件接口 +} +} + +// 或使用完整命名空间 +namespace MyApplication { +namespace Plugins { +namespace MyPlugin { + // 插件实现 +} +} +} +``` + +### 避免命名冲突 + +- 使用应用前缀 +- 使用功能后缀 +- 避免通用名称(如 `Plugin`、`Manager`) + +--- + +## 事件系统 + +### 插件间通信 + +```cpp +// 发布事件 +dpf::EventDispatcherManager::instance() + .publish(Events::EventType::FileSelected, fileUrl); + +// 订阅事件 +dpf::EventDispatcherManager::instance() + .subscribe(Events::EventType::FileSelected, + this, &MyPlugin::handleFileSelected); +``` + +### 定义事件 + +```cpp +// events/eventtypes.h +namespace Events { +Q_NAMESPACE enum class EventType { + FileSelected = 1, + FileCreated, + FileDeleted, + FileModified +}; +Q_ENUM_NS(EventType) +} +``` + +--- + +## 最佳实践 + +1. **最小依赖** - 插件应该最小化依赖 +2. **清晰契约** - 通过接口明确定义契约 +3. **版本兼容** - 元数据中声明版本依赖 +4. **错误处理** - 妥善处理插件加载失败 +5. **资源清理** - 在 stop() 中清理资源 +6. **线程安全** - 确保插件跨线程安全 + +--- + +## 快速参考 + +| 任务 | 方法 | +|------|------| +| 定义插件 | 继承 `PluginInterface` + `Q_PLUGIN_METADATA` | +| 插件元数据 | `plugin.json` | +| 插件状态 | Invalid → Reading → Readed → Loading → Loaded | +| 事件发布 | `dpf::EventDispatcherManager::instance().publish()` | +| 事件订阅 | `dpf::EventDispatcherManager::instance().subscribe()` | +| 命名约定 | `{app}::{features}::{Plugin}` | diff --git a/.trellis/spec/backend/qt-dtk-guide.md b/.trellis/spec/backend/qt-dtk-guide.md new file mode 100644 index 00000000..60c6355f --- /dev/null +++ b/.trellis/spec/backend/qt-dtk-guide.md @@ -0,0 +1,507 @@ +# Qt/DTK 编程指南 + +> Qt6/DTK Widget 开发的编码规范和最佳实践。 + +--- + +## 概览 + +本指南涵盖 DDE 桌面应用开发中的 Qt/DTK 编码模式、DTK 组件使用和最佳实践。 + +--- + +## 信号槽模式 + +### 连接类型 + +```cpp +// Auto connection (default) - 根据上下文自动选择 +connect(sender, &Sender::signal, receiver, &Receiver::slot); + +// Direct connection - 同线程,立即执行 +connect(sender, &Sender::signal, receiver, &Receiver::slot, Qt::DirectConnection); + +// Queued connection - 跨线程,投递到事件循环 +connect(sender, &Sender::signal, receiver, &Receiver::slot, Qt::QueuedConnection); + +// Blocking queued - 跨线程,阻塞直到处理完成 +connect(sender, &Sender::signal, receiver, &Receiver::slot, Qt::BlockingQueuedConnection); +``` + +### Lambda 连接 + +```cpp +// 简单 lambda +connect(button, &DPushButton::clicked, this, [this]() { + handleButtonClick(); +}); + +// 捕获变量 +const QString id = "button-001"; +connect(button, &DPushButton::clicked, this, [this, id]() { + qDebug() << "Clicked:" << id; +}); + +// 带上下文追踪(上下文销毁时自动断开) +connect(timer, &QTimer::timeout, this, [this]() { + updateProgress(); +}); +``` + +### 断开连接模式 + +```cpp +// 断开发送者的所有连接 +disconnect(sender, nullptr, nullptr, nullptr); + +// 断开特定信号 +disconnect(sender, &Sender::signal, nullptr, nullptr); + +// 断开特定连接(使用 QMetaObject::Connection) +QMetaObject::Connection conn = connect(...); +disconnect(conn); +``` + +--- + +## 内存管理模式 + +### Parent-Child 模式 + +```cpp +// Qt 在父对象删除时自动处理子对象 +class MyWidget : public DWidget { +public: + MyWidget(QWidget *parent = nullptr) : DWidget(parent) { + // 子组件自动删除 + m_label = new DLabel(this); + m_button = new DPushButton(this); + } +private: + DLabel *m_label; + DPushButton *m_button; +}; +``` + +### Pimpl 模式(推荐用于 ABI 稳定性) + +```cpp +// myclass.h +class MyClass : public DWidget { + Q_OBJECT +public: + explicit MyClass(QWidget *parent = nullptr); + ~MyClass() override; + + void setValue(int value); + int value() const; + +private: + class Private; + QSharedPointer<Private> d; +}; + +// myclass.cpp +class MyClass::Private { +public: + int value = 0; + QString name; +}; + +MyClass::MyClass(QWidget *parent) + : DWidget(parent) + , d(new Private()) +{ +} + +MyClass::~MyClass() = default; +``` + +### 智能指针模式 + +```cpp +// QSharedPointer 用于共享所有权 +QSharedPointer<Resource> resource = QSharedPointer<Resource>::create(); + +// QScopedPointer 用于独占所有权 +QScopedPointer<Worker> worker(new Worker()); + +// QWeakPointer 用于非拥有引用 +QWeakPointer<Resource> weakRef = resource; +if (auto strong = weakRef.lock()) { + strong->doSomething(); +} +``` + +### Delete Later 模式 + +```cpp +// 在事件循环中安全删除 +widget->deleteLater(); + +// 在槽处理中 +void MyClass::onWorkComplete() { + m_worker->deleteLater(); + m_worker = nullptr; +} +``` + +--- + +## 线程模式 + +### QThread Worker 模式 + +```cpp +// Worker 类 +class Worker : public QObject { + Q_OBJECT +public: + explicit Worker(QObject *parent = nullptr); + +public slots: + void doWork(); + +signals: + void workComplete(const Result &result); +}; + +// 使用 +QThread *thread = new QThread; +Worker *worker = new Worker; +worker->moveToThread(thread); + +connect(thread, &QThread::started, worker, &Worker::doWork); +connect(worker, &Worker::workComplete, this, &MyClass::handleResult); +connect(worker, &Worker::workComplete, thread, &QThread::quit); +connect(thread, &QThread::finished, thread, &QThread::deleteLater); +connect(thread, &QThread::finished, worker, &Worker::deleteLater); + +thread->start(); +``` + +### Qt 并发模式 + +```cpp +#include <QtConcurrent> + +// 在线程池中运行 +QFuture<Result> future = QtConcurrent::run([this]() { + return expensiveComputation(); +}); + +// 监听完成 +QFutureWatcher<Result> *watcher = new QFutureWatcher<Result>(this); +connect(watcher, &QFutureWatcher<Result>::finished, this, [this, watcher]() { + Result result = watcher->result(); + handleResult(result); + watcher->deleteLater(); +}); +watcher->setFuture(future); +``` + +--- + +## 错误处理模式 + +### DDialog 错误显示 + +```cpp +void showError(const QString &title, const QString &message) { + DDialog dialog; + dialog.setIcon(QIcon::fromTheme("dialog-error")); + dialog.setTitle(title); + dialog.setMessage(message); + dialog.addButton(tr("OK"), true, DDialog::ButtonRecommend); + dialog.exec(); +} +``` + +### Result 模式 + +```cpp +struct Result { + bool success = false; + QString error; + QVariant data; + + static Result ok(const QVariant &data = {}) { + return {true, QString(), data}; + } + + static Result fail(const QString &error) { + return {false, error, QVariant()}; + } +}; +``` + +--- + +## DTK 组件指南 + +### DDialog + +```cpp +#include <DDialog> +#include <DApplication> + +// 简单对话框 +void showSimpleDialog() { + DDialog dialog; + dialog.setTitle(tr("Confirm Action")); + dialog.setMessage(tr("Are you sure you want to proceed?")); + dialog.setIcon(QIcon::fromTheme("dialog-question")); + + dialog.addButton(tr("Cancel"), false, DDialog::ButtonNormal); + dialog.addButton(tr("OK"), true, DDialog::ButtonRecommend); + + if (dialog.exec() == DDialog::Accepted) { + // 用户点击了 OK + } +} + +// 带自定义内容的对话框 +void showCustomDialog() { + DDialog dialog; + dialog.setTitle(tr("Settings")); + + QWidget *content = new QWidget; + QVBoxLayout *layout = new QVBoxLayout(content); + + DLineEdit *lineEdit = new DLineEdit; + lineEdit->setPlaceholderText(tr("Enter value")); + layout->addWidget(lineEdit); + + dialog.addContent(content); + dialog.addButton(tr("Cancel"), false, DDialog::ButtonNormal); + dialog.addButton(tr("Apply"), true, DDialog::ButtonRecommend); + + if (dialog.exec() == DDialog::Accepted) { + QString value = lineEdit->text(); + } +} +``` + +### DMainWindow + +```cpp +#include <DMainWindow> +#include <DTitlebar> +#include <DWidgetUtil> + +class MainWindow : public DMainWindow { + Q_OBJECT +public: + explicit MainWindow(QWidget *parent = nullptr) + : DMainWindow(parent) + { + // 设置标题栏 + titlebar()->setTitle("My Application"); + titlebar()->setMenu(new QMenu(this)); + + // 添加菜单动作 + QMenu *menu = titlebar()->menu(); + menu->addAction(tr("Settings"), this, &MainWindow::showSettings); + menu->addSeparator(); + menu->addAction(tr("About"), this, &MainWindow::showAbout); + + // 设置中心组件 + setCentralWidget(createCentralWidget()); + + // 窗口属性 + setMinimumSize(800, 600); + setWindowIcon(QIcon::fromTheme("preferences-system")); + } + +private: + QWidget* createCentralWidget() { + QWidget *widget = new QWidget(this); + QVBoxLayout *layout = new QVBoxLayout(widget); + return widget; + } +}; +``` + +### DLineEdit + +```cpp +#include <DLineEdit> + +// 基本使用 +DLineEdit *lineEdit = new DLineEdit; +lineEdit->setPlaceholderText(tr("Enter text")); +lineEdit->setClearButtonEnabled(true); + +// 带警告 +lineEdit->setAlert(true); +lineEdit->showAlertMessage(tr("Invalid input")); + +// 密码模式 +DLineEdit *passwordEdit = new DLineEdit; +passwordEdit->setEchoMode(QLineEdit::Password); + +// 信号连接 +connect(lineEdit, &DLineEdit::textChanged, this, [this](const QString &text) { + // 处理文本变化 +}); + +connect(lineEdit, &DLineEdit::returnPressed, this, [this]() { + // 处理回车键 +}); +``` + +--- + +## DTK 工具类 + +### DApplication + +```cpp +#include <DApplication> +#include <DWidgetUtil> + +int main(int argc, char *argv[]) { + DApplication a(argc, argv); + + // 应用信息 + a.setApplicationName("myapp"); + a.setApplicationVersion("1.0.0"); + a.setProductName(QObject::tr("My Application")); + a.setProductIcon(QIcon::fromTheme("myapp")); + + // 加载翻译 + a.loadTranslator(); + + MainWindow w; + w.show(); + + return a.exec(); +} +``` + +### DGuiApplicationHelper (主题) + +```cpp +#include <DGuiApplicationHelper> + +// 获取当前主题类型 +DGuiApplicationHelper::ColorType themeType = + DGuiApplicationHelper::instance()->themeType(); + +if (themeType == DGuiApplicationHelper::DarkType) { + // 深色主题 +} else { + // 浅色主题 +} + +// 监听主题变化 +connect(DGuiApplicationHelper::instance(), &DGuiApplicationHelper::themeTypeChanged, + this, [this](DGuiApplicationHelper::ColorType type) { + // 处理主题变化 + }); + +// 获取调色板(从不硬编码颜色) +QPalette palette = DGuiApplicationHelper::instance()->applicationPalette(); +QColor textColor = palette.color(QPalette::WindowText); +``` + +--- + +## 设置模式 + +```cpp +#include <QSettings> + +void saveSettings() { + QSettings settings("org.deepin", "myapp"); + settings.setValue("window/size", size()); + settings.setValue("window/position", pos()); + settings.setValue("value", m_value); +} + +void loadSettings() { + QSettings settings("org.deepin", "myapp"); + resize(settings.value("window/size", QSize(800, 600)).toSize()); + move(settings.value("window/position", QPoint(100, 100)).toPoint()); + m_value = settings.value("value", 0).toInt(); +} +``` + +--- + +## 最佳实践 + +### 始终使用 DTK 组件 + +| 推荐使用 | 禁止使用 | +|---------|---------| +| `DMainWindow` | `QMainWindow` | +| `DDialog` | `QDialog` | +| `DMessageBox` | `QMessageBox` | +| `DWidget` | `QWidget` | +| `DLabel` | `QLabel` | +| `DPushButton` | `QPushButton` | +| `DLineEdit` | `QLineEdit` | +| `DListView` | `QListView` | + +### 主题适配 + +```cpp +// ❌ 错误: 硬编码颜色 +label->setStyleSheet("color: #333333;"); + +// ✅ 正确: 使用调色板 +QPalette palette = DGuiApplicationHelper::instance()->applicationPalette(); +label->setPalette(palette); +``` + +### 国际化 + +```cpp +// 始终对面向用户的文本使用 tr() +button->setText(tr("Save")); +label->setText(tr("Hello World")); + +// 代码注释使用英文 +// Initialize the widget +m_widget = new DWidget(this); +``` + +--- + +## 反模式 + +```cpp +// ❌ 错误: 没有父对象的原始指针(内存泄漏) +QObject *obj = new QObject(); + +// ❌ 错误: 阻塞 UI 线程 +QThread::sleep(5); // 阻塞 UI! + +// ❌ 错误: 跨线程连接没有使用 queued connection +connect(threadWorker, &Worker::signal, uiWidget, &Widget::slot); // 可能崩溃! + +// ❌ 错误: 在错误线程中直接删除 QObject +delete worker; // 如果 worker 在不同线程中会崩溃! + +// ✅ 正确: 对跨线程删除使用 deleteLater +worker->deleteLater(); + +// ✅ 正确: 对跨线程信号使用 QueuedConnection +connect(threadWorker, &Worker::signal, uiWidget, &Widget::slot, Qt::QueuedConnection); +``` + +--- + +## 快速参考 + +| 任务 | 解决方案 | +|------|----------| +| 显示对话框 | `DDialog` + `exec()` | +| 创建主窗口 | `DMainWindow` + `titlebar()` | +| 获取用户输入 | `DLineEdit` + `textChanged` 信号 | +| 显示列表 | `DListView` + `QStandardItemModel` | +| 获取主题颜色 | `DGuiApplicationHelper::instance()->applicationPalette()` | +| 线程安全信号 | `Qt::QueuedConnection` | +| 安全删除 | `deleteLater()` | +| 设置存储 | `QSettings("org.deepin", "app")` | diff --git a/.trellis/spec/backend/reference/dbus-service-usage.md b/.trellis/spec/backend/reference/dbus-service-usage.md new file mode 100644 index 00000000..06a42515 --- /dev/null +++ b/.trellis/spec/backend/reference/dbus-service-usage.md @@ -0,0 +1,313 @@ +# DBus 服务使用指南 + +## 核心原则 + +**使用 DBus 事件驱动获取系统状态**,不要轮询 sysfs 文件。 + +## 何时使用 DBus + +| 场景 | 使用 | 原因 | +|------|------|------| +| 电池/电源状态 | ✅ DBus (UPower) | 事件驱动,实时通知 | +| 网络连接状态 | ✅ DBus (NetworkManager) | 事件驱动,状态完整 | +| 显示器亮度 | ✅ DBus (DDE Display) | 系统统一管理 | +| 音量控制 | ✅ DBus (DDE Audio) | 系统统一管理 | +| 磁盘信息 | ⚠️ DBus 或 udisks2 | 两者都可以 | +| 简单系统信息 | ⚠️ sysfs 或 DBus | 静态信息可轮询 | + +## 常用 DDE/FreeDesktop 服务 + +| 服务 | 总线 | 用途 | +|------|------|------| +| `org.freedesktop.UPower` | System | 电池、电源事件 | +| `org.freedesktop.NetworkManager` | System | 网络连接状态 | +| `org.deepin.dde.Display1` | Session | 显示器设置、亮度 | +| `org.deepin.dde.Audio1` | Session | 音量、音频设备 | +| `org.deepin.dde.SystemInfo1` | Session | 系统信息 | +| `org.freedesktop.UDisks2` | System | 磁盘、挂载 | + +## 基本用法 + +### 监听电源事件 (UPower) + +```cpp +#include <QDBusInterface> +#include <QDBusConnection> + +class PowerManager : public QObject { + Q_OBJECT + +public: + PowerManager(QObject *parent = nullptr) : QObject(parent) { + // 连接 UPower 服务 + m_upowerInterface = new QDBusInterface( + "org.freedesktop.UPower", + "/org/freedesktop/UPower", + "org.freedesktop.UPower", + QDBusConnection::systemBus(), + this + ); + + // 监听属性变化 + QDBusConnection::systemBus().connect( + "org.freedesktop.UPower", + "/org/freedesktop/UPower", + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + this, + SLOT(handlePropertiesChanged(QString, QVariantMap, QStringList)) + ); + + // 获取初始状态 + enumerateDevices(); + } + + void enumerateDevices() { + QDBusReply<QList<QDBusObjectPath>> reply = + m_upowerInterface->call("EnumerateDevices"); + + if (reply.isValid()) { + for (const QDBusObjectPath &path : reply.value()) { + readDeviceProperties(path.path()); + } + } + } + + void readDeviceProperties(const QString &path) { + QDBusInterface device( + "org.freedesktop.UPower", + path, + "org.freedesktop.DBus.Properties", + QDBusConnection::systemBus() + ); + + QDBusReply<QVariantMap> props = device.call("GetAll", + "org.freedesktop.UPower.Device"); + + if (props.isValid()) { + QVariantMap properties = props.value(); + + uint state = properties["State"].toUInt(); + double percentage = properties["Percentage"].toDouble(); + bool present = properties["IsPresent"].toBool(); + + qInfo() << "Battery:" << percentage << "%" + << "State:" << state + << "Present:" << present; + } + } + +private slots: + void handlePropertiesChanged(const QString &interface, + const QVariantMap &changed, + const QStringList &invalidated) { + if (interface == "org.freedesktop.UPower.Device") { + for (auto it = changed.begin(); it != changed.end(); ++it) { + if (it.key() == "Percentage") { + emit batteryLevelChanged(it.value().toDouble()); + } + if (it.key() == "State") { + emit powerStateChanged(it.value().toUInt()); + } + } + } + } + +signals: + void batteryLevelChanged(double percentage); + void powerStateChanged(uint state); + +private: + QDBusInterface *m_upowerInterface; +}; +``` + +### 监听网络状态 (NetworkManager) + +```cpp +#include <QDBusInterface> +#include <QDBusConnection> + +class NetworkMonitor : public QObject { + Q_OBJECT + +public: + NetworkMonitor(QObject *parent = nullptr) : QObject(parent) { + m_nmInterface = new QDBusInterface( + "org.freedesktop.NetworkManager", + "/org/freedesktop/NetworkManager", + "org.freedesktop.NetworkManager", + QDBusConnection::systemBus(), + this + ); + + // 监听状态变化 + QDBusConnection::systemBus().connect( + "org.freedesktop.NetworkManager", + "/org/freedesktop/NetworkManager", + "org.freedesktop.NetworkManager", + "PropertiesChanged", + this, + SLOT(handleNetworkStateChanged(QVariantMap)) + ); + + // 获取初始状态 + getNetworkState(); + } + + void getNetworkState() { + QDBusReply<uint> stateReply = m_nmInterface->call("state"); + + if (stateReply.isValid()) { + uint state = stateReply.value(); + + // NM 状态枚举 + // NM_STATE_CONNECTED_GLOBAL = 70 + // NM_STATE_CONNECTED_SITE = 60 + // NM_STATE_DISCONNECTED = 20 + + qInfo() << "Network state:" << state; + emit networkStateChanged(state); + } + } + +private slots: + void handleNetworkStateChanged(const QVariantMap &properties) { + if (properties.contains("State")) { + uint state = properties["State"].toUInt(); + emit networkStateChanged(state); + } + } + +signals: + void networkStateChanged(uint state); + +private: + QDBusInterface *m_nmInterface; +}; +``` + +### 控制显示器亮度 (DDE) + +```cpp +#include <QDBusInterface> +#include <QDBusConnection> + +class DisplayController : public QObject { + Q_OBJECT + +public: + DisplayController(QObject *parent = nullptr) : QObject(parent) { + m_displayInterface = new QDBusInterface( + "org.deepin.dde.Display1", + "/org/deepin/dde/Display1", + "org.deepin.dde.Display1", + QDBusConnection::sessionBus(), + this + ); + } + + double getBrightness() { + QDBusReply<double> reply = m_displayInterface->property("Brightness"); + return reply.isValid() ? reply.value() : 0.5; + } + + void setBrightness(double value) { + m_displayInterface->call("SetBrightness", qBound(0.0, value, 1.0)); + } + +signals: + void brightnessChanged(double value); + +private: + QDBusInterface *m_displayInterface; +}; +``` + +### 控制音量 (DDE Audio) + +```cpp +#include <QDBusInterface> +#include <QDBusConnection> + +class AudioController : public QObject { + Q_OBJECT + +public: + AudioController(QObject *parent = nullptr) : QObject(parent) { + m_audioInterface = new QDBusInterface( + "org.deepin.dde.Audio1", + "/org/deepin/dde/Audio1/Sink", + "org.deepin.dde.Audio1.Sink", + QDBusConnection::sessionBus(), + this + ); + } + + double getVolume() { + QDBusReply<double> reply = m_audioInterface->property("Volume"); + return reply.isValid() ? reply.value() : 0.5; + } + + void setVolume(double value) { + m_audioInterface->call("SetVolume", qBound(0.0, value, 1.0), true); + } + + bool isMuted() { + QDBusReply<bool> reply = m_audioInterface->property("Mute"); + return reply.isValid() && reply.value(); + } + + void setMute(bool muted) { + m_audioInterface->call("SetMute", muted); + } + +private: + QDBusInterface *m_audioInterface; +}; +``` + +## 错误处理 + +```cpp +QDBusInterface interface("org.example.Service", "/org/example/Path"); + +if (!interface.isValid()) { + qWarning() << "DBus interface invalid:" + << QDBusConnection::sessionBus().lastError().message(); + return; +} + +QDBusReply<QVariant> reply = interface.call("MethodName"); + +if (!reply.isValid()) { + qWarning() << "DBus call failed:" << reply.error().message(); + return; +} +``` + +## 常见错误 + +| 错误 | 后果 | 修复 | +|------|------|------| +| 轮询 `/sys/class/power_supply` | CPU 浪费、延迟 | → DBus 信号监听 | +| 使用错误的总线 | 连接失败 | → System vs Session | +| 不检查接口有效性 | 崩溃 | → `isValid()` 检查 | +| 忽略错误处理 | 无法诊断 | → 检查 `QDBusError` | +| 同步调用阻塞 UI | UI 冻结 | → 异步调用或后台线程 | + +## CMake 配置 + +```cmake +find_package(Qt6 REQUIRED COMPONENTS DBus) +# 或 +find_package(Qt5 REQUIRED COMPONENTS DBus) + +target_link_libraries(app PRIVATE Qt6::DBus) +``` + +## 相关资源 + +- [Qt DBus 文档](https://doc.qt.io/qt-6/qtdbus-index.html) +- [FreeDesktop DBus API](https://www.freedesktop.org/wiki/Software/dbus/) +- [UPower DBus API](https://upower.freedesktop.org/docs/) diff --git a/.trellis/spec/backend/reference/dtk-widgets-guide.md b/.trellis/spec/backend/reference/dtk-widgets-guide.md new file mode 100644 index 00000000..c114bebb --- /dev/null +++ b/.trellis/spec/backend/reference/dtk-widgets-guide.md @@ -0,0 +1,202 @@ +# DTK Widgets 使用指南 + +## 核心原则 + +**必须使用 DTK 控件替代 Qt 原生控件**,确保应用符合 DDE 设计规范并支持主题切换。 + +## 控件对照表 + +| 用途 | DTK 控件 | Qt 控件 (禁止) | 说明 | +|------|----------|---------------|------| +| 主窗口 | `DMainWindow` | `QMainWindow` | 支持 DDE 窗口装饰、模糊标题栏 | +| 对话框 | `DDialog` | `QDialog` | 自适应主题、圆角设计 | +| 消息框 | `DMessageBox` | `QMessageBox` | DDE 风格按钮布局 | +| 基础控件 | `DWidget` | `QWidget` | 支持 DTK 样式和调色板 | +| 表格 | `DTableView` | `QTableView` | DDE 风格滚动条、选中效果 | +| 树形 | `DTreeView` | `QTreeView` | 主题适配 | +| 列表 | `DListView` | `QListView` | 主题适配 | +| 按钮 | `DPushButton` | `QPushButton` | DDE 风格按钮 | +| 输入框 | `DLineEdit` | `QLineEdit` | 圆角边框、主题色 | +| 标签 | `DLabel` | `QLabel` | 支持 DTK 调色板 | + +## 主题适配 + +### 正确做法 + +```cpp +#include <DGuiApplicationHelper> +#include <DPalette> + +DWIDGET_USE_NAMESPACE + +// 获取当前主题调色板 +DPalette palette = DGuiApplicationHelper::instance()->applicationPalette(); + +// 使用调色板颜色 +QColor bgColor = palette.color(DPalette::Window); +QColor textColor = palette.color(DPalette::WindowText); +QColor highlight = palette.color(DPalette::Highlight); + +// 设置到控件 +widget->setPalette(palette); +``` + +### 错误做法 + +```cpp +// ❌ 硬编码颜色值 +setStyleSheet("background-color: #2b2b2b; color: #ffffff;"); + +// ❌ 手动检测主题然后切换 +if (isDarkMode) { + setStyleSheet("background-color: #2b2b2b;"); +} else { + setStyleSheet("background-color: #f0f0f0;"); +} +``` + +## 常用控件示例 + +### DMainWindow + +```cpp +#include <DMainWindow> +#include <DTitlebar> +#include <DWidget> + +DWIDGET_USE_NAMESPACE + +DMainWindow *window = new DMainWindow(); +window->setWindowTitle("应用名称"); +window->setMinimumSize(800, 600); + +// 自定义标题栏 +DTitlebar *titlebar = window->titlebar(); +titlebar->setIcon(QIcon::fromTheme("app-icon")); +titlebar->setAutoFillBackground(true); + +// 设置中央控件 +DWidget *centralWidget = new DWidget(window); +window->setCentralWidget(centralWidget); + +window->show(); +``` + +### DDialog + +```cpp +#include <DDialog> +#include <DLineEdit> + +DWIDGET_USE_NAMESPACE + +DDialog *dialog = new DDialog("标题", "内容描述"); +dialog->setIcon(QIcon::fromTheme("dialog-icon")); + +// 添加输入控件 +DLineEdit *lineEdit = new DLineEdit(dialog); +lineEdit->setPlaceholderText("请输入..."); +dialog->addContent(lineEdit); + +// 添加按钮 +dialog->addButton("取消", false, DDialog::ButtonNormal); +dialog->addButton("确定", true, DDialog::ButtonRecommend); + +// 连接信号 +connect(dialog, &DDialog::buttonClicked, + dialog, [](int index, const QString &text) { + if (text == "确定") { + // 处理确认 + } +}); + +dialog->exec(); +``` + +### DMessageBox + +```cpp +#include <DMessageBox> + +DWIDGET_USE_NAMESPACE + +// 信息 +DMessageBox::information(parent, "标题", "操作已完成"); + +// 确认 +int ret = DMessageBox::question( + parent, + "确认删除", + "确定要删除此文件吗?", + DMessageBox::Yes | DMessageBox::No, + DMessageBox::No +); + +if (ret == DMessageBox::Yes) { + // 删除文件 +} + +// 警告 +DMessageBox::warning(parent, "磁盘空间不足", "剩余空间小于 1GB"); + +// 错误 +DMessageBox::critical(parent, "保存失败", "无法写入磁盘,请检查权限"); +``` + +## 样式定制 + +### 使用 DTK 调色板 + +```cpp +#include <DStyleOption> +#include <DStyle> + +void CustomWidget::paintEvent(QPaintEvent *event) { + QPainter painter(this); + + DStyleOption opt; + opt.initFrom(this); + + DStyle *style = qobject_cast<DStyle*>(DApplication::style()); + QColor bgColor = style->palette(opt, this).color(QPalette::Window); + + painter.fillRect(rect(), bgColor); +} +``` + +### 监听主题变化 + +```cpp +#include <DGuiApplicationHelper> + +// 在构造函数中连接信号 +connect(DGuiApplicationHelper::instance(), + &DGuiApplicationHelper::themeTypeChanged, + this, + &YourClass::handleThemeChanged); + +void YourClass::handleThemeChanged(DGuiApplicationHelper::ColorType theme) { + // 更新自定义颜色 + updateColors(); + + // 刷新样式 + style()->unpolish(this); + style()->polish(this); + update(); +} +``` + +## 常见错误 + +| 错误 | 后果 | 修复 | +|------|------|------| +| 使用 `QMainWindow` | 主题切换失效、DDE 集成缺失 | → `DMainWindow` | +| 硬编码 `#2b2b2b` | 深色/浅色模式显示错误 | → `applicationPalette()` | +| 不使用 `DWIDGET_USE_NAMESPACE` | 编译错误 | → 添加宏 | +| 忽略 `themeTypeChanged` 信号 | 主题切换时 UI 不更新 | → 连接信号并刷新 | +| 直接设置 RGB 颜色 | 与系统主题不协调 | → 使用调色板 | + +## 相关资源 + +- [DTK Widget API 文档](https://github.com/linuxdeepin/dtkwidget) +- [DDE 设计规范](https://github.com/linuxdeepin/deepin-design-guidelines) diff --git a/.trellis/spec/backend/reference/gvfs-gio-integration.md b/.trellis/spec/backend/reference/gvfs-gio-integration.md new file mode 100644 index 00000000..6c1df76a --- /dev/null +++ b/.trellis/spec/backend/reference/gvfs-gio-integration.md @@ -0,0 +1,295 @@ +# GVfs/GIO 文件操作指南 + +## 核心原则 + +**访问网络协议(smb://, mtp://, dav://)必须使用 GIO API**,Qt 的 `QFile` 无法处理 GVfs 挂载的路径。 + +## 何时使用 GIO + +| 场景 | 使用 | 原因 | +|------|------|------| +| `smb://` 网络共享 | ✅ GIO | Qt 不支持 SMB 协议 | +| `mtp://` 移动设备 | ✅ GIO | Qt 不支持 MTP 协议 | +| `dav://` WebDAV | ✅ GIO | Qt 不支持 WebDAV 协议 | +| `ftp://` FTP | ✅ GIO | Qt 不支持 GVfs FTP 挂载 | +| `file:///` 本地文件 | ⚠️ QFile 或 GIO | 两者都可以,QFile 更简单 | + +## GVfs 架构 + +``` +用户空间 +├── GVfs 服务 (gvfsd) +│ ├── gvfsd-smb → SMB/CIFS +│ ├── gvfsd-mtp → MTP 设备 +│ ├── gvfsd-dav → WebDAV +│ └── gvfsd-ftp → FTP +│ +├── 挂载点 +│ └── /run/user/1000/gvfs/ +│ ├── smb-share:server=SERVER,share=SHARE/ +│ ├── mtp=host_[device_id]/ +│ └── dav:host=server.com,ssl=true/ +│ +└── 应用层 + ├── GIO API (推荐) → 直接 URI 访问 + └── QFile (仅本地) → 无法访问网络协议 +``` + +## 基本用法 + +### 读取文件 + +```cpp +#include <gio/gio.h> +#include <QByteArray> + +QByteArray readFile(const QString &uri) { + GFile *file = g_file_new_for_uri(uri.toUtf8().constData()); + GError *error = nullptr; + + // 打开文件 + GFileInputStream *input = g_file_read(file, nullptr, &error); + if (error) { + qWarning() << "Failed to open:" << error->message; + g_error_free(error); + g_object_unref(file); + return QByteArray(); + } + + // 读取内容 + QByteArray buffer; + char buf[4096]; + gssize bytes_read; + + while ((bytes_read = g_input_stream_read( + G_INPUT_STREAM(input), buf, sizeof(buf), nullptr, &error)) > 0) { + buffer.append(buf, bytes_read); + } + + if (error) { + qWarning() << "Read error:" << error->message; + g_error_free(error); + } + + // 清理 + g_object_unref(input); + g_object_unref(file); + + return buffer; +} + +// 使用示例 +QByteArray content = readFile("smb://server/share/file.txt"); +``` + +### 写入文件 + +```cpp +bool writeFile(const QString &uri, const QByteArray &data) { + GFile *file = g_file_new_for_uri(uri.toUtf8().constData()); + GError *error = nullptr; + + // 创建输出流 + GFileOutputStream *output = g_file_replace( + file, nullptr, false, G_FILE_CREATE_NONE, nullptr, &error + ); + + if (error) { + qWarning() << "Failed to create output:" << error->message; + g_error_free(error); + g_object_unref(file); + return false; + } + + // 写入数据 + gsize bytes_written; + gboolean success = g_output_stream_write_all( + G_OUTPUT_STREAM(output), + data.constData(), + data.size(), + &bytes_written, + nullptr, + &error + ); + + if (!success) { + qWarning() << "Write failed:" << error->message; + g_error_free(error); + } + + // 关闭流 + g_output_stream_close(G_OUTPUT_STREAM(output), nullptr, nullptr); + g_object_unref(output); + g_object_unref(file); + + return success; +} +``` + +### 获取文件信息 + +```cpp +struct FileInfo { + QString displayName; + qint64 size; + QString mimeType; + QDateTime modified; +}; + +FileInfo getFileInfo(const QString &uri) { + GFile *file = g_file_new_for_uri(uri.toUtf8().constData()); + GError *error = nullptr; + + FileInfo info; + + GFileInfo *fileInfo = g_file_query_info( + file, + G_FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME "," + G_FILE_ATTRIBUTE_STANDARD_SIZE "," + G_FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE "," + G_FILE_ATTRIBUTE_TIME_MODIFIED, + G_FILE_QUERY_INFO_NONE, + nullptr, + &error + ); + + if (error) { + qWarning() << "Query failed:" << error->message; + g_error_free(error); + g_object_unref(file); + return info; + } + + info.displayName = g_file_info_get_display_name(fileInfo); + info.size = g_file_info_get_size(fileInfo); + + const char *contentType = g_file_info_get_content_type(fileInfo); + if (contentType) { + info.mimeType = contentType; + } + + GTimeVal mtime; + if (g_file_info_get_modification_time(fileInfo, &mtime)) { + info.modified = QDateTime::fromSecsSinceEpoch(mtime.tv_sec); + } + + g_object_unref(fileInfo); + g_object_unref(file); + + return info; +} +``` + +## 异步操作 + +**文件 I/O 必须在后台线程执行**,避免阻塞 UI: + +```cpp +#include <QFuture> +#include <QtConcurrent> + +class GVfsFileHandler : public QObject { + Q_OBJECT + +public slots: + void readFileAsync(const QString &uri) { + QFutureWatcher<QByteArray> *watcher = + new QFutureWatcher<QByteArray>(this); + + connect(watcher, &QFutureWatcher<QByteArray>::finished, + this, [this, watcher, uri]() { + try { + QByteArray data = watcher->result(); + emit fileReadComplete(uri, data); + } catch (...) { + emit fileReadError(uri, "Unknown error"); + } + watcher->deleteLater(); + }); + + QFuture<QByteArray> future = QtConcurrent::run([uri]() { + return readFile(uri); // 同步 GIO 操作在后台线程 + }); + + watcher->setFuture(future); + } + +signals: + void fileReadComplete(const QString &uri, const QByteArray &data); + void fileReadError(const QString &uri, const QString &error); +}; +``` + +## 错误处理 + +```cpp +GError *error = nullptr; +GFile *file = g_file_new_for_uri(uri); + +GFileInputStream *input = g_file_read(file, nullptr, &error); +if (error) { + switch (error->code) { + case G_IO_ERROR_NOT_FOUND: + qWarning() << "File not found:" << uri; + break; + case G_IO_ERROR_PERMISSION_DENIED: + qWarning() << "Permission denied:" << uri; + break; + case G_IO_ERROR_HOST_NOT_FOUND: + qWarning() << "Host not found:" << uri; + break; + case G_IO_ERROR_NOT_MOUNTED: + qWarning() << "Volume not mounted:" << uri; + break; + default: + qWarning() << "GIO error:" << error->message; + } + g_error_free(error); +} +``` + +## 常见错误 + +| 错误 | 后果 | 修复 | +|------|------|------| +| `QFile("smb://...")` | 打开失败 | → `g_file_new_for_uri()` | +| 主线程 GIO 操作 | UI 冻结 | → `QtConcurrent::run()` | +| 忽略 `GError` | 无法诊断问题 | → 检查 error 参数 | +| 忘记 `g_object_unref` | 内存泄漏 | → 使用 RAII 包装 | +| 硬编码 GVfs 挂载点 | 路径可能变化 | → 使用 URI API | + +## GVfs 挂载点参考 + +**不要硬编码这些路径**,优先使用 GIO URI API: + +```bash +# GVfs 挂载点位置 +/run/user/[UID]/gvfs/ + +# SMB 挂载点格式 +smb-share:server=SERVER,share=SHARE_NAME/ + +# MTP 挂载点格式 +mtp=host_[device_id]/storage_[id]/ + +# WebDAV 挂载点格式 +dav:host=server.com,ssl=true,port=443,user=username/ + +# FTP 挂载点格式 +ftp:host=server.com,user=username/ +``` + +## CMake 配置 + +```cmake +find_package(PkgConfig REQUIRED) +pkg_check_modules(GIO REQUIRED gio-2.0) + +target_include_directories(app PRIVATE ${GIO_INCLUDE_DIRS}) +target_link_libraries(app PRIVATE ${GIO_LIBRARIES}) +``` + +## 相关资源 + +- [GIO API 文档](https://docs.gtk.org/gio/) +- [GVfs 文档](https://wiki.gnome.org/Projects/GVfs) diff --git a/.trellis/spec/backend/reference/polkit-auth-workflow.md b/.trellis/spec/backend/reference/polkit-auth-workflow.md new file mode 100644 index 00000000..484e8e14 --- /dev/null +++ b/.trellis/spec/backend/reference/polkit-auth-workflow.md @@ -0,0 +1,304 @@ +# Polkit 鉴权工作流 + +## 核心原则 + +**使用 Polkit 进行权限提升**,禁止以 root 身份运行整个应用。 + +## 何时使用 Polkit + +| 场景 | 使用 | 原因 | +|------|------|------| +| 修改 `/etc` 配置 | ✅ Polkit | 系统级操作需要鉴权 | +| 安装/卸载软件 | ✅ Polkit | 系统级操作 | +| 修改系统服务 | ✅ Polkit | 系统级操作 | +| 访问硬件设备 | ✅ Polkit | 需要特殊权限 | +| 普通文件操作 | ❌ 不需要 | 用户权限即可 | +| 应用配置修改 | ❌ 不需要 | 用户主目录内操作 | + +## Polkit 架构 + +``` +┌─────────────────┐ +│ 你的应用 │ +│ (非特权进程) │ +└────────┬────────┘ + │ 请求授权 + ↓ +┌─────────────────┐ +│ Polkit Agent │ +│ (认证对话框) │ +│ 由 DDE 提供 │ +└────────┬────────┘ + │ 用户输入密码 + ↓ +┌─────────────────┐ +│ Polkit Daemon │ +│ (系统服务) │ +│ 验证凭据 │ +└────────┬────────┘ + │ 授权结果 + ↓ +┌─────────────────┐ +│ Helper 程序 │ +│ (以 root 运行) │ +│ 执行特权操作 │ +└─────────────────┘ +``` + +## 方法 1: PolkitQt5-1 直接鉴权 + +### CMake 配置 + +```cmake +find_package(PolkitQt5-1 REQUIRED) +# 或 Qt6 +find_package(PolkitQt6-1 REQUIRED) + +target_link_libraries(app PRIVATE PolkitQt5-1::PolkitQt5-1) +``` + +### 基本用法 + +```cpp +#include <PolkitQt1/Authority> +#include <PolkitQt1/Subject> + +using namespace PolkitQt1; + +class PrivilegedOperation : public QObject { + Q_OBJECT + +public: + explicit PrivilegedOperation(QObject *parent = nullptr) + : QObject(parent) {} + + void performOperation() { + // 检查权限 + Authority::instance()->checkAuthorizationAsync( + "org.deepin.dde.policy.authentication", + UnixProcessSubject(QCoreApplication::applicationPid()), + Authority::AllowUserInteraction, + this, + SLOT(authorizationResult(PolkitQt1::Authority::Result)) + ); + } + +private slots: + void authorizationResult(Authority::Result result) { + switch (result) { + case Authority::Yes: + qInfo() << "Authorization granted"; + performPrivilegedAction(); + break; + case Authority::No: + qWarning() << "Authorization denied"; + emit authorizationFailed("Permission denied"); + break; + case Authority::Cancelled: + qWarning() << "Authorization cancelled by user"; + emit authorizationFailed("Cancelled"); + break; + } + } + + void performPrivilegedAction() { + // 执行需要特权的操作 + // 注意:这里仍然需要适当的权限机制 + } + +signals: + void authorizationFailed(const QString &reason); +}; +``` + +## 方法 2: Helper 程序 + pkexec (推荐) + +### 定义 Polkit Action + +创建 `/usr/share/polkit-1/actions/com.example.myapp.policy`: + +```xml +<?xml version="1.0" encoding="UTF-8"?> +<policyconfig> + <vendor>Deepin</vendor> + <vendor_url>https://www.deepin.org</vendor_url> + <icon_name>deepin-editor</icon_name> + + <action id="com.example.myapp.modify-system-config"> + <description>Modify system configuration</description> + <message>Authentication is required to modify system configuration</message> + <defaults> + <allow_any>auth_admin</allow_any> + <allow_inactive>auth_admin</allow_inactive> + <allow_active>auth_admin_keep</allow_active> + </defaults> + <annotate key="org.freedesktop.policykit.exec.path">/usr/lib/myapp/myapp-helper</annotate> + <annotate key="org.freedesktop.policykit.exec.allow_gui">true</annotate> + </action> +</policyconfig> +``` + +### Helper 程序 + +```cpp +// myapp-helper.cpp +// 以 root 身份运行,执行特权操作 + +#include <QCoreApplication> +#include <QFile> +#include <QTextStream> +#include <QFileInfo> + +int main(int argc, char *argv[]) { + QCoreApplication app(argc, argv); + + // 检查是否以 root 运行 + if (geteuid() != 0) { + qCritical() << "Helper must run as root"; + return 1; + } + + // 从 stdin 读取命令 + QTextStream in(stdin); + QString command = in.readLine(); + + if (command == "write-config") { + QString path = in.readLine(); + QString content = in.readLine(); + + // 验证路径(防止目录遍历攻击) + if (!path.startsWith("/etc/myapp/")) { + qCritical() << "Invalid path:" << path; + return 1; + } + + QFile file(path); + if (file.open(QIODevice::WriteOnly)) { + file.write(content.toUtf8()); + file.close(); + qDebug() << "OK"; + return 0; + } else { + qCritical() << "Failed to write:" << file.errorString(); + return 1; + } + } + + qCritical() << "Unknown command:" << command; + return 1; +} +``` + +### 主程序调用 Helper + +```cpp +#include <QProcess> +#include <QStandardPaths> + +class SystemConfigManager : public QObject { + Q_OBJECT + +public: + void writeConfig(const QString &path, const QString &content) { + QProcess *process = new QProcess(this); + + // 查找 helper 程序 + QString helperPath = QStandardPaths::findExecutable("myapp-helper", + QStringList() << "/usr/lib/myapp" << "/usr/libexec/myapp"); + + if (helperPath.isEmpty()) { + emit configWriteFailed("Helper program not found"); + return; + } + + // 使用 pkexec 启动 helper (会触发 Polkit 认证) + process->start("pkexec", QStringList() << helperPath); + + // 发送命令和参数 + process->write("write-config\n"); + process->write(path.toUtf8() + "\n"); + process->write(content.toUtf8() + "\n"); + process->closeWriteChannel(); + + connect(process, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished), + this, [this, process](int exitCode, QProcess::ExitStatus status) { + if (exitCode == 0) { + QString output = QString::fromUtf8(process->readAllStandardOutput()); + if (output.trimmed() == "OK") { + emit configWritten(); + } else { + emit configWriteFailed(output); + } + } else { + QString error = QString::fromUtf8(process->readAllStandardError()); + emit configWriteFailed(error.isEmpty() ? "Unknown error" : error); + } + process->deleteLater(); + }); + + connect(process, &QProcess::errorOccurred, + this, [this, process](QProcess::ProcessError error) { + emit configWriteFailed(QString("Process error: %1").arg(error)); + process->deleteLater(); + }); + } + +signals: + void configWritten(); + void configWriteFailed(const QString &error); +}; +``` + +## CMake 安装规则 + +```cmake +# 安装 helper 程序 +install(TARGETS myapp-helper + DESTINATION lib/myapp +) + +# 安装 Polkit action 文件 +install(FILES com.example.myapp.policy + DESTINATION share/polkit-1/actions +) +``` + +## 常见错误 + +| 错误 | 后果 | 修复 | +|------|------|------| +| `sudo ./app` 启动 | 安全风险、文件权限问题 | → Polkit + Helper | +| 整个应用以 root 运行 | 安全隐患、用户文件损坏 | → 仅 Helper 提权 | +| 不验证 Helper 输入 | 命令注入风险 | → 严格验证路径和参数 | +| 缺少 Polkit action | 认证失败 | → 创建 `.policy` 文件 | +| Helper 路径错误 | pkexec 失败 | → 使用绝对路径 | + +## 安全最佳实践 + +1. **最小权限原则**: Helper 只执行必要的特权操作 +2. **输入验证**: 严格验证所有传入参数 +3. **路径限制**: 限制可访问的目录范围 +4. **日志记录**: 记录所有特权操作 +5. **错误处理**: 不泄露敏感信息 + +## 调试 + +```bash +# 检查 Polkit 服务状态 +systemctl status polkit + +# 查看 Polkit 日志 +journalctl -u polkit -f + +# 测试 action 定义 +pkcheck --action-id com.example.myapp.modify-system-config --process $$ + +# 查看已注册的 actions +ls -la /usr/share/polkit-1/actions/ +``` + +## 相关资源 + +- [Polkit 文档](https://www.freedesktop.org/software/polkit/docs/latest/) +- [PolkitQt5-1 API](https://github.com/linuxdeepin/polkit-qt5-1) +- [Deepin Polkit Agent](https://github.com/linuxdeepin/deepin-polkit-agent) diff --git a/.trellis/spec/backend/test-standards.md b/.trellis/spec/backend/test-standards.md new file mode 100644 index 00000000..a93fbbfd --- /dev/null +++ b/.trellis/spec/backend/test-standards.md @@ -0,0 +1,379 @@ +# 测试规范 + +> DDE 应用的 Qt Test 测试框架规范。 + +--- + +## 概览 + +本指南涵盖使用 Qt Test 框架编写 DDE 应用测试的模式和最佳实践。 + +--- + +## Qt Test 框架 + +### 基本测试结构 + +```cpp +#include <QtTest> +#include <DWidget> + +class TestMyWidget : public QObject +{ + Q_OBJECT + +private slots: + // 设置/清理 + void initTestCase(); // 所有测试前调用一次 + void cleanupTestCase(); // 所有测试后调用一次 + void init(); // 每个测试前调用 + void cleanup(); // 每个测试后调用 + + // 测试用例 + void testConstructor(); + void testBasicFunctionality(); + void testEdgeCases(); + void testSignals(); +}; + +void TestMyWidget::initTestCase() +{ + // 一次性初始化 + qRegisterMetaType<MyCustomType>(); +} + +void TestMyWidget::cleanupTestCase() +{ + // 一次性清理 +} + +void TestMyWidget::init() +{ + // 每个测试的设置 + m_widget = new MyWidget(); +} + +void TestMyWidget::cleanup() +{ + // 每个测试的清理 + delete m_widget; + m_widget = nullptr; +} + +void TestMyWidget::testConstructor() +{ + QVERIFY(m_widget != nullptr); + QCOMPARE(m_widget->isEnabled(), true); +} + +QTEST_MAIN(TestMyWidget) +#include "test_mywidget.moc" +``` + +### 常见断言 + +```cpp +// 布尔检查 +QVERIFY(condition); // 条件为真 +QVERIFY2(condition, "message"); // 带自定义消息 + +// 相等性检查 +QCOMPARE(actual, expected); // 使用 operator== + +// 类型检查 +QCOMPARE(actual.type(), ExpectedType); + +// 空指针检查 +QVERIFY(ptr != nullptr); +QVERIFY(ptr == nullptr); + +// 字符串比较 +QCOMPARE(actualString, expectedString); +QVERIFY(actualString.contains("substring")); +QVERIFY(actualString.startsWith("prefix")); +QVERIFY(actualString.isEmpty()); + +// 数值比较 +QCOMPARE(actual, expected); +QVERIFY(actual > expected); +QVERIFY(qFuzzyCompare(actual, expected)); // 浮点数 +``` + +### 数据驱动测试 + +```cpp +private slots: + void testValidation_data(); + void testValidation(); + +void TestMyWidget::testValidation_data() +{ + QTest::addColumn<QString>("input"); + QTest::addColumn<bool>("expectedValid"); + QTest::addColumn<QString>("expectedError"); + + QTest::newRow("valid email") << "test@example.com" << true << ""; + QTest::newRow("invalid email") << "invalid" << false << "Invalid format"; + QTest::newRow("empty") << "" << false << "Required field"; + QTest::newRow("null chars") << QString() << false << "Required field"; +} + +void TestMyWidget::testValidation() +{ + QFETCH(QString, input); + QFETCH(bool, expectedValid); + QFETCH(QString, expectedError); + + bool isValid = m_widget->validate(input); + QCOMPARE(isValid, expectedValid); + + if (!expectedValid) { + QCOMPARE(m_widget->errorString(), expectedError); + } +} +``` + +### 信号测试 + +```cpp +#include <QSignalSpy> + +void TestMyWidget::testSignals() +{ + QSignalSpy spy(m_widget, &MyWidget::valueChanged); + + // 触发信号 + m_widget->setValue(42); + + // 验证信号已发出 + QCOMPARE(spy.count(), 1); + + // 验证信号参数 + QList<QVariant> arguments = spy.takeFirst(); + QCOMPARE(arguments.at(0).toInt(), 42); +} + +void TestMyWidget::testMultipleSignals() +{ + QSignalSpy valueSpy(m_widget, &MyWidget::valueChanged); + QSignalSpy errorSpy(m_widget, &MyWidget::errorOccurred); + + m_widget->setValue(100); + + QCOMPARE(valueSpy.count(), 1); + QCOMPARE(errorSpy.count(), 0); + + m_widget->setInvalidValue(); + + QCOMPARE(valueSpy.count(), 1); // 没有新信号 + QCOMPARE(errorSpy.count(), 1); +} +``` + +### 使用 QSignalSpy 进行 Mock + +```cpp +// 使用信号的 Mock 对象 +class MockService : public QObject { + Q_OBJECT +public: + void emitDataReady(const QString &data) { + emit dataReady(data); + } +signals: + void dataReady(const QString &data); +}; + +void TestIntegration::testServiceIntegration() +{ + MockService mockService; + QSignalSpy spy(&mockService, &MockService::dataReady); + + m_widget->setService(&mockService); + mockService.emitDataReady("test data"); + + QCOMPARE(spy.count(), 1); + QCOMPARE(m_widget->currentData(), QString("test data")); +} +``` + +### 测试异步操作 + +```cpp +void TestAsync::testAsyncOperation() +{ + QEventLoop loop; + QTimer::singleShot(100, &loop, &QEventLoop::quit); + + connect(m_worker, &Worker::finished, &loop, &QEventLoop::quit); + + m_worker->startAsync(); + loop.exec(); // 等待完成 + + QVERIFY(m_worker->isComplete()); + QCOMPARE(m_worker->result(), expectedResult); +} + +// 或使用 QTRY_VERIFY +void TestAsync::testWithTryVerify() +{ + m_worker->startAsync(); + + // 等待最多 5 秒满足条件 + QTRY_VERIFY_WITH_TIMEOUT(m_worker->isComplete(), 5000); + + QCOMPARE(m_worker->result(), expectedResult); +} +``` + +### 测试 D-Bus + +```cpp +void TestDBus::testDBusCall() +{ + // 注册 mock 服务 + QDBusConnection bus = QDBusConnection::sessionBus(); + + MockDBusAdaptor adaptor(m_mockService); + bus.registerService("org.deepin.Test"); + bus.registerObject("/Test", m_mockService); + + // 测试调用 + QDBusInterface iface("org.deepin.Test", "/Test"); + QDBusReply<QString> reply = iface.call("GetMethod"); + + QVERIFY(reply.isValid()); + QCOMPARE(reply.value(), QString("expected")); + + // 清理 + bus.unregisterObject("/Test"); + bus.unregisterService("org.deepin.Test"); +} +``` + +--- + +## 覆盖率分析 + +### 启用覆盖率 (CMake) + +```cmake +# 启用覆盖率标志 +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} --coverage -fprofile-arcs -ftest-coverage") +set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} --coverage") + +# 查找 gcov/lcov +find_program(GCOV_PATH gcov) +find_program(LCOV_PATH lcov) +find_program(GENHTML_PATH genhtml) +``` + +### 生成覆盖率报告 + +```bash +# 运行测试 +./test_myapp + +# 生成覆盖率数据 +lcov --capture --directory . --output-file coverage.info + +# 过滤系统头文件 +lcov --remove coverage.info '/usr/*' --output-file coverage.info + +# 生成 HTML 报告 +genhtml coverage.info --output-directory coverage_html +``` + +### 覆盖率目标 + +```cmake +# 添加覆盖率目标 +add_custom_target(coverage + COMMAND ${LCOV_PATH} --capture --directory . --output-file coverage.info + COMMAND ${LCOV_PATH} --remove coverage.info '/usr/*' --output-file coverage.info + COMMAND ${GENHTML_PATH} coverage.info --output-directory coverage_html + DEPENDS test_myapp + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + COMMENT "Generating coverage report..." +) +``` + +--- + +## 测试组织 + +### 目录结构 + +``` +tests/ +├── CMakeLists.txt +├── test_main.cpp # 公共测试设置 +├── unit/ +│ ├── test_widget.cpp +│ ├── test_model.cpp +│ └── test_service.cpp +├── integration/ +│ ├── test_dbus.cpp +│ └── test_workflow.cpp +└── mocks/ + ├── mock_service.h + └── mock_dbus.h +``` + +### 测试 CMakeLists.txt + +```cmake +enable_testing() + +# 查找 Qt Test +find_package(Qt${QT_VERSION_MAJOR} COMPONENTS Test REQUIRED) + +# 添加测试可执行文件 +add_executable(test_myapp + test_main.cpp + unit/test_widget.cpp + unit/test_model.cpp +) + +target_link_libraries(test_myapp + PRIVATE + Qt${QT_VERSION_MAJOR}::Test + myapp_lib +) + +# 使用 CTest 注册 +add_test(NAME test_myapp COMMAND test_myapp) +``` + +--- + +## 最佳实践 + +1. **每个类一个测试文件** +2. **描述性命名测试**: `test<方法>_<场景>_<期望结果>` +3. **测试边界情况**: null、空、最大值 +4. **保持测试独立**: 没有共享状态 +5. **正确使用 init/cleanup** +6. **Mock 外部依赖** +7. **目标 >80% 覆盖率** +8. **在 CI 中运行测试** +9. **测试成功和失败路径** + +--- + +## 测试用例模板 + +```cpp +void TestMyClass::testMethodName_scenario_expectedResult() +{ + // given + QString input = "test input"; + QString expected = "expected output"; + + // when + QString actual = m_object->methodName(input); + + // then + QCOMPARE(actual, expected); +} +``` diff --git a/.trellis/spec/big-question/dbus-async-vs-sync.md b/.trellis/spec/big-question/dbus-async-vs-sync.md new file mode 100644 index 00000000..db5e802e --- /dev/null +++ b/.trellis/spec/big-question/dbus-async-vs-sync.md @@ -0,0 +1,261 @@ +# Dbus 异步/同步问题 + +> 使用错误的 DBus 调用方式导致阻塞 UI 或数据不一致。 + +--- + +## 问题描述 (P1) + +在 DDE 应用中使用 DBus 时: +- 阻塞同步调用导致 UI 冻结 +- 异步调用结果未正确处理 +- 跨线程通信使用错误的连接类型 + +--- + +## 根本原因 + +1. 混淆同步/异步调用时机 +2. 系统服务调用未考虑网络延迟 +3. 信号槽连接类型不当 + +--- + +## 错误示例 + +### 1. 同步阻塞调用 + +```cpp +// ❌ 错误: UI 线程阻塞调用 +void checkBattery() { + QDBusInterface iface("org.freedesktop.UPower", + "/org/freedesktop/UPower"); + // 阻塞 UI 线程! + QDBusReply<QVariant> reply = iface.call("GetAll"); + processBatteryLevel(reply.value()); +} +``` + +### 2. 错误的总线类型 + +```cpp +// ❌ 错误: 系统服务使用 sessionBus +void getBrightness() { + // 错误: Power 属于系统服务 + QDBusConnection::sessionBus().connect( + "org.freedesktop.Power", + "/org/freedesktop/Power", + ...); + // 连接失败! +} +``` + +### 3. 异步调用无错误处理 + +```cpp +// ❌ 错误: 异步调用没有错误处理 +void asyncCall() { + QDBusInterface iface(...); + iface.asyncCall("Method"); // 没有监听结果或错误 +} +``` + +--- + +## 正确做法 + +### 使用异步调用 + +```cpp +void checkBattery() { + QDBusInterface iface("org.freedesktop.UPower", + "/org/freedesktop/UPower"); + QDBusPendingCall async = iface.asyncCall("GetAll"); + + // 设置监听 + QDBusPendingCallWatcher *watcher = + new QDBusPendingCallWatcher(async, this); + connect(watcher, &QDBusPendingCallWatcher::finished, + this, &MyClass::onBatteryUpdated); +} + +void onBatteryUpdated(QDBusPendingCallWatcher *watcher) { + QDBusPendingReply<QVariantMap> reply = *watcher; + if (reply.isError()) { + qWarning() << "DBus error:" << reply.error().message(); + return; + } + + QVariantMap values = reply.value(); + updateUI(values); + watcher->deleteLater(); +} +``` + +### 正确的总线类型 + +```cpp +// ✅ 正确: 系统服务使用 systemBus +void monitorPower() { + // ✅ 系统级服务 + QDBusConnection::systemBus().connect( + "org.freedesktop.UPower", + "/org/freedesktop/UPower", + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + this, SLOT(handlePropertyChange(QString, QVariantMap))); +} + +// ✅ 正确: 用户服务使用 sessionBus +void monitorClipboard() { + QDBusConnection::sessionBus().connect( + "org.deepin.dde.Clipboard", + "/org/deepin/dde/Clipboard", + ...); +} +``` + +### 异步调用错误处理 + +```cpp +void asyncCall() { + QDBusPendingCall async = iface.asyncCall("Method", arg1, arg2); + + QDBusPendingCallWatcher *watcher = + new QDBusPendingCallWatcher(async, this); + + connect(watcher, &QDBusPendingCallWatcher::finished, + this, [this, watcher]() { + QDBusPendingReply<QString> reply = *watcher; + if (reply.isError()) { + handleError(reply.error()); + } else { + handleResult(reply.value()); + } + watcher->deleteLater(); + }); +} +``` + +--- + +## DBus 调用流程 + +### 同步调用(仅后台线程) + +```cpp +// 仅在非 UI 线程中使用 +void workerFunction() { + QDBusConnection bus = QDBusConnection::systemBus(); + QDBusInterface iface(...); + + // 后台线程可以同步调用 + QDBusReply<QString> reply = iface.call("Method"); + if (reply.isValid()) { + process(reply.value()); + } +} +``` + +### 异步调用(UI 线程) + +```cpp +// UI 线程必须使用异步调用 +void uiFunction() { + QDBusConnection bus = QDBusConnection::systemBus(); + QDBusInterface iface(...); + + // 异步调用 + QDBusPendingCall async = iface.asyncCall("Method"); + QDBusPendingCallWatcher *watcher = + new QDBusPendingCallWatcher(async, this); + + connect(watcher, &QDBusPendingCallWatcher::finished, + this, &MyClass::onResult); +} +``` + +--- + +## 常见服务总线类型 + +| 服务类型 | 总线 | 示例 | +|---------|------|------| +| 系统服务 | `systemBus` | UPower, NetworkManager, Polkit, systemd | +| 用户服务 | `sessionBus` | Clipboard, Settings, App Services | + +--- + +## 检测方法 + +### 检查 DBus 连接 + +```cpp +bool checkDBusConnection() { + QDBusConnection sys = QDBusConnection::systemBus(); + QDBusConnection sess = QDBusConnection::sessionBus(); + + return sys.isConnected() && sess.isConnected(); +} +``` + +### 记录 DBus 调用耗时 + +```cpp +void callDBusMethod() { + QElapsedTimer timer; + timer.start(); + + QDBusReply reply = iface.call("Method"); + + qint64 elapsed = timer.elapsed(); + if (elapsed > 100) { // 超过 100ms 记录警告 + qWarning() << "DBus call took" << elapsed << "ms"; + } +} +``` + +--- + +## 预防措施 + +### 1. UI 线程异步调用 + +```cpp +// 规则: UI 线程所有 DBus 调用使用 asyncCall +// 后台线程可以同步调用,但推荐也用异步 +``` + +### 2. 总线类型命名约定 + +```cpp +// 清晰命名连接 +class DbusService { + QDBusConnection systemBus() const { + return QDBusConnection::systemBus(); + } + + QDBusConnection sessionBus() const { + return QDBusConnection::sessionBus(); + } +}; +``` + +### 3. 超时设置 + +```cpp +QDBusPendingCall async = iface.asyncCall("Method"); +async.setTimeout(5000); // 5 秒超时 +``` + +--- + +## 代码检查清单 + +- [ ] UI 线程中使用 asyncCall 而非 call +- [ ] 系统服务使用 systemBus +- [ ] 用户服务使用 sessionBus +- [ ] 所有异步调用监听完成和错误 +- [ ] 设置合理的超时时间 +- [ ] 记录慢调用(>100ms) +- [ ] 处理 DBus 连接失败情况 diff --git a/.trellis/spec/big-question/gthread-ui-thread-safety.md b/.trellis/spec/big-question/gthread-ui-thread-safety.md new file mode 100644 index 00000000..9ff332bf --- /dev/null +++ b/.trellis/spec/big-question/gthread-ui-thread-safety.md @@ -0,0 +1,232 @@ +# GThread UI 线程安全陷阱 + +> 在 DDE 应用中跨线程更新 UI 常见的安全问题。 + +--- + +## 问题描述 (P0) + +从非主线程直接更新 UI 组件导致应用崩溃或随机出现显示异常。 + +--- + +## 根本原因 + +Qt UI 组件不是线程安全的,只能在主线程(GUI 线程)中创建和操作。直接从工作线程访问或修改 UI 会导致未定义行为。 + +--- + +## 错误示例 + +### 1. 工作线程直接更新 UI + +```cpp +// ❌ 错误: 从工作线程更新 UI +class Worker : public QObject { + Q_OBJECT +public: + void doWork() { + // 崩溃!不在主线程 + label->setText("Processing..."); + progressBar->setValue(50); + } + QLabel *label; + QProgressBar *progressBar; +}; +``` + +### 2. AutoConnection 跨线程陷阱 + +```cpp +// ❌ 错误: 默认 AutoConnection 可能导致 DirectConnection +class Controller : public QObject { +public: + void startWorker() { + Worker *worker = new Worker; + worker->moveToThread(&workerThread); + + // AutoConnection 在跨线程时使用 QueuedConnection + // 但如果 worker 和 controller 在同一线程,使用 DirectConnection + // 然后如果 worker 移动到其他线程,问题就出现了 + connect(worker, &Worker::progress, this, &Controller::updateUI); + } +}; +``` + +### 3. 直接访问 QMetaObject::invokeMethod + +```cpp +// ❌ 错误: DirectConnection 在错误线程 +void Worker::notifyProgress(int value) { + QMetaObject::invokeMethod(ui, "setProgress", + Qt::DirectConnection, // 错误! + Q_ARG(int, value)); +} + +// ✅ 正确: 使用 Qt::QueuedConnection +void Worker::notifyProgress(int value) { + QMetaObject::invokeMethod(ui, "setProgress", + Qt::QueuedConnection, + Q_ARG(int, value)); +} +``` + +--- + +## 正确做法 + +### 使用信号槽 + QueuedConnection + +```cpp +class Worker : public QObject { + Q_OBJECT +signals: + void progressChanged(int value); + void workComplete(const QString &result); +}; + +class Controller : public QObject { + Q_OBJECT +public: + void startWorker() { + Worker *worker = new Worker; + worker->moveToThread(&workerThread); + + // ✅ 正确: 明确指定 QueuedConnection + connect(worker, &Worker::progressChanged, + this, &Controller::updateProgress, + Qt::QueuedConnection); + + connect(worker, &Worker::workComplete, + this, &Controller::handleComplete, + Qt::QueuedConnection); + } + +private slots: + void updateProgress(int value) { + // 在主线程中安全执行 + progressBar->setValue(value); + } +}; +``` + +### 检查当前线程 + +```cpp +// 检查是否在主线程 +void setSafeText(const QString &text) { + if (QThread::currentThread() == qApp->thread()) { + label->setText(text); + } else { + // 通过信号在主线程更新 + emit textChanged(text); + } +} +``` + +### QMetaObject::invokeMethod 线程安全 + +```cpp +// ✅ 正确: 使用 QueuedConnection +// 确保在主线程执行 +QThread *mainThread = qApp->thread(); +if (QThread::currentThread() != mainThread) { + QMetaObject::invokeMethod(ui, "setProgress", + Qt::QueuedConnection, + Q_ARG(int, value)); +} +``` + +--- + +## 连接类型选择 + +| 连接类型 | 说明 | 使用场景 | +|---------|------|---------| +| `AutoConnection` | 根据上下文自动选择 | 同线程用 Direct,跨线程用 Queued | +| `DirectConnection` | 立即调用 | 同线程,或确定安全的情况 | +| `QueuedConnection` | 投递到事件循环 | 跨线程,确保线程安全 | +| `BlockingQueuedConnection` | 阻塞等待 | 需要同步结果时(谨慎使用) | + +--- + +## 检测方法 + +### 静态分析 + +```cpp +// 断言检查 +Q_ASSERT_X(QThread::currentThread() == qApp->thread(), + "UI update must be on main thread"); +``` + +### 编译时检查 + +```cpp +// 可以使用 C++11 static_assert 检查线程 +constexpr bool IsMainThread() { + return true; // 运行时检查 +} +``` + +--- + +## 预防措施 + +### 1. 设计原则 + +- 所有 UI 操作必须在主线程 +- 工作线程只做计算,不访问 UI +- 通过信号槽通信 + +### 代码模式 + +```cpp +// Worker 类 - 无 UI 依赖 +class Worker : public QObject { + Q_OBJECT +public: + void process(const QString &data); +signals: + void finished(const QString &result); + void error(const QString &message); +}; + +// Controller 类 - 负责桥接 +class Controller : public QObject { + Q_OBJECT +public: + void start(const QString &data); + +private slots: + void onFinished(const QString &result); + void onError(const QString &message); +}; +``` + +### 3. 信号槽约定 + +```cpp +// ✅ 推荐: 跨线程信号总是指定 QueuedConnection +connect(worker, &Worker::signal, this, &Controller::slot, + Qt::QueuedConnection); + +// ✅ 推荐: 在头文件注释中说明线程要求 +/* + * @brief Update progress bar + * @note Must be called from main thread + */ +void updateProgress(int value); +``` + +--- + +## 代码检查清单 + +- [ ] 所有 UI 更新在主线程执行 +- [ ] 工作线程中设置 moveToThread +- [ ] 跨线程信号使用 Qt::QueuedConnection +- [ ] 在调试模式添加线程检查断言 +- [ ] 避免直接从工作线程访问 UI +- [ ] 考虑使用 QtConcurrent 替代手动线程管理 +- [ ] 测试多线程场景下 UI 更新 diff --git a/.trellis/spec/big-question/gvfs-mount-path-issues.md b/.trellis/spec/big-question/gvfs-mount-path-issues.md new file mode 100644 index 00000000..8138b5e0 --- /dev/null +++ b/.trellis/spec/big-question/gvfs-mount-path-issues.md @@ -0,0 +1,223 @@ +# GVfs 挂载路径问题 + +> 使用硬编码的 GVfs 挂载路径导致应用在不同会话间失效。 + +--- + +## 问题描述 (P1) + +应用中硬编码 GVfs 挂载路径 `/run/user/UID/gvfs/`,导致路径在不同会话或重启后失效。 + +--- + +## 根本原因 + +GVfs 挂载点: +1. 路径包含会话 ID,随每次登录而变化 +2. `/run/user/UID/gvfs/smb-share:server=...` 会话敏感 +3. 重启或重新登录后会重新生成 + +--- + +## 错误示例 + +### 硬编码 GVfs 路径 + +```cpp +// ❌ 错误: 硬编码 GVfs 挂载路径 +QString getSambaPath(const QString &server, const QString &share) { + // 重启后会失效! + return QString("/run/user/%1/gvfs/smb-share:server=%2,share=%3") + .arg(getuid()) + .arg(server) + .arg(share); +} + +// ❌ 错误: 保存到配置文件 +settings.setValue("lastFile", "/run/user/1000/gvfs/smb..."); +// 重启后路径无效 +``` + +--- + +## 正确做法 + +### 使用 GIO URI API + +```cpp +// ✅ 正确: 始终使用 GIO URI +#include <gio/gio.h> + +void openSmbFile(const QString &server, const QString &share) { + // 构建 URI + QString uri = QString("smb://%1/%2").arg(server, share); + + // 使用 GFile + GFile *file = g_file_new_for_uri(uri.toUtf8().constData()); + GFileInputStream *input = g_file_read(file, nullptr, &error); + + if (input) { + // 读取数据 + g_object_unref(input); + } + g_object_unref(file); +} +``` + +### 使用 gio-qt + +```cpp +// ✅ 正确: 使用 gio-qt 提供的 C++ 接口 +#include <QUrl> +#include <QCoreApplication> +#include <giomm/file.h> + +void openFile(const QUrl &uri) { + auto file = Gio::File::create_for_uri(uri.toString().toStdString()); + + // 异步读取 + file->read_async(sigc::mem_fun(*this, &MyClass::onFileRead), + Glib::PRIORITY_DEFAULT); +} +``` + +### 配置存储 URI 而非路径 + +```cpp +// ✅ 正确: 存储 URI +settings.setValue("lastFile", "smb://server/share/file.txt"); + +// ✅ 当需要时动态解析 +QString getLastFilePath() { + QString uri = settings.value("lastFile").toString(); + // 使用 GIO API 访问 UR,而非转换为路径 + return uri; // 返回 URI 或使用 GIO API 处理 +} +``` + +--- + +## 挂载点路径解析 + +### 获取当前挂载信息 + +```cpp +// 使用 gio mount 命令 +QProcess process; +process.start("gio", QStringList() << "mount" << "-l"); +process.waitForFinished(); +QString mounts = process.readAllStandardOutput(); + +// 解析挂载点 +``` + +### 使用 GMountMonitor 监听挂载变化 + +```cpp +#include <gio/gio.h> + +void setupMountMonitor() { + GVolumeMonitor *monitor = g_volume_monitor_get(); + + g_signal_connect(monitor, "mount-added", + G_CALLBACK(onMountAdded), this); + g_signal_connect(monitor, "mount-removed", + G_CALLBACK(onMountRemoved), this); +} +``` + +--- + +## 检测方法 + +### 测试路径有效性 + +```cpp +bool isPathValid(const QString &path) { + GFile *file = g_file_new_for_path(path.toUtf8().constData()); + bool exists = g_file_query_exists(file, nullptr); + g_object_unref(file); + return exists; +} +``` + +### 验证 URI + +```cpp +bool isUriValid(const QString &uri) { + GFile *file = g_file_new_for_uri(uri.toUtf8().constData()); + bool exists = g_file_query_exists(file, nullptr); + g_object_unref(file); + return exists; +} +``` + +--- + +## 预防措施 + +### 1. 始终使用 URI + +```cpp +// ✅ 错误: 使用路径 +QString path = "/run/user/1000/gvfs/smb-share:..."; + +// ✅ 正确: 使用 URI +QUrl uri("smb://server/share/file.txt"); +``` + +### 2. 配置文件存储 URI + +```cpp +// 配置文件示例 +{ + "recentFiles": [ + "smb://server/file1.txt", + "file:///home/user/file2.txt", + "mtp://device/file3.mp3" + ] +} +``` + +### 3. 运行时路径解析 + +```cpp +// 只有在显示给用户时才解析路径 +QString getDisplayName(const QString &uri) { + GFile *file = g_file_new_for_uri(uri.toUtf8().constData()); + char *path = g_file_get_path(file); + QString displayPath = QString::fromUtf8(path); + g_free(path); + g_object_unref(file); + return displayPath; +} +``` + +### 4. 挂载状态检查 + +```cpp +// 检查远程位置是否已挂载 +bool isMounted(const QString &uri) { + GVolumeMonitor *monitor = g_volume_monitor_get(); + GMount *mount = g_volume_monitor_get_mount_for_uuid( + monitor, uri.toUtf8().constData()); + + if (mount) { + g_object_unref(mount); + return true; + } + return false; +} +``` + +--- + +## 代码检查清单 + +- [ ] 所有远程文件访问使用 GIO URI API +- [ ] 配置文件中存储 URI 而非路径 +- [ ] 避免硬编码 `/run/user/UID/gvfs/` 路径 +- [ ] 使用 gio-qt 或 C API 处理远程文件 +- [ ] 添加挂载状态检查 +- [ ] 提供用户挂载失败的提示 +- [ ] 监听挂载/卸载事件 diff --git a/.trellis/spec/big-question/index.md b/.trellis/spec/big-question/index.md new file mode 100644 index 00000000..78f6c297 --- /dev/null +++ b/.trellis/spec/big-question/index.md @@ -0,0 +1,49 @@ +# 常见问题和陷阱 + +> 快速查阅常见问题和解决方案。 + +--- + +## 概览 + +本层收录 DDE 开发中的常见问题和陷阱,提供快速解答和解决方案。 + +--- + +## 问题索引 + +| 问题 | 严重性 | 描述 | +|------|--------|------| +| [qt-memory-management-pitfalls.md](qt-memory-management-pitfalls.md) | P1 | Qt 对象生命周期和内存管理 | +| [gthread-ui-thread-safety.md](gthread-ui-thread-safety.md) | P0 | 跨线程更新 UI | +| [gvfs-mount-path-issues.md](gvfs-mount-path-issues.md) | P1 | GVfs 挂载路径会话敏感 | +| [dbus-async-vs-sync.md](dbus-async-vs-sync.md) | P1 | DBus 调用阻塞 UI | + +--- + +## 严重性说明 + +| 严重性 | 说明 | +|--------|------| +| P0 | 关键问题,必须立即解决 | +| P1 | 重要问题,应该尽快解决 | +| P2 | 一般问题,按优先级解决 | + +--- + +## 使用方式 + +### 查找问题 + +1. 描述问题症状 +2. 在本文档中查找匹配的问题 +3. 按照解决方案修复 +4. 验证修复效果 + +### 贡献新问题 + +发现文档中没有收录的新问题时: +1. 记录问题描述和根因 +2. 记录错误示例和正确做法 +3. 记录检测方法和预防措施 +4. 添加到本文档 diff --git a/.trellis/spec/big-question/qt-memory-management-pitfalls.md b/.trellis/spec/big-question/qt-memory-management-pitfalls.md new file mode 100644 index 00000000..f0027368 --- /dev/null +++ b/.trellis/spec/big-question/qt-memory-management-pitfalls.md @@ -0,0 +1,204 @@ +# Qt 内存管理陷阱 + +> Qt 对象生命周期和内存管理的常见陷阱及解决方案。 + +--- + +## 问题描述 (P1) + +开发 DDE 应用时,经常出现内存泄漏或崩溃,通常与 Qt Parent-Child 机制、信号槽连接和 deleteLater 使用不当有关。 + +--- + +## 根本原因 + +Qt 的自动内存管理依赖于 Parent-Child 关系和信号槽的生命周期绑定,但不当使用会导致: +1. 对象被删除后仍然被访问 +2. 跨线程删除导致崩溃 +3. 循环引用导致内存泄漏 + +--- + +## 错误示例 + +### 1. 没有父对象的临时对象泄漏 + +```cpp +// ❌ 错误: 没有父对象,泄漏 +void showDialog() { + DDialog *dialog = new DDialog(); + dialog->exec(); + // 泄漏!从未删除 +} + +// ✅ 正确: 设置父对象或使用栈对象 +void showDialog() { + DDialog dialog; // 栈对象自动析构 + dialog.exec(); +} + +// ✅ 正确: 或者正确管理生命周期 +void showDialog() { + DDialog *dialog = new DDialog(parentWidget); + connect(dialog, &DDialog::finished, dialog, &DDialog::deleteLater); + dialog->exec(); +} +``` + +### 2. 跨线程删除崩溃 + +```cpp +// ❌ 错误: 在错误线程中删除 +class Manager : public QObject { +public: + void stopWorker() { + delete worker; // 崩溃!worker 在不同线程 + } + Worker *worker; +}; + +// ✅ 正确: 使用 deleteLater +void stopWorker() { + worker->deleteLater(); // 安全删除 +} +``` + +### 3. 信号槽连接导致的循环引用 + +```cpp +// ❌ 错误: 循环引用 +class Parent : public QObject { + void setChild(Child *child) { + m_child = child; + connect(child, &Child::signal, this, &Parent::handler); + connect(this, &Parent::signal, child, &Child::handler); + // 互相引用,可能泄漏 + } +}; + +// ✅ 正确: 明确所有权或使用 QWeakPointer +class Parent : public QObject { + void setChild(Child *child) { + child->setParent(this); // 明确 Parent 拥有 Child + connect(child, &Child::signal, this, &Parent::handler); + // 连接到 Parent 的槽,Parent 拥有 Child + } +}; +``` + +### 4. deleteLater 后立即使用 + +```cpp +// ❌ 错误: 计划删除后继续使用 +void cleanup() { + widget->deleteLater(); + widget->show(); // 未定义行为! +} + +// ✅ 正确: 释放引用 +void cleanup() { + widget->deleteLater(); + widget = nullptr; // 立即释放引用 +} + +// ✅ 正确: 使用信号处理 +void cleanup() { + connect(widget, &QObject::destroyed, this, [this]() { + // widget 已完全删除 + }); + widget->deleteLater(); +} +``` + +--- + +## 检测方法 + +### Valgrind + +```bash +valgrind --leak-check=full --show-leak-kinds=all ./myapp +``` + +### Qt 内存分析 + +```cpp +// Qt 内存调试模式 +#include <QtCore> +QLoggingCategory::setFilterRules("qt.core.memory.debug=true"); +``` + +### ASAN (AddressSanitizer) + +```cmake +# CMake +target_compile_options(myapp PRIVATE -fsanitize=address) +target_link_options(myapp PRIVATE -fsanitize=address) +``` + +--- + +## 预防措施 + +### 1. 始终设置父对象 + +```cpp +// 规则: 所有没有明确所有权的对象都应该设置父对象 +class MyWidget : public DWidget { + MyWidget(QWidget *parent) : DWidget(parent) { + m_label = new DLabel(this); // 自动删除 + m_button = new DPushButton(this); // 自动删除 + } +}; +``` + +### 2. 使用智能指针 + +```cpp +// 规则: 需要共享所有权时使用 QSharedPointer +QSharedPointer<Resource> resource = QSharedPointer<Resource>::create(); + +// 规则: 非拥有引用使用 QWeakPointer +QWeakPointer<Resource> weakRef = resource; +if (auto strong = weakRef.lock()) { + strong->use(); +} +``` + +### 3. 正确的信号槽连接 + +```cpp +// 规则: 跨线程使用 QueuedConnection +connect(worker, &Worker::signal, ui, &UI::handler, + Qt::QueuedConnection); + +// 规则: 连接时使用上下文对象 +connect(sender, &Sender::signal, receiver, &Receiver::slot); +// receiver 被销毁时连接自动断开 +``` + +### 4. 对象生命周期明确 + +```cpp +// 规则: 在头文件的注释中说明对象所有权 +class Controller : public QObject { + // 以下对象由 Controller 拥有 + QScopedPointer<Worker> m_worker; + QSharedPointer<DataCache> m_cache; + + // 以下为非拥有引用 + QWeakPointer<Config> m_config; +}; +``` + +--- + +## 代码检查清单 + +- [ ] 所有 `new` 创建的对象都有父对象或所有者 +- [ ] 跨线程操作使用 `deleteLater` 而非直接 `delete` +- [ ] 信号槽连接使用正确的连接类型 (Direct/Queued) +- [ ] 检查潜在的循环引用 +- [ ] 在析构函数中断开信号槽 +- [ ] 使用 RAII 模式管理资源 +- [ ] 定期运行 Valgrind/ASAN 检测泄漏 diff --git a/.trellis/spec/dfm-burn/error-handling.md b/.trellis/spec/dfm-burn/error-handling.md new file mode 100644 index 00000000..2e6efe91 --- /dev/null +++ b/.trellis/spec/dfm-burn/error-handling.md @@ -0,0 +1,203 @@ +# dfm-burn 错误处理规范 + +## 概述 + +dfm-burn 使用简单直接的错误处理模式:操作返回 `bool`,错误信息通过 `lastError()` 获取。 + +## 错误处理模式 + +### 1. errorMsg 成员变量 + +**私有实现类**: +```cpp +class DOpticalDiscManagerPrivate +{ +public: + QString errorMsg; // 存储最后一次操作错误信息 +}; +``` + +### 2. 公共接口返回值 + +```cpp +class DOpticalDiscManager : public QObject +{ + Q_OBJECT + +public: + // 所有操作返回 bool + bool setStageFile(const QString &diskPath, const QString &isoPath); + bool commit(const BurnOptions &opts, int speed = 0, const QString &volId = "ISOIMAGE"); + bool eraseDisc(EraseType type); + + // 获取错误信息 + QString lastError() const; +}; +``` + +### 3. 使用示例 + +```cpp +DOpticalDiscManager manager(devicePath); + +if (!manager.commit(options)) { + QString err = manager.lastError(); + qWarning() << "Burn failed:" << err; +} else { + qDebug() << "Burn successful"; +} +``` + +## 错误设置模式 + +**位置**: `src/dfm-burn/dfm-burn-lib/dopticaldiscmanager.cpp:34-47` + +```cpp +bool DOpticalDiscManager::setStageFile(const QString &diskPath, const QString &isoPath) +{ + dptr->errorMsg.clear(); + + QUrl diskUrl = QUrl::fromLocalFile(diskPath); + if (diskUrl.isEmpty() || !diskUrl.isValid()) { + dptr->errorMsg = "Invalid disk path"; + return false; + } + + QUrl isoUrl = QUrl::fromLocalFile(isoPath); + if (isoUrl.isEmpty() || !isoUrl.isValid()) { + dptr->errorMsg = "Invalid ISO path"; + return false; + } + + // ... 执行操作 + return true; +} +``` + +## 引擎错误传递 + +**位置**: `src/dfm-burn/dfm-burn-lib/dopticaldiscmanager.cpp:61-101` + +```cpp +bool DOpticalDiscManager::commit(const BurnOptions &opts, int speed, const QString &volId) +{ + QScopedPointer<DXorrisoEngine> xorrisoEngine { new DXorrisoEngine }; + + connect(xorrisoEngine.data(), &DXorrisoEngine::jobStatusChanged, this, + [this, ptr = QPointer(xorrisoEngine.data())](JobStatus status, int progress, QString speed) { + if (ptr) + Q_EMIT jobStatusChanged(status, progress, speed, ptr->takeInfoMessages()); + }, + Qt::DirectConnection); + + connect(xorrisoEngine.data(), &DXorrisoEngine::errorOccurred, this, + [this, ptr = QPointer(xorrisoEngine.data())](const QString &err) { + if (ptr) + dptr->errorMsg = err; + }, + Qt::DirectConnection); + + ret = xorrisoEngine->doBurn(...); + + return ret; +} +``` + +## 错误处理最佳实践 + +### DO - 应该做 + +1. **在操作开始时清空错误** +```cpp +bool MyClass::doSomething() { + d->errorMsg.clear(); // 清空之前的错误 + // ... 执行操作 +} +``` + +2. **返回 bool 表示操作结果** +```cpp +bool MyClass::doSomething() { + if (somethingWrong) { + d->errorMsg = "Something went wrong"; + return false; + } + return true; +} +``` + +3. **使用 lastError() 获取错误** +```cpp +if (!obj.doOperation()) { + qWarning() << obj.lastError(); +} +``` + +### DON'T - 不应该做 + +1. **不要忽略错误检查** +```cpp +// 错误: +obj.doOperation(); // 不检查返回值 + +// 正确: +if (!obj.doOperation()) { + // 处理错误 +} +``` + +2. **不要使用异常** +```cpp +// 错误: +if (invalid) { + throw std::runtime_error("Invalid"); +} + +// 正确: +if (invalid) { + d->errorMsg = "Invalid"; + return false; +} +``` + +3. **不要忘记设置错误信息** +```cpp +// 错误: +if (invalid) { + return false; // 没有解释为什么失败 +} + +// 正确: +if (invalid) { + d->errorMsg = "Invalid parameter: expected X got Y"; + return false; +} +``` + +## 信号报告进度 + +除了 `lastError()`,dfm-burn 使用信号报告进度和状态: + +```cpp +class DOpticalDiscManager : public QObject +{ +Q_SIGNALS: + void jobStatusChanged(JobStatus status, int progress, QString speed, QStringList message); +}; + +// 连接信号 +connect(&manager, &DOpticalDiscManager::jobStatusChanged, + [](JobStatus status, int progress, QString speed, QStringList messages) { + if (status == JobStatus::kFailed) { + qWarning() << "Burn failed:" << messages.join(", "); + } +}); +``` + +## 示例代码引用 + +| 文件路径 | 描述 | +|---------|------| +| `src/dfm-burn/dfm-burn-lib/dopticaldiscmanager.cpp:34-47` | 错误设置示例 | +| `src/dfm-burn/dfm-burn-lib/dopticaldiscmanager.cpp:61-101` | 引擎错误传递 | +| `src/dfm-burn/dfm-burn-lib/dpacketwritingcontroller.cpp:162-163` | lastError() 实现 | diff --git a/.trellis/spec/dfm-burn/index.md b/.trellis/spec/dfm-burn/index.md new file mode 100644 index 00000000..ccf1c554 --- /dev/null +++ b/.trellis/spec/dfm-burn/index.md @@ -0,0 +1,40 @@ +# dfm-burn 库开发规范 + +> dfm-burn 是光盘刻录库,使用 xorriso/udfburn 后端。 + +## 技术栈 + +- **C++11/14** +- **Qt5/Qt6** - QObject、信号槽 +- **libburnia xorriso** - 光盘刻录后端 +- **UDF 客户端** - 自定义 UDF 文件系统实现 + +## 规范索引 + +| 文档 | 描述 | 优先级 | +|------|------|--------| +| [error-handling.md](error-handling.md) | errorMsg + lastError() 模式 | P0 | +| [naming-conventions.md](naming-conventions.md) | 类名、方法名约定 | P0 | +| [memory-management.md](memory-management.md) | QScopedPointer、QSharedData | P0 | +| [code-patterns.md](code-patterns.md) | 信号连接、DirectConnection | P1 | + +--- + +## 开发前检查清单 + +- [ ] 阅读 [error-handling.md](error-handling.md) 了解 errorMsg 模式 +- [ ] 阅读 [memory-management.md](memory-management.md) 了解 QScopedPointer 和 QSharedData +- [ ] 信号声明使用 `Q_SIGNALS`,槽使用 `Q_SLOTS` + +--- + +## 核心规则摘要 + +| 规则 | 要求 | +|------|------| +| 信号声明 | 必须使用 `Q_SIGNALS` 宏 | +| 成员变量 | 小驼峰命名,bool 用 is/has 前缀 | +| 错误处理 | 返回 bool + lastError() 获取错误 | +| QObject 子类 | 使用 QScopedPointer 管理私有实现 | +| 数据类 | 使用 QSharedData(隐式共享) | +| 异步操作 | 使用 DirectConnection + QPointer 防止野指针 | diff --git a/.trellis/spec/dfm-burn/naming-conventions.md b/.trellis/spec/dfm-burn/naming-conventions.md new file mode 100644 index 00000000..0070e2f3 --- /dev/null +++ b/.trellis/spec/dfm-burn/naming-conventions.md @@ -0,0 +1,171 @@ +# dfm-burn 命名约定 + +## 类命名 + +### 公共接口类 + +**规则**: `D` + 功能名词,大驼峰 (PascalCase) + +```cpp +class DOpticalDiscManager; // 光盘管理器 +class DOpticalDiscInfo; // 光盘信息 +class DPacketWritingController; // 包写入控制器 +``` + +### 引擎类 + +**规则**: `D` + 引擎名称 + `Engine` + +```cpp +class DXorrisoEngine; // XORRISO 刻录引擎 +class DUDFBurnEngine; // UDF 刻录引擎 +``` + +## 方法命名 + +### 公共方法 + +**规则**: 小驼峰 (camelCase) + +```cpp +// 操作方法 +bool setStageFile(const QString &diskPath, const QString &isoPath); +bool commit(const BurnOptions &opts, int speed = 0, const QString &volId = "ISOIMAGE"); +bool eraseDisc(EraseType type); + +// 错误获取 +QString lastError() const; + +// 光盘信息查询 +bool hasMedium() const; +const QString &volumeId() const; +quint64 usedSize() const; +quint64 totalSize() const; +``` + +## 成员变量命名 + +### 普通成员 + +**规则**: 小驼峰命名 + +```cpp +class DOpticalDiscManagerPrivate { +public: + QString errorMsg; + QString curDev; + QPair<QString, QString> files; // first: local disk, second: optical disk +}; + +class DOpticalDiscInfoPrivate { +public: + bool formatted {}; + MediaType media; + quint64 data {}; + QString devid {}; + QString volid {}; +}; +``` + +### 布尔成员 + +**规则**: `is` 或 `has` 前缀 + +```cpp +class DOpticalDiscInfoPrivate { +public: + bool hasMedium() const; + bool isBlank() const; + bool isRewritable() const; + bool isFormatted() const; +}; +``` + +## 枚举命名 + +### 枚举类型 + +**规则**: 大驼峰 (PascalCase) + +```cpp +enum JobStatus { ... }; +enum MediaType { ... }; +enum EraseType { ... }; +enum BurnType { ... }; +``` + +### 枚举值 + +**规则**: 大驼峰 (PascalCase) + +```cpp +enum JobStatus { + kIdle, + kRunning, + kFinished, + kFailed +}; + +enum MediaType { + kUnknown, + kCD, + kDVD, + kBD, + kHDDVD +}; + +enum EraseType { + kFast, + kComplete +}; + +enum BurnType { + kISO9660, + kUDF +}; +``` + +## 文件命名 + +| 类型 | 规则 | 示例 | +|------|------|------| +| 公共头文件 | 类名小写 + `.h` | `dopticaldiscmanager.h` | +| 私有实现头文件 | 类名小写 + `_p.h` | `dopticaldiscmanager_p.h` | +| 实现文件 | 类名小写 + `.cpp` | `dopticaldiscmanager.cpp` | + +## 信号命名 + +```cpp +class DOpticalDiscManager : public QObject { +Q_SIGNALS: + void jobStatusChanged(JobStatus status, int progress, QString speed, QStringList message); +}; + +class DXorrisoEngine : public QObject { +Q_SIGNALS: + void jobStatusChanged(JobStatus status, int progress, QString speed); + void errorOccurred(const QString &error); +}; +``` + +## 命名空间 + +```cpp +// 全局头文件定义宏 +#define DFM_BURN_BEGIN_NS namespace DFMBURN { +#define DFM_BURN_END_NS } +#define DFM_BURN_USE_NS using namespace DFMBURN; + +// 使用 +DFM_BURN_BEGIN_NS +class DOpticalDiscManager { /* ... */ }; +DFM_BURN_END_NS +``` + +## 示例代码引用 + +| 文件路径 | 描述 | +|---------|------| +| `src/dfm-burn/dfm-burn-lib/private/dpacketwritingcontroller_p.h:12-28` | 成员变量命名 | +| `src/dfm-burn/dfm-burn-lib/private/dopticaldiscinfo_p.h:44-61` | 布尔成员命名 | +| `include/dfm-burn/dfm-burn/dopticaldiscinfo.h:26-42` | 枚举命名 | diff --git a/.trellis/spec/dfm-io/error-handling.md b/.trellis/spec/dfm-io/error-handling.md new file mode 100644 index 00000000..d51fcb69 --- /dev/null +++ b/.trellis/spec/dfm-io/error-handling.md @@ -0,0 +1,228 @@ +# dfm-io 错误处理规范 + +## 概述 + +dfm-io 使用双重错误处理系统:`GError`(来自 GLib/GIO)和 `DFMIOErrorCode`(项目自定义)。 + +## 错误类型 + +### 1. DFMIOErrorCode 枚举 + +**位置**: `include/dfm-io/dfm-io/error/en.h` + +```cpp +enum class DFMIOErrorCode : int { + DFM_IO_ERROR_NONE = -1, // 无错误 + DFM_IO_ERROR_FAILED, // 通用错误 + DFM_IO_ERROR_NOT_FOUND, // 文件未找到 + DFM_IO_ERROR_EXISTS, // 文件已存在 + DFM_IO_ERROR_PERMISSION_DENIED, // 权限拒绝 + DFM_IO_ERROR_NO_SPACE, // 磁盘空间不足 + DFM_IO_ERROR_NOT_DIRECTORY, // 不是目录 + // ... 约 70+ 个错误码 +}; +``` + +### 2. DFMIOError 结构体 + +**位置**: `include/dfm-io/dfm-io/error/error.h` + +```cpp +struct DFMIOError { + DFMIOErrorCode errorCode; + QString errMsg; + + // 获取错误码对应的默认消息 + QString code() const { return GetError_En(errorCode); } + + // 优先返回自定义消息,否则返回默认消息 + QString errorMsg() const { returnerrMsg.isEmpty() ? code() : errMsg; } + + // 判断是否有错误 + bool isError() const { return errorCode != DFM_IO_ERROR_NONE; } + + // 设置错误码 + void setCode(DFMIOErrorCode code) { errorCode = code; } + + // 设置自定义消息 + void setMessage(const QString &msg) { errMsg = msg; } + + // 隐式转换为 bool + explicit operator bool() const { return isError(); } +}; +``` + +## 错误转换模式 + +### 1. 从 GError 转换 + +**位置**: `src/dfm-io/dfm-io/doperator.cpp:31-42` + +```cpp +void DOperatorPrivate::setErrorFromGError(GError *gerror) +{ + if (!gerror) + return; + + // 将 GError code 映射到 DFMIOErrorCode + error.setCode(DFMIOErrorCode(gerror->code)); + + // 通用错误需要特殊处理消息 + if (error.code() == DFMIOErrorCode::DFM_IO_ERROR_FAILED) { + QString strErr(gerror->message); + // GIO 错误消息格式通常是 "domain: message: other: info" + // 提取核心信息 + if (strErr.contains(':')) + strErr = strErr.left(strErr.indexOf(":")) + strErr.mid(strErr.lastIndexOf(":")); + error.setMessage(strErr); + } +} +``` + +### 2. 从 errno 转换 + +**位置**: `src/dfm-io/dfm-io/doperator.cpp:44-90` + +```cpp +void DOperatorPrivate::setErrorFromErrno(int errnoValue) +{ + DFMIOErrorCode errorCode; + switch (errnoValue) { + case EACCES: + case EPERM: + errorCode = DFM_IO_ERROR_PERMISSION_DENIED; + break; + case ENOENT: + errorCode = DFM_IO_ERROR_NOT_FOUND; + break; + case EEXIST: + errorCode = DFM_IO_ERROR_EXISTS; + break; + case ENOSPC: + errorCode = DFM_IO_ERROR_NO_SPACE; + break; + default: + errorCode = DFM_IO_ERROR_FAILED; + break; + } + error.setCode(errorCode); +} +``` + +## 公共接口错误处理模式 + +### 每个操作类都有 lastError() 方法 + +```cpp +// DOperator +DFMIOError lastError() const; + +// DFileInfo +DFMIOError lastError() const; + +// DWatcher +DFMIOError lastError() const; + +// DEnumerator +DFMIOError lastError() const; +``` + +### 使用示例 + +```cpp +// 操作失败时,返回 false 或 nullptr +DOperator op(filePath); +if (!op.renameFile(newPath)) { + DFMIOError err = op.lastError(); + qWarning() << "Rename failed:" << err.errorMsg(); + return; +} +``` + +## 错误处理最佳实践 + +### DO - 应该做 + +1. **每次操作后检查错误** +```cpp +DOperator op(filePath); +if (!op.copyFile(destPath)) { + handle_error(op.lastError()); +} +``` + +2. **使用 lastError() 获取完整错误信息** +```cpp +DFMIOError err = op.lastError(); +if (err.isError()) { + errCode = err.errorCode(); // 获取错误码 + errMsg = err.errorMsg(); // 获取错误消息 +} +``` + +3. **使用 g_autoptr 自动管理 GError** +```cpp +g_autoptr(GError) gerror = nullptr; +// GLib 函数调用会填充 gerror +if (opFailed) { + setErrorFromGError(gerror); +} +// g_error_free 不需要,g_autoptr 自动处理 +``` + +### DON'T - 不应该做 + +1. **不要忽略错误** +```cpp +// 错误: +op.renameFile(newPath); // 忽略返回值 + +// 正确: +if (!op.renameFile(newPath)) { + // 处理错误 +} +``` + +2. **不要在转换后丢失原始错误码** +```cpp +// 错误: +if (gerror) { + QString msg(gerror->message); + setError(msg); // 丢失了 gerror->code +} + +// 正确: +if (gerror) { + error.setCode(DFMIOErrorCode(gerror->code)); + error.setMessage(gerror->message); +} +``` + +3. **不要忘记释放手动分配的 GError** +```cpp +// 错误: +GError *err = nullptr; +call_func(&err); +// 忘记 g_error_free(err); + +// 正确: +GError *err = nullptr; +call_func(&err); +if (err) { + // 处理错误 + g_error_free(err); +} + +// 更好: 使用 g_autoptr +g_autoptr(GError) err = nullptr; +call_func(&err); +``` + +## 示例代码引用 + +| 文件路径 | 描述 | +|---------|------| +| `include/dfm-io/dfm-io/error/en.h` | 错误码枚举定义 | +| `include/dfm-io/dfm-io/error/error.h` | DFMIOError 结构体 | +| `src/dfm-io/dfm-io/doperator.cpp:31-90` | 错误转换实现 | +| `src/dfm-io/dfm-io/dfileinfo.cpp` | DFileInfo 错误处理 | diff --git a/.trellis/spec/dfm-io/index.md b/.trellis/spec/dfm-io/index.md new file mode 100644 index 00000000..8cf11247 --- /dev/null +++ b/.trellis/spec/dfm-io/index.md @@ -0,0 +1,41 @@ +# dfm-io 库开发规范 + +> dfm-io 是 Deepin 文件管理器的文件 I/O 操作核心库,基于 GIO/GLib 提供异步文件操作。 + +## 技术栈 + +- **C++11+** +- **Qt5/Qt6** - QUrl、QObject、智能指针 +- **GLib/GIO** - 底层文件系统操作 +- **g_autoptr** - GLib 自动指针 + +## 规范索引 + +| 文档 | 描述 | 优先级 | +|------|------|--------| +| [error-handling.md](error-handling.md) | GError + DFMIOErrorCode 错误处理 | P0 | +| [naming-conventions.md](naming-conventions.md) | 类名、方法名、变量名约定 | P0 | +| [memory-management.md](memory-management.md) | P-impl 模式、智能指针、GLib 对象管理 | P0 | +| [code-patterns.md](code-patterns.md) | 信号槽、回调、文件组织 | P1 | +| [signals-and-callbacks.md](signals-and-callbacks.md) | Q_SIGNALS、GIO 信号桥接 | P1 | + +--- + +## 开发前检查清单 + +- [ ] 阅读 [error-handling.md](error-handling.md) 了解 GError 转换和 DFMIOErrorCode 使用 +- [ ] 阅读 [memory-management.md](memory-management.md) 了解 g_autoptr 和 GLib 对象释放 +- [ ] 确认禁用 signals/slots 关键字,使用 Q_SIGNALS/Q_SLOTS 宏 + +--- + +## 核心规则摘要 + +| 规则 | 要求 | +|------|------| +| 信号声明 | 必须使用 `Q_SIGNALS` 宏 | +| 槽声明 | 必须使用 `Q_SLOTS` 宏 | +| GLib 对象 | 优先使用 `g_autoptr`,手动释放用 `g_object_unref` | +| 错误处理 | 检查 `lastError()` 获取错误信息 | +| 命名空间 | 使用 `BEGIN_IO_NAMESPACE` / `END_IO_NAMESPACE` 宏 | +| 私有实现 | 派生 QSharedData | diff --git a/.trellis/spec/dfm-io/naming-conventions.md b/.trellis/spec/dfm-io/naming-conventions.md new file mode 100644 index 00000000..c151c5f2 --- /dev/null +++ b/.trellis/spec/dfm-io/naming-conventions.md @@ -0,0 +1,198 @@ +# dfm-io 命名约定 + +## 类命名 + +### 公共接口类 + +**规则**: `D` + 功能名词,大驼峰 (PascalCase) + +```cpp +class DFile; +class DFileInfo; +class DOperator; +class DWatcher; +class DEnumerator; +class DFileFuture; +class DFileInfoFuture; +class DTrashHelper; +class DLocalHelper; +class DMediaInfo; +``` + +### 私有实现类 + +**规则**: 公共类名 + `Private` + +```cpp +class DFilePrivate; +class DFileInfoPrivate; +class DOperatorPrivate; +class DWatcherPrivate; +class DEnumeratorPrivate; +class DFileFuturePrivate; +``` + +## 方法命名 + +### 公共方法 + +**规则**: 小驼峰 (camelCase),动词开头 + +```cpp +// 文件操作 +bool renameFile(const QString &newName); +bool copyFile(const QString &dest); +bool moveFile(const QString &dest); +bool deleteFile(); + +// 属性访问 +QUrl uri() const; +bool exists() const; +void setUri(const QUrl &uri); + +// 异步方法 +void renameFileAsync(const QString &newName, RenameCallbackFunc cb, void *userData); +void copyFileAsync(const QString &dest, CopyCallbackFunc cb, void *userData); +``` + +### 私有方法 + +**规则**: 小驼峰 + +```cpp +void setErrorFromGError(GError *gerror); +void setErrorFromErrno(int errnoValue); +GFile *makeGFile(const QUrl &url); +void checkAndResetCancel(); +``` + +## 成员变量命名 + +### d 和 q 指针 + +**规则**: +- `d`: 指向私有实现(公共类中) +- `q`: 指向公共类(私有实现类中) + +```cpp +// 公共类 +class DFileInfo { +private: + mutable QSharedDataPointer<DFileInfoPrivate> d; // d 指向私有实现 +}; + +// 私有实现类 +class DFileInfoPrivate { +public: + DFileInfo *q { nullptr }; // q 指向公共类 +}; +``` + +### 普通成员变量 + +**规则**: 小驼峰命名 + +```cpp +class DWatcherPrivate { + QUrl uri; + QList<QUrl> children; + GFileMonitor *monitor { nullptr }; + GCancellable *gcancellable { nullptr }; + DFMIOError error; +}; +``` + +### GLib 对象 + +**规则**: 加 `g` 前缀或直接使用类型名 + +```cpp +GFile *gfile { nullptr }; +GFileInfo *gfileinfo { nullptr }; +GFileMonitor *monitor { nullptr }; +GCancellable *gcancellable { nullptr }; +``` + +## 枚举命名 + +### 枚举类型 + +**规则**: 大驼峰 (PascalCase) + +```cpp +enum class DFileType : uint16_t { ... }; +enum class DFileAttributeType : uint8_t { ... }; +enum class FileQueryInfoFlags : uint8_t { ... }; +``` + +### 枚举值 + +**规则**: `k` + 大驼峰 (PascalCase) + +```cpp +enum class DFileType : uint16_t { + kUnknown = 0, + kRegular = 1, + kDirectory = 2, + kSymbolicLink = 3, + kSpecial = 4, + kShortcut = 5, + kMountable = 6, + kUserType = 0x100 +}; + +enum class DFileAttributeType : uint8_t { + kTypeInvalid = 0, + kTypeString = 1, + kTypeByteString = 2, + kTypeBool = 3, + kTypeUint32 = 4, + kTypeInt32 = 5, + kTypeUint64 = 6, + kTypeInt64 = 7 +}; +``` + +## 文件命名 + +| 类型 | 规则 | 示例 | +|------|------|------| +| 公共头文件 | 类名小写 + `.h` | `dfile.h`, `dfileinfo.h` | +| 私有实现头文件 | 类名小写 + `_p.h` | `dfile_p.h`, `dfileinfo_p.h` | +| 实现文件 | 类名小写 + `.cpp` | `dfile.cpp`, `dfileinfo.cpp` | +| 工具类 | 小驼峰 + `.h` | `dlocalhelper.h`, `dmediainfo.h` | + +## 命名空间 + +```cpp +// 全局头文件定义宏 +#define DFMIO dfmio +#define BEGIN_IO_NAMESPACE namespace DFMIO { +#define USING_IO_NAMESPACE using namespace DFMIO; +#define END_IO_NAMESPACE } + +// 使用 +BEGIN_IO_NAMESPACE +class DFileInfo { /* ... */ }; +END_IO_NAMESPACE +``` + +## 类型别名 + +```cpp +// 回调函数类型 +using ProgressCallbackFunc = void (*)(int64_t, int64_t, void *); +using FileOperateCallbackFunc = void (*)(bool, void *); + +// std::function 类型 +using InitQuerierAsyncCallback = std::function<void(bool, void *)>; +using AttributeAsyncCallback = std::function<void(bool, void *, QVariant)>; +``` + +## 示例代码引用 + +| 文件路径 | 描述 | +|---------|------| +| `include/dfm-io/dfm-io/dfileinfo.h:27-191` | 枚举命名示例 | +| `src/dfm-io/dfm-io/private/dfileinfo_p.h` | 私有类命名示例 | +| `include/dfm-io/dfm-io/doperator.h:25-27` | 回调函数类型定义 | diff --git a/.trellis/spec/dfm-mount/error-handling.md b/.trellis/spec/dfm-mount/error-handling.md new file mode 100644 index 00000000..be4577ea --- /dev/null +++ b/.trellis/spec/dfm-mount/error-handling.md @@ -0,0 +1,259 @@ +# dfm-mount 错误处理规范 + +## 概述 + +dfm-mount 处理来自多个来源的错误:UDisks2、GIO、GDBus,通过 `DeviceError` 枚举统一管理。 + +## 错误类型 + +### 1. DeviceError 枚举 + +**位置**: `include/dfm-mount/dfm-mount/base/dmount_global.h:158-343` + +```cpp +enum class DeviceError : int16_t { + kNoError = 0, + kUnhandledError = 10001, + + // UDisks2 错误 (10000-10099) + kUDisksErrorFailed = UDISKS_ERR_START, // 10000 + kUDisksErrorAlreadyMounted, + kUDisksErrorNotMounted, + // ... + + // GIO 错误 (10100-10199) + kGIOError = GIO_ERR_START, // 10100 + kGIOErrorFailed, + kGIOErrorNotFound, + // ... + + // GDBus 错误 (10200-10299) + kGDBusError = GDBUS_ERR_START, // 10200 + + // 自定义错误 (20000-29999) + kUserError = USER_ERR_START, // 20000 + kUserErrorNotMountable, + kUserErrorAlreadyMounted, + // ... +}; +``` + +### 2. OperationErrorInfo 结构体 + +```cpp +struct OperationErrorInfo { + DeviceError code; // 错误码 + QString message; // 错误消息 + + OperationErrorInfo() + : code(DeviceError::kNoError) {} + + OperationErrorInfo(DeviceError c, const QString &msg = "") + : code(c), message(msg) {} + + bool hasError() const { return code != DeviceError::kNoError; } +}; + +// 私有实现中存储最后一次错误 +mutable OperationErrorInfo lastError { DeviceError::kNoError, "" }; +``` + +## 错误转换 + +### 1. GError 到 DeviceError 转换 + +**位置**: `src/dfm-mount/lib/base/dmountutils.cpp:503-519` + +```cpp +DeviceError Utils::castFromGError(const GError *const err) +{ + if (!err) + return DeviceError::kNoError; + + // 根据 GError domain 映射到不同的 DeviceError 范围 + const char *errDomain = g_quark_to_string(err->domain); + if (strcmp(errDomain, UDISKS_ERR_DOMAIN) == 0) { + return static_cast<DeviceError>(err->code + UDISKS_ERR_START); + } else if (strcmp(errDomain, GIO_ERR_DOMAIN) == 0) { + return static_cast<DeviceError>(err->code + GIO_ERR_START); + } else if (strcmp(errDomain, GDBUS_ERR_DOMAIN) == 0) { + return static_cast<DeviceError>(err->code + GDBUS_ERR_START); + } + return DeviceError::kUnhandledError; +} +``` + +### 2. 错误生成工具 + +```cpp +OperationErrorInfo Utils::genOperateErrorInfo(DeviceError errCode, const QString &errMsg = "") +{ + return { errCode, errMsg }; +} +``` + +## 同步操作错误处理 + +**位置**: `src/dfm-mount/lib/dblockdevice.cpp:581-612` + +```cpp +QString DBlockDevicePrivate::mount(const QVariantMap &opts) +{ + warningIfNotInMain(); // 线程安全检查 + + // 检查是否有正在进行的作业 + if (findJob(kBlockJob)) + return ""; + + UDisksFilesystem_autoptr fs = getFilesystemHandler(); + if (!fs) { + lastError = Utils::genOperateErrorInfo(DeviceError::kUserErrorNotMountable); + return ""; + } + + // 准备参数 + GError *err = nullptr; + GVariant *gopts = Utils::castFromQVariantMap(opts); + char *mountPoint = nullptr; + + // 同步调用 DBus 方法 + bool mounted = udisks_filesystem_call_mount_sync(fs, gopts, &mountPoint, nullptr, &err); + + // 处理错误 + handleErrorAndRelease(err); + + 返回挂载点或空字符串 + QString ret; + if (mounted && mountPoint) { + ret = mountPoint; + g_free(mountPoint); + } + return ret; +} +``` + +## 异步操作错误处理 + +**位置**: `src/dfm-mount/lib/dblockdevice.cpp:23-55` + +```cpp +// 错误处理回调 +void DBlockDevicePrivate::handleErrorAndRelease(CallbackProxy *proxy, + bool result, + GError *gerr, + QString info) +{ + OperationErrorInfo err; + if (!result && gerr) { + // 转换 GError + err.code = Utils::castFromGError(gerr); + err.message = gerr->message; + qInfo() << "error occured while operating device" << err.message; + + // 释放 GError + g_error_free(gerr); + } + + // 调用用户回调 + if (proxy) { + if (proxy->cb) { + proxy->cb(result, err); + } else if (proxy->cbWithInfo) { + proxy->cbWithInfo(result, err, info); + } + delete proxy; // 清理代理对象 + } +} + +// 异步挂载回调 +void DBlockDevicePrivate::mountAsyncCallback(GObject *sourceObj, + GAsyncResult *res, + gpointer userData) +{ + UDisksFilesystem *fs = UDISKS_FILESYSTEM(sourceObj); + CallbackProxy *proxy = static_cast<CallbackProxy *>(userData); + + GError *err = nullptr; + g_autofree char *mountPoint = nullptr; + + // 完成 DBus 异步调用 + bool result = udisks_filesystem_call_mount_finish(fs, &mountPoint, res, &err); + if (mountPoint) + result = true; + + // 处理结果和错误 + QString info(mountPoint ? mountPoint : ""); + handleErrorAndRelease(proxy, result, err, info); +} +``` + +## 错误处理最佳实践 + +### DO - 应该做 + +1. **使用 GError 自动指针** +```cpp +g_autoptr(GError) gerror = nullptr; +// GError 自动释放 +``` + +2. **回调中正确释放 GError** +```cpp +if (gerr) { + // 处理错误 + g_error_free(gerr); +} +``` + +3. **使用 warningIfNotInMain() 检查线程安全** +```cpp +QString DBlockDevicePrivate::mount(const QVariantMap &opts) { + warningIfNotInMain(); // 同步方法必须在主线程 +} +``` + +### DON'T - 不应该做 + +1. **不要忽略 GError 释放** +```cpp +GError *err = nullptr; +udisks_call(&err); +// 忘记 g_error_free(err) +``` + +2. **不要在非主线程调用同步方法** +```cpp +// 错误: 在工作线程中调用 mount() +QThread::create([]() { + device->mount(); // 会触发警告 +})->start(); + +// 正确: 使用异步方法 +QThread::create([]() { + device->mountAsync({}, [](bool ok, OperationErrorInfo err) { + // 处理结果 + }); +})->start(); +``` + +3. **不要忘记释放 g_autofree 和 g_free** +```cpp +// g_autofree 自动释放 +g_autofree char *mountPoint = nullptr; +udisks_filesystem_call_mount_sync(fs, opts, &mountPoint, nullptr, &err); +// 不需要 g_free(mountPoint) + +// 手动分配需要释放 +char *data = g_strdup("hello"); +// 使用后 +g_free(data); +``` + +## 示例代码引用 + +| 文件路径 | 描述 | +|---------|------| +| `include/dfm-mount/dfm-mount/base/dmount_global.h:158-343` | DeviceError 枚举 | +| `src/dfm-mount/lib/base/dmountutils.cpp:503-519` | GError 转换函数 | +| `src/dfm-mount/lib/dblockdevice.cpp:581-612` | 同步挂载错误处理 | +| `src/dfm-mount/lib/dblockdevice.cpp:23-55` | 异步回调错误处理 | diff --git a/.trellis/spec/dfm-mount/index.md b/.trellis/spec/dfm-mount/index.md new file mode 100644 index 00000000..10cfb6e2 --- /dev/null +++ b/.trellis/spec/dfm-mount/index.md @@ -0,0 +1,44 @@ +# dfm-mount 库开发规范 + +> dfm-mount 是设备挂载库,使用 UDisks2 和 GIO/GDBus 管理设备。 + +## 技术栈 + +- **C++11+** +- **Qt5/Qt6** - QObject、QSharedPointer、QDBusServiceWatcher +- **UDisks2** - libudisks2 设备管理系统 +- **GLib/GIO** - GVariant、GError、GDBus、GSignal +- **libmount** - 解析挂载点 + +## 规范索引 + +| 文档 | 描述 | 优先级 | +|------|------|--------| +| [error-handling.md](error-handling.md) | UDisks2、GIO、GDBus 错误处理 | P0 | +| [naming-conventions.md](naming-conventions.md) | 类名、方法名、变量名约定 | P0 | +| [memory-management.md](memory-management.md) | GLib autoptr、UDisksX_autoptr | P0 | +| [code-patterns.md](code-patterns.md) | DBus 集成、GIO 信号桥接 | P1 | +| [dbus-integration.md](dbus-integration.md) | UDisks2 调用模式 | P1 | + +--- + +## 开发前检查清单 + +- [ ] 阅读 [error-handling.md](error-handling.md) 了解 GError 转换和 DeviceError 枚举 +- [ ] 阅读 [memory-management.md](memory-management.md) 了解 UDisksX_autoptr 使用 +- [ ] 阅读 [dbus-integration.md](dbus-integration.md) 了解同步/异步 DBus 调用 +- [ ] 确认禁用 signals/slots 关键字,使用 Q_SIGNALS/Q_SLOTS 宏 +- [ ] 同步方法会调用 `warningIfNotInMain()` 检查线程安全 + +--- + +## 核心规则摘要 + +| 规则 | 要求 | +|------|------| +| 信号声明 | 必须使用 `Q_SIGNALS` 宏 | +| GIO 回调 | 使用静态回调函数 + userData 传递 this | +| GLib 对象 | 必须使用 `UDisksX_autoptr` 自动管理 | +| 线程安全 | 同步方法仅主线程,否则用 Async 版本 | +| 错误处理 | `lastError()` 返回 OperationErrorInfo | +| 函数注册 | 使用 std::bind 注册虚函数到基类 | diff --git a/.trellis/spec/dfm-mount/naming-conventions.md b/.trellis/spec/dfm-mount/naming-conventions.md new file mode 100644 index 00000000..ec4bcd3a --- /dev/null +++ b/.trellis/spec/dfm-mount/naming-conventions.md @@ -0,0 +1,192 @@ +# dfm-mount 命名约定 + +## 类命名 + +### 公共接口类 + +**规则**: `D` + 功能名词,大驼峰 (PascalCase) + +```cpp +class DDevice; // 设备基类 +class DBlockDevice; // 块设备 +class DProtocolDevice; // 协议设备 +class DBlockMonitor; // 块设备监视器 +class DProtocolMonitor; // 协议设备监视器 +class DDeviceManager; // 设备管理器 +``` + +### 私有实现类 + +**规则**: 公共类名 + `Private` + +```cpp +class DDevicePrivate; +class DBlockDevicePrivate; +class DProtocolDevicePrivate; +class DBlockMonitorPrivate; +class DProtocolMonitorPrivate; +``` + +## 方法命名 + +### 公共方法 + +**规则**: 小驼峰 (camelCase) + +```cpp +// 同步/异步方法配对 +QString mount(const QVariantMap &opts = {}); +void mountAsync(const QVariantMap &opts = {}, DeviceOperateCallbackWithMessage cb = nullptr); + +bool unmount(const QVariantMap &opts = {}); +void unmountAsync(const QVariantMap &opts = {}, DeviceOperateCallback cb = nullptr); + +QString path() const; +bool hasBlock() const; +QVariant queryProperty(Property property) const; +``` + +### 静态回调函数 + +**规则**: `on` + 事件描述,静态成员 + +```cpp +class DBlockMonitorPrivate final { +private: + // GIO 信号使用静态回调函数 + static void onObjectAdded(GDBusObjectManager *mng, + GDBusObject *obj, + gpointer userData); + static void onObjectRemoved(GDBusObjectManager *mng, + GDBusObject *obj, + gpointer userData); + static void onPropertyChanged(GDBusObjectManagerClient *mngClient, + GDBusObjectProxy *objProxy, + GDBusProxy *dbusProxy, + GVariant *property, + const gchar *const invalidProperty, + gpointer userData); +}; +``` + +## 成员变量命名 + +### d 和 q 指针 + +```cpp +class DDevice { +protected: + QScopedPointer<DDevicePrivate> d; // d 指向私有实现 +}; + +class DDevicePrivate { +public: + DDevice *q { nullptr }; // q 指向公共类 +}; +``` + +### 普通成员变量 + +**规则**: 小驼峰命名 + +```cpp +class DBlockDevicePrivate { + QString blkObjPath; + UDisksClient *client { nullptr }; + bool deviceOpended { false }; + OperationErrorInfo lastError; + QHash<QString, ulong> connections; +}; +``` + +### GLib/UDisks 对象 + +**规则**: 全小写描述性名称 + +```cpp +UDisksClient *client { nullptr }; +UDisksObject_autoptr udisksObj; +UDisksBlock_autoptr block; +UDisksDrive_autoptr drive; +``` + +## 枚举命名 + +### 枚举类型 + +**规则**: 大驼峰 (PascalCase) + +```cpp +enum class DeviceType : uint16_t { ... }; +enum class Property : uint16_t { ... }; +enum class DeviceError : int16_t { ... }; +``` + +### 枚举值 + +**规则**: `k` + 大驼峰 (PascalCase) + +```cpp +enum class DeviceType : uint16_t { + kAllDevice = 0, + kBlockDevice = 1, + kProtocolDevice = 2, + kNetDevice = 3, +}; + +enum class Property : uint16_t { + kNotInit = 0, + kBlockProperty = 1, + kDriveProperty = 30, + kFileSystemProperty = 31, +}; + +enum class DeviceError : int16_t { + kNoError = 0, + kUnhandledError = 10001, + kUDisksErrorFailed = 10000, + kUserErrorNotMountable = 20000, +}; +``` + +## 文件命名 + +| 类型 | 规则 | 示例 | +|------|------|------| +| 公共头文件 | 类名小写 + `.h` | `ddevice.h`, `dblockdevice.h` | +| 私有实现头文件 | 类名小写 + `_p.h` | `ddevice_p.h`, `dblockdevice_p.h` | +| 实现文件 | 类名小写 + `.cpp` | `ddevice.cpp`, `dblockdevice.cpp` | + +## 类型别名 + +```cpp +using DeviceOperateCallback = std::function<void(bool, OperationErrorInfo)>; +using DeviceOperateCallbackWithMessage = std::function<void(bool, OperationErrorInfo, QString)>; +``` + +## 宏定义 + +```cpp +// 虚函数占位符 +#define DMNT_VIRTUAL virtual + +// 线程安全警告 +#define warningIfNotInMain() \ + { \ + if (qApp->thread() != QThread::currentThread()) \ + qWarning() << "<" << __PRETTY_FUNCTION__ << ">"; \ + } + +// 错误域常量 +#define UDISKS_ERR_DOMAIN "org.freedesktop.UDisks2.Error" +#define GIO_ERR_DOMAIN "g-io-error-quark" +#define GDBUS_ERR_DOMAIN "g-dbus-error-quark" +``` + +## 示例代码引用 + +| 文件路径 | 描述 | +|---------|------| +| `include/dfm-mount/dfm-mount/base/dmount_global.h:36-57` | 枚举命名示例 | +| `src/dfm-mount/private/dblockmonitor_p.h:48-53` | 静态回调函数命名 | +| `src/dfm-mount/private/dblockdevice_p.h:95-96` | 成员变量命名 | diff --git a/.trellis/spec/dfm-search/error-handling.md b/.trellis/spec/dfm-search/error-handling.md new file mode 100644 index 00000000..aebcea2d --- /dev/null +++ b/.trellis/spec/dfm-search/error-handling.md @@ -0,0 +1,271 @@ +# dfm-search 错误处理规范 + +## 概述 + +dfm-search 使用现代 C++ 的 `std::error_code` 模式进行错误处理,不使用异常。 + +## 错误类型 + +### 1. 分层错误码设计 + +**位置**: `include/dfm-search/dfm-search/searcherror.h:16-83` + +```cpp +// 通用搜索错误码 (0-999) +enum class SearchErrorCode : int { + Success = 0, + PermissionDenied = 1, + InvalidQuery = 100, + PathIsEmpty, + PathNotFound, + SearchTimeout, + InternalError, + InvalidBoolean, + InvalidSerchMethod, +}; + +// 文件名搜索错误码 (1000-1999) +enum class FileNameSearchErrorCode : int { + KeywordIsEmpty = 1000, + KeywordTooLong, + InvalidPinyinFormat = 1050, + InvalidFileTypes = 1100, + FileNameIndexNotFound = 1200, + FileNameIndexException = 1300, +}; + +// 内容搜索错误码 (2000-2999) +enum class ContentSearchErrorCode : int { + KeywordTooShort = 2000, + WildcardNotSupported = 2001, + ContentIndexNotFound = 2200, + ContentIndexException = 2300, +}; +``` + +### 2. 错误分类(ErrorCategory) + +```cpp +class SearchErrorCategory : public std::error_category +{ +public: + const char *name() const noexcept override { return "search_error"; } + + std::string message(int ev) const override { + switch (static_cast<SearchErrorCode>(ev)) { + case SearchErrorCode::Success: + return "Success: The operation completed successfully."; + case SearchErrorCode::InvalidQuery: + return "Invalid search query: The provided search query is not valid."; + // ... + default: + return "Unknown error"; + } + } + + // Qt 友好的消息获取 + virtual QString qMessage(int ev) const { + return QString::fromStdString(message(ev)); + } +}; + +// 继承支持派生类型 +class FileNameSearchErrorCategory : public SearchErrorCategory { + std::string message(int ev) const override { + switch (static_cast<FileNameSearchErrorCode>(ev)) { + case FileNameSearchErrorCode::KeywordIsEmpty: + return "Keyword is empty: The search keyword cannot be empty."; + // ... + } + } +}; + +class ContentSearchErrorCategory : public SearchErrorCategory { /* ... */ }; +``` + +### 3. 错误包装类 + +**位置**: `include/dfm-search/dfm-search/searcherror.h:109-128` + +```cpp +class SearchError { +public: + SearchError() = default; + explicit SearchError(SearchErrorCode code); + explicit SearchError(FileNameSearchErrorCode code); + explicit SearchError(ContentSearchErrorCode code); + + bool isError() const { return m_code.value() != 0; } + const std::error_code &code() const { return m_code; } + QString message() const; + QString name() const; + +private: + std::error_code m_code; +}; +``` + +## make_error_code 实现 + +**位置**: `src/dfm-search/dfm-search-lib/core/searcherror.cpp` + +```cpp +// 单例模式提供 ErrorCategory 实例 +const SearchErrorCategory &search_category() { + static SearchErrorCategory c; + return c; +} + +const FileNameSearchErrorCategory &filename_search_category() { + static FileNameSearchErrorCategory c; + return c; +} + +const ContentSearchErrorCategory &content_search_category() { + static ContentSearchErrorCategory c; + return c; +} + +// 在 searcherror.h 中声明 +inline std::error_code make_error_code(SearchErrorCode ec) { + return std::error_code(static_cast<int>(ec), search_category()); +} + +inline std::error_code make_error_code(FileNameSearchErrorCode ec) { + return std::error_code(static_cast<int>(ec), filename_search_category()); +} +``` + +## 错误处理模式 + +### 1. 信号报告错误 + +```cpp +class AbstractSearchEngine : public QObject { + Q_OBJECT +Q_SIGNALS: + void errorOccurred(const DFMSEARCH::SearchError &error); +}; + +// 使用 +connect(engine, &SearchEngine::errorOccurred, [](const SearchError &err) { + qWarning() << "Search error:" << err.message(); +}); +``` + +### 2. 返回错误状态 + +```cpp +class IndexManager { +public: + bool buildIndex(const QString &path, SearchError &error) { + if (path.isEmpty()) { + error = SearchError(FileNameSearchErrorCode::KeywordIsEmpty); + return false; + } + // ... 构建索引 + return true; + } +}; + +// 使用 +SearchError err; +if (!manager.buildIndex(path, err)) { + qWarning() << err.message(); +} +``` + +## 错误处理最佳实践 + +### DO - 应该做 + +1. **使用标准错误码模式** +```cpp +// 返回 + 错误参数模式 +bool doSomething(SearchError &error); + +// 使用信号异步报告 +Q_SIGNAL void errorOccurred(const SearchError &error); +``` + +2. **单例模式管理 ErrorCategory** +```cpp +const SearchErrorCategory &search_category() { + static SearchErrorCategory c; + return c; +} +``` + +3. **清晰的错误分层** +```cpp +// 基础错误: 0-999 +enum class SearchErrorCode { ... }; + +// 文件名错误: 1000-1999 +enum class FileNameSearchErrorCode { ... }; + +// 内容错误: 2000-2999 +enum class ContentSearchErrorCode { ... }; +``` + +### DON'T - 不应该做 + +1. **不要使用异常** +```cpp +// 错误: +if (path.isEmpty()) { + throw std::runtime_error("Path is empty"); +} + +// 正确: +if (path.isEmpty()) { + error = SearchError(FileNameSearchErrorCode::KeywordIsEmpty); + return false; +} +``` + +2. **不要使用 std::shared_ptr 管理错误** +```cpp +// 错误: +std::shared_ptr<SearchError> error; + +// 正确: +SearchError error; // 值语义,轻量级 +``` + +3. **不要使用裸指针管理 ErrorCategory** +```cpp +// 错误: +static SearchErrorCategory *c = new SearchErrorCategory(); +// 永远不会被 delete + +// 正确: +static SearchErrorCategory c; // 静态局部变量 +``` + +## Qt 集成 + +```cpp +// qMessage() 提供友好的 Qt 字符串接口 +class SearchError { +public: + QString message() const { + if (auto cat = dynamic_cast<const SearchErrorCategory *>(&m_code.category())) { + return cat->qMessage(m_code.value()); + } + return QString::fromStdString(m_code.message()); + } + + QString name() const { + return QString::fromLocal8Bit(m_code.category().name()); + } +}; +``` + +## 示例代码引用 + +| 文件路径 | 描述 | +|---------|------| +| `include/dfm-search/dfm-search/searcherror.h` | 错误码和 ErrorCategory 定义 | +| `src/dfm-search/dfm-search-lib/core/searcherror.cpp` | ErrorCategory 实现 | +| `src/dfm-search/dfm-search-lib/core/abstractsearchengine.h` | 错误信号声明 | diff --git a/.trellis/spec/dfm-search/index.md b/.trellis/spec/dfm-search/index.md new file mode 100644 index 00000000..16b8aa14 --- /dev/null +++ b/.trellis/spec/dfm-search/index.md @@ -0,0 +1,42 @@ +# dfm-search 库开发规范 + +> dfm-search 是文件搜索库,采用现代 C++17 + Qt,使用 std::error_code 进行错误处理。 + +## 技术栈 + +- **C++17** +- **Qt5/Qt6** - 信号槽、线程、事件循环 +- **Lucene++** - 全文搜索引擎 +- **std::error_code** - 错误处理 + +## 规范索引 + +| 文档 | 描述 | 优先级 | +|------|------|--------| +| [error-handling.md](error-handling.md) | std::error_code + ErrorCategory模式 | P0 | +| [naming-conventions.md](naming-conventions.md) | 类名、方法名、m_ 前缀成员变量 | P0 | +| [memory-management.md](memory-management.md) | std::unique_ptr、PIMPL | P0 | +| [code-patterns.md](code-patterns.md) | 策略模式、线程模型 | P1 | +| [signal-threading.md](signal-threading.md) | 线程间信号通信 | P1 | + +--- + +## 开发前检查清单 + +- [ ] 阅读 [error-handling.md](error-handling.md) 了解 std::error_code 和 ErrorCategory +- [ ] 阅读 [memory-management.md](memory-management.md) 了解 std::unique_ptr 使用规则 +- [ ] 阅读 [signal-threading.md](signal-threading.md) 了解工作线程与主线程通信 +- [ ] 信号声明使用 `Q_SIGNALS`,槽使用 `Q_SLOTS` + +--- + +## 核心规则摘要 + +| 规则 | 要求 | +|------|------| +| 成员变量 | `m_` 前缀 + camelCase | +| 智能指针 | 仅用 `std::unique_ptr`,不用 `std::shared_ptr` | +| 错误处理 | `std::error_code` + 自定义 ErrorCategory | +| PIMPL | SearchResult、SearchOptions 使用 PIMPL | +| 线程模型 | SearchWorker 在独立线程,使用信号通信 | +| 策略模式 | BaseSearchStrategy + SearchStrategyFactory | diff --git a/.trellis/spec/dfm-search/naming-conventions.md b/.trellis/spec/dfm-search/naming-conventions.md new file mode 100644 index 00000000..a32002a6 --- /dev/null +++ b/.trellis/spec/dfm-search/naming-conventions.md @@ -0,0 +1,247 @@ +# dfm-search 命名约定 + +## 类命名 + +### 公共接口类 + +**规则**: 大驼峰 (PascalCase) + +```cpp +class SearchEngine; // 搜索引擎 +class AbstractSearchEngine; // 抽象搜索引擎 +class GenericSearchEngine; // 通用搜索引擎 +class FileNameSearchEngine; // 文件名搜索引擎 +class ContentSearchEngine; // 内容搜索引擎 +class SearchWorker; // 搜索工作线程 +class SearchFactory; // 搜索工厂 +class SearchQuery; // 搜索查询 +class SearchOptions; // 搜索选项 +class SearchResult; // 搜索结果 +``` + +### 策略类 + +**规则**: 功能描述 + `Strategy` + +```cpp +class BaseSearchStrategy; // 基础搜索策略 +class FileNameBaseStrategy; // 文件名基础策略 +class FileNameIndexedStrategy; // 文件名索引策略 +class FileNameRealtimeStrategy; // 文件名实时策略 +class ContentBaseStrategy; // 内容基础策略 +class ContentIndexedStrategy; // 内容索引策略 +class QueryBuilder; // 查询构建器 +class IndexManager; // 索引管理器 +``` + +### 数据类 + +**规则**: 类名 + `Data` + +```cpp +class SearchResultData; // 搜索结果数据 +class SearchOptionsData; // 搜索选项数据 +class SearchWorkerPrivate; // 工作线程私有数据 +``` + +### API 类 + +**规则**: 功能描述 + `API` + +```cpp +class FileNameOptionsAPI; // 文件名选项 API +class FileNameResultAPI; // 文件名结果 API +class ContentOptionsAPI; // 内容选项 API +class ContentResultAPI; // 内容结果 API +``` + +### 工厂类 + +**规则**: 功能描述 + `Factory` + +```cpp +class SearchStrategyFactory; // 搜索策略工厂 +class FileNameSearchStrategyFactory; // 文件名策略工厂 +class ContentSearchStrategyFactory; // 内容策略工厂 +``` + +## 方法命名 + +### 公共方法 + +**规则**: 小驼峰 (camelCase) + +```cpp +// 搜索操作 +void search(const SearchQuery &query); +void cancel(); +bool isCancelled() const; + +// 属性访问 +SearchType searchType() const; +void setSearchType(SearchType type); +SearchStatus status() const; +SearchOptions searchOptions() const; +void setSearchOptions(const SearchOptions &options); + +// 结果处理 +void handleSearchResult(const SearchResult &result); +void handleSearchFinished(const SearchResultList &results); +void handleErrorOccurred(const SearchError &error); +``` + +### 静态工厂方法 + +```cpp +class SearchFactory { +public: + static SearchEngine *create(SearchType type, QObject *parent = nullptr); + static SearchQuery createQuery(const QString &keyword, QueryType type); +}; +``` + +## 成员变量命名 + +### 主成员变量 + +**规则**: `m_` 前缀 + 小驼峰 (camelCase) + +```cpp +class GenericSearchEngine { +private: + SearchOptions m_options; + SearchQuery m_currentQuery; + SearchResultList m_results; + SearchError m_lastError; + QTimer m_batchTimer; + SearchResultList m_batchResults; + std::atomic<SearchStatus> m_status; + std::atomic<bool> m_cancelled; +}; +``` + +### 指针成员 + +**规则**: `m_` + 描述名 + +```cpp +class GenericSearchEngine { +private: + QThread m_workerThread; + SearchWorker *m_worker; +}; +``` + +### PIMPL 成员 + +**规则**: 用于 PIMPL 的指针用 `d_ptr` 或 `d` + +```cpp +class SearchResult { +protected: + std::unique_ptr<SearchResultData> d; // PIMPL +}; + +class SearchEngine { +private: + std::unique_ptr<AbstractSearchEngine> d_ptr; // PIMPL +}; + +class FileNameIndexedStrategy { +private: + std::unique_ptr<QueryBuilder> m_queryBuilder; + std::unique_ptr<IndexManager> m_indexManager; +}; +``` + +## 枚举命名 + +### 枚举类型 + +**规则**: 大驼峰 (PascalCase) + +```cpp +enum SearchType { ... }; +enum SearchStatus { ... }; +enum SearchMethod { ... }; +enum QueryType { ... }; +``` + +### enum class 值 + +**规则**: 大驼峰 (PascalCase) 或 `k` + 大驼峰 + +```cpp +enum SearchType { + FileName, + Content, + Custom = 50 +}; + +enum SearchStatus { + Ready, + Searching, + Finished, + Cancelled, + Error +}; + +enum SearchMethod { + Indexed, + Realtime +}; + +enum class SearchErrorCode { + Success = 0, + InvalidQuery = 100, + PathIsEmpty, + PathNotFound, + SearchTimeout, + InternalError +}; +``` + +## 文件命名 + +| 类型 | 规则 | 示例 | +|------|------|------| +| 公共头文件 | 类名小写 + `.h` | `searchengine.h`, `filenamesearchapi.h` | +| 私有头文件 | 类名小写 + `.h` | `searchresultdata.h` | +| 实现文件 | 类名小写 + `.cpp` | `searchengine.cpp`, `indexedstrategy.cpp` | + +## 命名空间 + +```cpp +// 全局头文件定义宏 +#define DFMSEARCH dfmsearch +#define DFM_SEARCH_BEGIN_NS namespace DFMSEARCH { +#define DFM_SEARCH_END_NS } + +// 使用 +DFM_SEARCH_BEGIN_NS +class SearchEngine { /* ... */ }; +DFM_SEARCH_END_NS +``` + +## Lambda 和回调命名 + +```cpp +// 命名清晰的 lambda +auto resultHandler = [this](const SearchResult &result) { + m_results.append(result); +}; + +// 槽函数命名 +private Q_SLOTS: + void handleSearchResult(const SearchResult &result); + void handleSearchFinished(const SearchResultList &results); + void handleErrorOccurred(const SearchError &error); +``` + +## 示例代码引用 + +| 文件路径 | 描述 | +|---------|------| +| `include/dfm-search/dfm-search/searchoptions.h` | 成员变量命名 | +| `src/dfm-search/dfm-search-lib/core/genericsearchengine.h` | m_ 前缀成员变量 | +| `src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.h` | 策略类命名 | diff --git a/.trellis/spec/guides/index.md b/.trellis/spec/guides/index.md new file mode 100644 index 00000000..74347c00 --- /dev/null +++ b/.trellis/spec/guides/index.md @@ -0,0 +1,73 @@ +# 思考指南 + +> 跨层的思考流程和方法论。 + +--- + +## 概览 + +本层的指南帮助扩展思考,发现可能未考虑的问题。避免"没想到"导致的 Bug 和技术债务。 + +--- + +## 指南索引 + +| 指南 | 描述 | 何时使用 | +|------|------|----------| +| [root-cause-analysis.md](root-cause-analysis.md) | 5-Why 根因分析方法 | Bug 修复深度分析 | + +--- + +## 核心哲学 + +> **30 分钟思考节省 3 小时调试** + +### 为什么需要思考流程 + +大多数 Bug 来自"没想到": +- 没想到跨层边界问题 → 跨层 Bug +- 没想到代码模式重复 → 到处重复代码 +- 没想到边界情况 → 运行时错误 +- 没想到未来维护者 → 不可读代码 + +### 关键原则 + +1. **系统性分析** - 使用 5-Why 到达根因 +2. **证据导向** - 每一层需要证据支撑 +3. **预防为主** - 从修复中提取教训,预防复发 + +### 5-Why 方法 + +``` +问题现象 + ↓ Why 1: 为什么会出现这个现象? +直接原因 + ↓ Why 2: 为什么会有这个直接原因? +深层原因 + ↓ Why 3: 为什么允许这个条件? +系统原因 + ↓ Why 4: 为什么设计允许这个问题? +设计原因 + ↓ Why 5: 根本原因是什么? +根本原因 +``` + +--- + +## 何时使用思考流程 + +### 根因分析 + +当出现以下情况时使用 5-Why 方法: +- 复现困难的 Bug +- 多次出现相同类型的 Bug +- 修复后仍然复发的问题 +- 涉及跨模块/跨层的问题 + +### 分析步骤 + +1. **描述现象** - 清晰描述问题 +2. **收集证据** - 日志、堆栈、代码 +3. **迭代提问** - 问为什么,直到根本原因 +4. **验证假设** - 确保每个假设有证据 +5. **设计修复** - 针对根本原因的修复方案 diff --git a/.trellis/spec/guides/root-cause-analysis.md b/.trellis/spec/guides/root-cause-analysis.md new file mode 100644 index 00000000..3052ea83 --- /dev/null +++ b/.trellis/spec/guides/root-cause-analysis.md @@ -0,0 +1,186 @@ +# 根因分析指南 + +> 使用 5-Why 方法进行系统性根因分析的思考流程。 + +--- + +## 概览 + +本指南提供 Bug 修复和问题分析中使用的 5-Why 方法,帮助找到根本原因而非表面症状。 + +--- + +## 5-Why 方法 + +### 原理 + +5-Why 方法是一种迭代技术,通过重复问"为什么"来找到问题的根本原因。目标是从表面症状走向深度理解。 + +### 基本流程 + +``` +问题现象 + ↓ Why 1 +直接原因 + ↓ Why 2 +深层原因 + ↓ Why 3 +系统原因 + ↓ Why 4 +设计原因 + ↓ Why 5 +根本原因 +``` + +--- + +## 5-Why 分析模板 + +### 分析表 + +| Level | Question | Answer | Evidence | +|-------|----------|--------|----------| +| Why 1 | 为什么会出现[现象]? | [答案] | [证据] | +| Why 2 | 为什么会有[原因1]? | [答案] | [证据] | +| Why 3 | 为什么允许[条件]? | [答案] | [证据] | +| Why 4 | 为什么设计允许[问题]? | [答案] | [证据] | +| Why 5 | 根本原因是什么? | [答案] | [证据] | + +--- + +## 实际案例 + +### 案例 1: 内存泄漏崩溃 + +| Level | Question | Answer | Evidence | +|-------|----------|--------|----------| +| Why 1 | 为什么应用崩溃? | 访问释放的内存 | 崩溃堆栈显示无效指针 | +| 为什么 2 | 为什么内存被释放了还被访问? | 信号槽连接在对象销毁后仍然活跃 | 日志显示对象已 deleteLater 但信号仍然发送 | +| 为什么 3 | 为什么对象销毁后信号仍然发送? | 没有正确断开信号槽连接 | 代码中缺少 disconnect 调用 | +| 为什么 4 | 为什么没有断开连接? | 对象生命周期管理不清晰 | 多个组件共享同一个对象没有所有权明确 | +| 为什么 5 | 为什么没有明确的所有权语义? | 设计中缺少对象生命周期模型 | 架构文档中没有定义对象生存期 | + +**根本原因**: 缺少清晰的对象生命周期模型和所有权语义设计。 + +**修复**: 定义对象所有权规则,使用 smart pointer 或 parent-child 模式,确保正确断开信号槽。 + +--- + +### 案例 2: 文件复制失败 + +| Level | Question | Answer | Evidence | +|-------|----------|--------|----------| +| Why 1 | 为什么文件复制失败? | 返回权限错误 | 错误日志: "Permission denied" | +| 为什么 2 | 为什么没有权限? | 目标是系统目录 /etc | 日志显示目标路径 | +| 为什么 3 | 为什么要写入系统目录? | 配置保存路径硬编码 | 代码中硬编码路径 | +| 为什么 4 | 为什么硬编码系统路径? | 没有使用 XDG 配置目录 | 配置管理不遵循规范 | +| 为什么 5 | 为什么不遵循 XDG 规范? | 开发时没考虑到不同环境 | 缺少跨平台兼容性设计 | + +**根本原因**: 缺少跨平台路径规范,未使用 XDG 配置目录标准。 + +**修复**: 使用 QStandardPaths 获取配置目录,确保跨平台兼容。 + +--- + +## 根因类型 + +### 代码层面 + +- **边界条件缺失** - 没有检查空值、边界 +- **状态管理错误** - 对象状态不一致 +- **资源管理问题** - 内存泄漏、文件未关闭 + +### 设计层面 + +- **架构缺陷** - 分层不合理、耦合过强 +- **接口设计** - API 语义不清、容易误用 +- **数据模型** - 状态机设计不完整 + +### 流程层面 + +- **开发流程** - 缺少代码审查、测试不足 +- **配置管理** - 版本管理混乱 +- **部署流程** - 配置错误或环境不一致 + +--- + +## 常见陷阱 + +### 1. 停止在表面原因 + +``` +现象: 应用崩溃 +❌ 错误: "因为访问了空指针" → 添加空指针检查 +✅ 正确: 继续问为什么访问了空指针的流程 +``` + +### 2. 归因于人为错误 + +``` +❌ 错误: "因为开发者写错了代码" +✅ 正确: 为什么这种错误会导致进入生产环境?缺少什么检查? +``` + +### 忽略证据 + +``` +❌ 错误: 基于假设分析,没有验证 +✅ 正确: 每一层都需要证据支撑(日志、堆栈、代码) +``` + +--- + +## 修复策略 + +### 针对根本原因修复 + +| 根因类型 | 修复策略 | +|---------|---------| +| 边界条件 | 添加验证和防御性编程 | +| 状态管理 | 定义清晰的状态机 | +| 资源管理 | 使用 RAII 和 smart pointer | +| 架构缺陷 | 重构分层、降低耦合 | +| 开发流程 | 改进代码审查、增加测试 | + +### 修复验证 + +- [ ] 修复解决根本问题,不只解决症状 +- [ ] 添加回归测试防止再次发生 +- [ ] 更新文档记录根本原因和修复 +- [ ] 检查其他地方是否有相同模式 + +--- + +## 预防措施 + +### 代码层面 + +- 使用 RAII 资源管理 +- 清晰定义对象所有权 +- 完善的边界条件检查 +- 充分的单元测试覆盖 + +### 流程层面 + +- 强制代码审查 +- 静态代码分析 +- 自动化测试 +- 持续集成 + +### 文档层面 + +- 记录设计决策和权衡 +- 维护 Bug 根因分析库 +- 分享常见模式案例 + +--- + +## 快速参考 + +| 任务 | 方法 | +|------|------| +| 开始分析 | 从问题描述开始,问第一次"为什么" | +| 迭代提问 | 对每个答案继续问"为什么",最多 5 次 | +| 收集证据 | 每层都需要证据支持(日志、代码、配置) | +| 验证原因 | 确保修复能解决根本问题而非症状 | +| 预防复发 | 添加测试、更新文档、改进流程 | diff --git a/.trellis/spec/review/code-review-standards.md b/.trellis/spec/review/code-review-standards.md new file mode 100644 index 00000000..951b82da --- /dev/null +++ b/.trellis/spec/review/code-review-standards.md @@ -0,0 +1,373 @@ +# 代码审查标准 + +> DDE 桌面应用代码审查指南,专注于 C/C++/Qt 开发场景。 + +--- + +## 概览 + +本指南定义 DDE 代码审查的标准流程和检查清单。 + +--- + +## 审查流程 + +### 第 1 阶段: 上下文收集(2-3 分钟) + +审查前,理解: +1. 阅读 PR/commit 描述和关联的 issue +2. 检查 PR 大小(超过 400 行?要求拆分) +3. 检查 CI/CD 状态(构建通过?) +4. 理解业务需求 +5. 注意相关的架构决策 + +### 第 2 阶段: 高级审查(5-10 分钟) + +1. **架构与设计** + - 解决方案是否合适? + - 检查:SOLID 原则、耦合/内聚、反模式 +2. **性能评估** + - 算法复杂度、内存使用、阻塞操作 +3. **文件组织** + - 新文件是否在正确的位置? +4. **测试策略** + - 是否有测试覆盖边界情况? + +### 第 3 阶段: 逐行审查(10-20 分钟) + +对每个文件检查: +- **逻辑与正确性** - 边界情况、空检查、竞态条件 +- **安全性** - 输入验证、注入风险、敏感数据 +- **性能** - 不必要的循环、内存泄漏、阻塞 UI +- **可维护性** - 清晰命名、单一职责、注释 + +### 第 4 阶段: 总结与决策(2-3 分钟) + +1. 总结关键问题 +2. 突出你喜欢的部分 +3. 做出明确决策: + - ✅ 批准 + - 💬 评论(建议) + - 🔄 请求更改(必须解决) + +--- + +## C/C++ 审查清单 + +### 内存安全 + +```cpp +// ❌ 错误: 内存泄漏 +QObject *obj = new QObject(); // 没有父对象! + +// ✅ 正确: Parent-child 所有权 +QObject *obj = new QObject(parent); + +// ❌ 错误: Use after free +delete widget; +widget->show(); // 崩溃! + +// ✅ 正确: 使用 deleteLater +widget->deleteLater(); + +// ❌ 错误: 缓冲区溢出 +char buffer[10]; +strcpy(buffer, "very long string"); // 溢出! + +// ✅ 正确: 使用安全函数 +strncpy(buffer, "string", sizeof(buffer) - 1); +buffer[sizeof(buffer) - 1] = '\0'; +``` + +### 未定义行为 + +```cpp +// ❌ 错误: 整数溢出 +int size = a + b; // 可能溢出 + +// ✅ 正确: 检查边界 +if (a > INT_MAX - b) return error; +int size = a + b; + +// ❌ 错误: 空指针解引用 +QString name = obj->name(); // 如果 obj 为空会怎样? + +// ✅ 正确: 空检查 +if (!obj) return error; +QString name = obj->name(); + +// ❌ 错误: 未初始化变量 +int value; +if (condition) value = 1; +// value 这里可能未初始化 + +// ✅ 正确: 初始化 +int value = 0; +``` + +### RAII 模式 + +```cpp +// ❌ 错误: 手动资源管理 +FILE *fp = fopen("file.txt", "r"); +// ... 很多代码 ... +fclose(fp); // 错误时可能被跳过! + +// ✅ 正确: RAII +std::ifstream file("file.txt"); +// 作用域结束时自动关闭 + +// ✅ 正确: Qt 智能指针 +QScopedPointer<Worker> worker(new Worker()); +QSharedPointer<Resource> resource = QSharedPointer<Resource>::create(); +``` + +--- + +## Qt 审查清单 + +### 信号槽连接 + +```cpp +// ❌ 错误: 跨线程没有 queued connection +connect(threadWorker, &Worker::signal, uiWidget, &Widget::slot); + +// ✅ 正确: 跨线程使用 QueuedConnection +connect(threadWorker, &Worker::signal, uiWidget, &Widget::slot, + Qt::QueuedConnection); + +// ❌ 错误: 阻塞 UI 线程 +QThread::sleep(5); // UI 冻结! + +// ✅ 正确: 使用后台线程 +QtConcurrent::run([this]() { + // 耗时工作 +}); +``` + +### 对象生命周期 + +```cpp +// ❌ 错误: 在错误线程中删除 QObject +delete worker; // 如果 worker 在不同线程会崩溃! + +// ✅ 正确: 使用 deleteLater +worker->deleteLater(); + +// ❌ 错误: Parent-child 违规 +child->setParent(nullptr); +delete child; // 谁拥有它? + +// ✅ 正确: 清晰的所有权 +child->setParent(nullptr); +child->deleteLater(); +``` + +### 内存泄漏 + +```cpp +// ❌ 错误: 没有父对象的 Widget +DWidget *widget = new DWidget(); // 泄漏! + +// ✅ 正确: 有父对象的 Widget +DWidget *widget = new DWidget(parentWidget); + +// ❌ 错误: 没有清理的 Timer +QTimer *timer = new QTimer(); +timer->start(1000); // 泄漏! + +// ✅ 正确: 有父对象的 Timer +QTimer *timer = new QTimer(this); +timer->start(1000); +``` + +--- + +## DDE 特定清单 + +### DTK 组件使用 + +| 禁止使用 | 改用 | +|---------|------| +| `QMainWindow` | `DMainWindow` | +| `QDialog` | `DDialog` | +| `QMessageBox` | `DMessageBox` | +| `QWidget` | `DWidget` | +| `QLabel` | `DLabel` | +| `QPushButton` | `DPushButton` | +| `QLineEdit` | `DLineEdit` | +| `QListView` | `DListView` | + +### 主题适配 + +```cpp +// ❌ 错误: 硬编码颜色 +label->setStyleSheet("color: #333333; background: #ffffff;"); +button->setStyleSheet("background-color: #FF6B35;"); + +// ✅ 正确: 使用调色板 +QPalette palette = DGuiApplicationHelper::instance()->applicationPalette(); +label->setPalette(palette); + +// ✅ 正确: 使用 DTK 样式 +button->setButtonType(DPushButton::RecommendButton); +``` + +### D-Bus 集成 + +```cpp +// ❌ 错误: 错误的总线类型 +QDBusConnection::sessionBus().connect(...); // 应该用 systemBus? + +// ✅ 正确: 正确的总线类型 +// 系统服务: systemBus +QDBusConnection::systemBus().connect( + "org.freedesktop.UPower", ...); + +// 用户服务: sessionBus +QDBusConnection::sessionBus().connect(...); + +// ❌ 错误: UI 线程阻塞调用 +QDBusInterface iface(...); +QDBusReply<QString> reply = iface.call("SlowMethod"); // 阻塞 UI! + +// ✅ 正确: 异步调用 +QDBusPendingCall async = iface.asyncCall("SlowMethod"); +QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(async, this); +connect(watcher, &QDBusPendingCallWatcher::finished, + this, &MyClass::handleResult); +``` + +### Polkit 认证 + +```cpp +// ❌ 错误: 使用 sudo +system("sudo rm -rf /important"); // 安全风险! + +// ✅ 正确: 使用 Polkit +PolkitQt1::Authority::instance()->checkAuthorizationSync( + "org.deepin.myapp.authentication", + PolkitQt1::UnixProcessSubject(QCoreApplication::applicationPid()), + PolkitQt1::Authority::AllowUserInteraction); +``` + +--- + +## 安全检查清单 + +### 输入验证 + +```cpp +// ❌ 错误: 没有验证 +QFile file(userInput); // 路径遍历风险! + +// ✅ 正确: 验证输入 +QString sanitized = sanitizePath(userInput); +if (!isPathSafe(sanitized)) return; +QFile file(sanitized); + +// ❌ 错误: SQL 注入 +QString query = "SELECT * FROM users WHERE name = '" + name + "'"; + +// ✅ 正确: 使用预处理语句 +QSqlQuery query; +query.prepare("SELECT * FROM users WHERE name = ?"); +query.addBindValue(name); +``` + +### 敏感数据 + +```cpp +// ❌ 错误: 记录敏感数据 +qDebug() << "Password:" << password; + +// ✅ 正确: 掩码敏感数据 +qDebug() << "User authenticated"; + +// ❌ 错误: 明文存储密码 +settings.setValue("password", password); + +// ✅ 正确: 使用密钥环 +// 使用 libsecret 或 KWallet 进行安全存储 +``` + +--- + +## 性能检查清单 + +### 常见问题 + +```cpp +// ❌ 错误: N+1 查询 +for (const auto &item : items) { + db.query("SELECT * FROM details WHERE id = " + item.id); +} + +// ✅ 正确: 批量查询 +QStringList ids; +for (const auto &item : items) ids << QString::number(item.id); +db.query("SELECT * FROM details WHERE id IN (" + ids.join(",") + ")"); + +// ❌ 错误: UI 线程繁重工作 +void onButtonClick() { + processLargeFile(); // UI 冻结! +} + +// ✅ 正确: 使用后台线程 +void onButtonClick() { + QtConcurrent::run([this]() { + processLargeFile(); + }); +} +``` + +--- + +## 严重性标签 + +| 标签 | 含义 | 示例 | +|-----|------|------| +| 🔴 `blocking` | 合并前必须修复 | 内存泄漏、安全问题、崩溃 | +| 🟡 `important` | 应该修复 | 性能问题、代码异味 | +| 🟢 `nit` | 次要样式问题 | 变量命名、空格 | +| 💡 `suggestion` | 可选改进 | 更好的算法建议 | +| 🎉 `praise` | 做得好 | 结构良好的代码 | + +--- + +## 审查反馈模板 + +```markdown +## 代码审查总结 + +**文件变更**: X 个文件, +Y/-Z 行 + +### 🔴 阻塞问题 +1. [file:line] 关键问题描述 + +### 🟡 重要问题 +1. [file:line] 重要问题描述 + +### 💡 建议 +1. [file:line] 可选改进 + +### 🎉 做得好 +- [file] 中出色的错误处理 +- [class] 的清晰抽象 + +**决策**: 🔄 请求更改 / ✅ 批准 / 💬 评论 +``` + +--- + +## 快速参考 + +| 检查 | 命令/方法 | +|------|----------| +| 构建错误 | 变更文件上的 `lsp_diagnostics` | +| 内存泄漏 | 检查没有父对象 `new`、没有 `free` 的 `malloc` | +| 线程安全 | 检查跨线程的 `Qt::QueuedConnection` | +| 主题支持 | 检查没有硬编码颜色 | +| DTK 组件 | 检查没有 `QMainWindow`、`QDialog` 等 | +| D-Bus 正确 | 检查 systemBus vs sessionBus | +| 安全性 | 检查输入验证、日志中没有敏感数据 | diff --git a/.trellis/spec/review/index.md b/.trellis/spec/review/index.md new file mode 100644 index 00000000..de7baee6 --- /dev/null +++ b/.trellis/spec/review/index.md @@ -0,0 +1,112 @@ +# 代码审查层 + +> DDE 代码审查标准和检查清单。 + +--- + +## 概览 + +本层定义 DDE 应用代码审查的标准流程,包括 C/C++/Qt 特定检查、DDE 约定检查、安全审计和性能评估。 + +--- + +## 规范索引 + +| 文档 | 描述 | 何时阅读 | +|------|------|----------| +| [code-review-standards.md](code-review-standards.md) | 代码审查流程、C/C++/Qt 检查清单 | 所有审查 | + +--- + +## 开发前检查清单 + +在审查 PR 前阅读: + +- [x] 理解 PR 描述和关联 issue +- [x] 检查 CI/CD 状态(构建通过?) +- [ ] 评估 PR 大小(>400 行?要求拆分) +- [ ] 识别变更类型(新功能、Bug 修复、重构) + +--- + +## 质量检查 + +代码审查时验证: + +- [ ] 代码质量(可读性、可维护性) +- [ ] 内存安全(无泄漏、正确的生命周期) +- [ ] 线程安全(UI 更新在主线程) +- [ ] DTE 约定(DTK 组件、主题适配) +- [ ] 安全性(输入验证、敏感数据) +- [ ] 性能(无阻塞 UI、避免 N+1 查询) + +--- + +## 技术参考 + +### 审查参考文档 + +- [reference/architecture-review-guide.md](reference/architecture-review-guide.md) - 架构审查 +- [reference/code-review-best-practices.md](reference/code-review-best-practices.md) - 最佳实践 +- [reference/common-bugs-checklist.md](reference/common-bugs-checklist.md) - 常见 Bug +- [reference/performance-review-guide.md](reference/performance-review-guide.md) - 性能审查 +- [reference/security-review-guide.md](reference/security-review-guide.md) - 安全审查 + +### 语言特定 + +- [/reference/cpp.md](reference/cpp.md) - C++ 审查 +- [reference/c.md](reference/c.md) - C 审查 +- [reference/qt.md](reference/qt.md) - Qt 审查 + +--- + +## 核心规则摘要 + +| 检查 | 要求 | +|------|------| +| DTK 组件 | 禁止 QMainWindow、QDialog 等 QtWidgets | +| 主题适配 | 禁止硬编码颜色 | +| 内存安全 | Parent-child 关系、智能指针 | +| 线程安全 | 跨线程使用 QueuedConnection | +| 输入验证 | 文件路径、SQL 参数验证 | +| 敏感数据 | 不记录密码等敏感信息 | + +--- + +## 审查流程 + +### 第 1 阶段:上下文收集(2-3 分钟) + +1. 阅读 PR/commit 描述 +2. 关联 issue +3. CI 状态 + +### 第 2 阶段:高级审查(5-10 分钟) + +1. 架构设计 +2. 性能评估 +3. 文件组织 +4. 测试策略 + +### 第 3 阶段:逐行审查(10-20 分钟) + +1. 逻辑与正确性 +2. 安全性 +3. 性能 +4. 可维护性 + +### 第 4 阶段:总结(2-3 分钟) + +1. 关键问题 +2. 决策(批准/评论/请求更改) + +--- + +## 快速参考 + +| 检查 | 命令/方法 | +|------|----------| +| 内存泄漏 | grep "new" 检查父对象 | +| 硬编码颜色 | grep -E "#[0-9a-fA-F]{6}" | +| Qt 组件 | grep "QMainWindow\|QDialog" | +| 主线程更新 | 检查跨线程连接 | diff --git a/.trellis/spec/review/reference/architecture-review-guide.md b/.trellis/spec/review/reference/architecture-review-guide.md new file mode 100644 index 00000000..abde68ce --- /dev/null +++ b/.trellis/spec/review/reference/architecture-review-guide.md @@ -0,0 +1,472 @@ +# Architecture Review Guide + +架构设计审查指南,帮助评估代码的架构是否合理、设计是否恰当。 + +## SOLID 原则检查清单 + +### S - 单一职责原则 (SRP) + +**检查要点:** +- 这个类/模块是否只有一个改变的理由? +- 类中的方法是否都服务于同一个目的? +- 如果要向非技术人员描述这个类,能否用一句话说清楚? + +**代码审查中的识别信号:** +``` +⚠️ 类名包含 "And"、"Manager"、"Handler"、"Processor" 等泛化词汇 +⚠️ 一个类超过 200-300 行代码 +⚠️ 类有超过 5-7 个公共方法 +⚠️ 不同的方法操作完全不同的数据 +``` + +**审查问题:** +- "这个类负责哪些事情?能否拆分?" +- "如果 X 需求变化,哪些方法需要改?如果 Y 需求变化呢?" + +### O - 开闭原则 (OCP) + +**检查要点:** +- 添加新功能时,是否需要修改现有代码? +- 是否可以通过扩展(继承、组合)来添加新行为? +- 是否存在大量的 if/else 或 switch 语句来处理不同类型? + +**代码审查中的识别信号:** +``` +⚠️ switch/if-else 链处理不同类型 +⚠️ 添加新功能需要修改核心类 +⚠️ 类型检查 (instanceof, typeof) 散布在代码中 +``` + +**审查问题:** +- "如果要添加新的 X 类型,需要修改哪些文件?" +- "这个 switch 语句会随着新类型增加而增长吗?" + +### L - 里氏替换原则 (LSP) + +**检查要点:** +- 子类是否可以完全替代父类使用? +- 子类是否改变了父类方法的预期行为? +- 是否存在子类抛出父类未声明的异常? + +**代码审查中的识别信号:** +``` +⚠️ 显式类型转换 (casting) +⚠️ 子类方法抛出 NotImplementedException +⚠️ 子类方法为空实现或只有 return +⚠️ 使用基类的地方需要检查具体类型 +``` + +**审查问题:** +- "如果用子类替换父类,调用方代码是否需要修改?" +- "这个方法在子类中的行为是否符合父类的契约?" + +### I - 接口隔离原则 (ISP) + +**检查要点:** +- 接口是否足够小且专注? +- 实现类是否被迫实现不需要的方法? +- 客户端是否依赖了它不使用的方法? + +**代码审查中的识别信号:** +``` +⚠️ 接口超过 5-7 个方法 +⚠️ 实现类有空方法或抛出 NotImplementedException +⚠️ 接口名称过于宽泛 (IManager, IService) +⚠️ 不同的客户端只使用接口的部分方法 +``` + +**审查问题:** +- "这个接口的所有方法是否都被每个实现类使用?" +- "能否将这个大接口拆分为更小的专用接口?" + +### D - 依赖倒置原则 (DIP) + +**检查要点:** +- 高层模块是否依赖于抽象而非具体实现? +- 是否使用依赖注入而非直接 new 对象? +- 抽象是否由高层模块定义而非低层模块? + +**代码审查中的识别信号:** +``` +⚠️ 高层模块直接 new 低层模块的具体类 +⚠️ 导入具体实现类而非接口/抽象类 +⚠️ 配置和连接字符串硬编码在业务逻辑中 +⚠️ 难以为某个类编写单元测试 +``` + +**审查问题:** +- "这个类的依赖能否在测试时被 mock 替换?" +- "如果要更换数据库/API 实现,需要修改多少地方?" + +--- + +## 架构反模式识别 + +### 致命反模式 + +| 反模式 | 识别信号 | 影响 | +|--------|----------|------| +| **大泥球 (Big Ball of Mud)** | 没有清晰的模块边界,任何代码都可能调用任何其他代码 | 难以理解、修改和测试 | +| **上帝类 (God Object)** | 单个类承担过多职责,知道太多、做太多 | 高耦合,难以重用和测试 | +| **意大利面条代码** | 控制流程混乱,goto 或深层嵌套,难以追踪执行路径 | 难以理解和维护 | +| **熔岩流 (Lava Flow)** | 没人敢动的古老代码,缺乏文档和测试 | 技术债务累积 | + +### 设计反模式 + +| 反模式 | 识别信号 | 建议 | +|--------|----------|------| +| **金锤子 (Golden Hammer)** | 对所有问题使用同一种技术/模式 | 根据问题选择合适的解决方案 | +| **过度工程 (Gas Factory)** | 简单问题用复杂方案解决,滥用设计模式 | YAGNI 原则,先简单后复杂 | +| **船锚 (Boat Anchor)** | 为"将来可能需要"而写的未使用代码 | 删除未使用代码,需要时再写 | +| **复制粘贴编程** | 相同逻辑出现在多处 | 提取公共方法或模块 | + +### 审查问题 + +```markdown +🔴 [blocking] "这个类有 2000 行代码,建议拆分为多个专注的类" +🟡 [important] "这段逻辑在 3 个地方重复,考虑提取为公共方法?" +💡 [suggestion] "这个 switch 语句可以用策略模式替代,更易扩展" +``` + +--- + +## 耦合度与内聚性评估 + +### 耦合类型(从好到差) + +| 类型 | 描述 | 示例 | +|------|------|------| +| **消息耦合** ✅ | 通过参数传递数据 | `calculate(price, quantity)` | +| **数据耦合** ✅ | 共享简单数据结构 | `processOrder(orderDTO)` | +| **印记耦合** ⚠️ | 共享复杂数据结构但只用部分 | 传入整个 User 对象但只用 name | +| **控制耦合** ⚠️ | 传递控制标志影响行为 | `process(data, isAdmin=true)` | +| **公共耦合** ❌ | 共享全局变量 | 多个模块读写同一个全局状态 | +| **内容耦合** ❌ | 直接访问另一模块的内部 | 直接操作另一个类的私有属性 | + +### 内聚类型(从好到差) + +| 类型 | 描述 | 质量 | +|------|------|------| +| **功能内聚** | 所有元素完成单一任务 | ✅ 最佳 | +| **顺序内聚** | 输出作为下一步输入 | ✅ 良好 | +| **通信内聚** | 操作相同数据 | ⚠️ 可接受 | +| **时间内聚** | 同时执行的任务 | ⚠️ 较差 | +| **逻辑内聚** | 逻辑相关但功能不同 | ❌ 差 | +| **偶然内聚** | 没有明显关系 | ❌ 最差 | + +### 度量指标参考 + +```yaml +耦合指标: + CBO (类间耦合): + 好: < 5 + 警告: 5-10 + 危险: > 10 + + Ce (传出耦合): + 描述: 依赖多少外部类 + 好: < 7 + + Ca (传入耦合): + 描述: 被多少类依赖 + 高值意味着: 修改影响大,需要稳定 + +内聚指标: + LCOM4 (方法缺乏内聚): + 1: 单一职责 ✅ + 2-3: 可能需要拆分 ⚠️ + >3: 应该拆分 ❌ +``` + +### 审查问题 + +- "这个模块依赖了多少其他模块?能否减少?" +- "修改这个类会影响多少其他地方?" +- "这个类的方法是否都操作相同的数据?" + +--- + +## 分层架构审查 + +### Clean Architecture 层次检查 + +``` +┌─────────────────────────────────────┐ +│ Frameworks & Drivers │ ← 最外层:Web、DB、UI +├─────────────────────────────────────┤ +│ Interface Adapters │ ← Controllers、Gateways、Presenters +├─────────────────────────────────────┤ +│ Application Layer │ ← Use Cases、Application Services +├─────────────────────────────────────┤ +│ Domain Layer │ ← Entities、Domain Services +└─────────────────────────────────────┘ + ↑ 依赖方向只能向内 ↑ +``` + +### 依赖规则检查 + +**核心规则:源代码依赖只能指向内层** + +```typescript +// ❌ 违反依赖规则:Domain 层依赖 Infrastructure +// domain/User.ts +import { MySQLConnection } from '../infrastructure/database'; + +// ✅ 正确:Domain 层定义接口,Infrastructure 实现 +// domain/UserRepository.ts (接口) +interface UserRepository { + findById(id: string): Promise<User>; +} + +// infrastructure/MySQLUserRepository.ts (实现) +class MySQLUserRepository implements UserRepository { + findById(id: string): Promise<User> { /* ... */ } +} +``` + +### 审查清单 + +**层次边界检查:** +- [ ] Domain 层是否有外部依赖(数据库、HTTP、文件系统)? +- [ ] Application 层是否直接操作数据库或调用外部 API? +- [ ] Controller 是否包含业务逻辑? +- [ ] 是否存在跨层调用(UI 直接调用 Repository)? + +**关注点分离检查:** +- [ ] 业务逻辑是否与展示逻辑分离? +- [ ] 数据访问是否封装在专门的层? +- [ ] 配置和环境相关代码是否集中管理? + +### 审查问题 + +```markdown +🔴 [blocking] "Domain 实体直接导入了数据库连接,违反依赖规则" +🟡 [important] "Controller 包含业务计算逻辑,建议移到 Service 层" +💡 [suggestion] "考虑使用依赖注入来解耦这些组件" +``` + +--- + +## 设计模式使用评估 + +### 何时使用设计模式 + +| 模式 | 适用场景 | 不适用场景 | +|------|----------|------------| +| **Factory** | 需要创建不同类型对象,类型在运行时确定 | 只有一种类型,或类型固定不变 | +| **Strategy** | 算法需要在运行时切换,有多种可互换的行为 | 只有一种算法,或算法不会变化 | +| **Observer** | 一对多依赖,状态变化需要通知多个对象 | 简单的直接调用即可满足需求 | +| **Singleton** | 确实需要全局唯一实例,如配置管理 | 可以通过依赖注入传递的对象 | +| **Decorator** | 需要动态添加职责,避免继承爆炸 | 职责固定,不需要动态组合 | + +### 过度设计警告信号 + +``` +⚠️ Patternitis(模式炎)识别信号: + +1. 简单的 if/else 被替换为策略模式 + 工厂 + 注册表 +2. 只有一个实现的接口 +3. 为了"将来可能需要"而添加的抽象层 +4. 代码行数因模式应用而大幅增加 +5. 新人需要很长时间才能理解代码结构 +``` + +### 审查原则 + +```markdown +✅ 正确使用模式: +- 解决了实际的可扩展性问题 +- 代码更容易理解和测试 +- 添加新功能变得更简单 + +❌ 过度使用模式: +- 为了使用模式而使用 +- 增加了不必要的复杂度 +- 违反了 YAGNI 原则 +``` + +### 审查问题 + +- "使用这个模式解决了什么具体问题?" +- "如果不用这个模式,代码会有什么问题?" +- "这个抽象层带来的价值是否大于它的复杂度?" + +--- + +## 可扩展性评估 + +### 扩展性检查清单 + +**功能扩展性:** +- [ ] 添加新功能是否需要修改核心代码? +- [ ] 是否提供了扩展点(hooks、plugins、events)? +- [ ] 配置是否外部化(配置文件、环境变量)? + +**数据扩展性:** +- [ ] 数据模型是否支持新增字段? +- [ ] 是否考虑了数据量增长的场景? +- [ ] 查询是否有合适的索引? + +**负载扩展性:** +- [ ] 是否可以水平扩展(添加更多实例)? +- [ ] 是否有状态依赖(session、本地缓存)? +- [ ] 数据库连接是否使用连接池? + +### 扩展点设计检查 + +```typescript +// ✅ 好的扩展设计:使用事件/钩子 +class OrderService { + private hooks: OrderHooks; + + async createOrder(order: Order) { + await this.hooks.beforeCreate?.(order); + const result = await this.save(order); + await this.hooks.afterCreate?.(result); + return result; + } +} + +// ❌ 差的扩展设计:硬编码所有行为 +class OrderService { + async createOrder(order: Order) { + await this.sendEmail(order); // 硬编码 + await this.updateInventory(order); // 硬编码 + await this.notifyWarehouse(order); // 硬编码 + return await this.save(order); + } +} +``` + +### 审查问题 + +```markdown +💡 [suggestion] "如果将来需要支持新的支付方式,这个设计是否容易扩展?" +🟡 [important] "这里的逻辑是硬编码的,考虑使用配置或策略模式?" +📚 [learning] "事件驱动架构可以让这个功能更容易扩展" +``` + +--- + +## 代码结构最佳实践 + +### 目录组织 + +**按功能/领域组织(推荐):** +``` +src/ +├── user/ +│ ├── User.ts (实体) +│ ├── UserService.ts (服务) +│ ├── UserRepository.ts (数据访问) +│ └── UserController.ts (API) +├── order/ +│ ├── Order.ts +│ ├── OrderService.ts +│ └── ... +└── shared/ + ├── utils/ + └── types/ +``` + +**按技术层组织(不推荐):** +``` +src/ +├── controllers/ ← 不同领域混在一起 +│ ├── UserController.ts +│ └── OrderController.ts +├── services/ +├── repositories/ +└── models/ +``` + +### 命名约定检查 + +| 类型 | 约定 | 示例 | +|------|------|------| +| 类名 | PascalCase,名词 | `UserService`, `OrderRepository` | +| 方法名 | camelCase,动词 | `createUser`, `findOrderById` | +| 接口名 | I 前缀或无前缀 | `IUserService` 或 `UserService` | +| 常量 | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT` | +| 私有属性 | 下划线前缀或无 | `_cache` 或 `#cache` | + +### 文件大小指南 + +```yaml +建议限制: + 单个文件: < 300 行 + 单个函数: < 50 行 + 单个类: < 200 行 + 函数参数: < 4 个 + 嵌套深度: < 4 层 + +超出限制时: + - 考虑拆分为更小的单元 + - 使用组合而非继承 + - 提取辅助函数或类 +``` + +### 审查问题 + +```markdown +🟢 [nit] "这个 500 行的文件可以考虑按职责拆分" +🟡 [important] "建议按功能领域而非技术层组织目录结构" +💡 [suggestion] "函数名 `process` 不够明确,考虑改为 `calculateOrderTotal`?" +``` + +--- + +## 快速参考清单 + +### 架构审查 5 分钟速查 + +```markdown +□ 依赖方向是否正确?(外层依赖内层) +□ 是否存在循环依赖? +□ 核心业务逻辑是否与框架/UI/数据库解耦? +□ 是否遵循 SOLID 原则? +□ 是否存在明显的反模式? +``` + +### 红旗信号(必须处理) + +```markdown +🔴 God Object - 单个类超过 1000 行 +🔴 循环依赖 - A → B → C → A +🔴 Domain 层包含框架依赖 +🔴 硬编码的配置和密钥 +🔴 没有接口的外部服务调用 +``` + +### 黄旗信号(建议处理) + +```markdown +🟡 类间耦合度 (CBO) > 10 +🟡 方法参数超过 5 个 +🟡 嵌套深度超过 4 层 +🟡 重复代码块 > 10 行 +🟡 只有一个实现的接口 +``` + +--- + +## 工具推荐 + +| 工具 | 用途 | 语言支持 | +|------|------|----------| +| **SonarQube** | 代码质量、耦合度分析 | 多语言 | +| **NDepend** | 依赖分析、架构规则 | .NET | +| **JDepend** | 包依赖分析 | Java | +| **Madge** | 模块依赖图 | JavaScript/TypeScript | +| **ESLint** | 代码规范、复杂度检查 | JavaScript/TypeScript | +| **CodeScene** | 技术债务、热点分析 | 多语言 | + +--- + +## 参考资源 + +- [Clean Architecture - Uncle Bob](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) +- [SOLID Principles in Code Review - JetBrains](https://blog.jetbrains.com/upsource/2015/08/31/what-to-look-for-in-a-code-review-solid-principles-2/) +- [Software Architecture Anti-Patterns](https://medium.com/@christophnissle/anti-patterns-in-software-architecture-3c8970c9c4f5) +- [Coupling and Cohesion in System Design](https://www.geeksforgeeks.org/system-design/coupling-and-cohesion-in-system-design/) +- [Design Patterns - Refactoring Guru](https://refactoring.guru/design-patterns) diff --git a/.trellis/spec/review/reference/c.md b/.trellis/spec/review/reference/c.md new file mode 100644 index 00000000..cfd31ed1 --- /dev/null +++ b/.trellis/spec/review/reference/c.md @@ -0,0 +1,285 @@ +# C Code Review Guide + +> C code review guide focused on memory safety, undefined behavior, and portability. Examples assume C11. + +## Table of Contents + +- [Pointer and Buffer Safety](#pointer-and-buffer-safety) +- [Ownership and Resource Management](#ownership-and-resource-management) +- [Undefined Behavior Pitfalls](#undefined-behavior-pitfalls) +- [Integer Types and Overflow](#integer-types-and-overflow) +- [Error Handling](#error-handling) +- [Concurrency](#concurrency) +- [Macros and Preprocessor](#macros-and-preprocessor) +- [API Design and Const](#api-design-and-const) +- [Tooling and Build Checks](#tooling-and-build-checks) +- [Review Checklist](#review-checklist) + +--- + +## Pointer and Buffer Safety + +### Always carry size with buffers + +```c +// ? Bad: ignores destination size +bool copy_name(char *dst, size_t dst_size, const char *src) { + strcpy(dst, src); + return true; +} + +// ? Good: validate size and terminate +bool copy_name(char *dst, size_t dst_size, const char *src) { + size_t len = strlen(src); + if (len + 1 > dst_size) { + return false; + } + memcpy(dst, src, len + 1); + return true; +} +``` + +### Avoid dangerous APIs + +Prefer `snprintf`, `fgets`, and explicit bounds over `gets`, `strcpy`, or `sprintf`. + +```c +// ? Bad: unbounded write +sprintf(buf, "%s", input); + +// ? Good: bounded write +snprintf(buf, buf_size, "%s", input); +``` + +### Use the right copy primitive + +```c +// ? Bad: memcpy with overlapping regions +memcpy(dst, src, len); + +// ? Good: memmove handles overlap +memmove(dst, src, len); +``` + +--- + +## Ownership and Resource Management + +### One allocation, one free + +Track ownership and clean up on every error path. + +```c +// ? Good: cleanup label avoids leaks +int load_file(const char *path) { + int rc = -1; + FILE *f = NULL; + char *buf = NULL; + + f = fopen(path, "rb"); + if (!f) { + goto cleanup; + } + buf = malloc(4096); + if (!buf) { + goto cleanup; + } + + if (fread(buf, 1, 4096, f) == 0) { + goto cleanup; + } + + rc = 0; + +cleanup: + free(buf); + if (f) { + fclose(f); + } + return rc; +} +``` + +--- + +## Undefined Behavior Pitfalls + +### Common UB patterns + +```c +// ? Bad: use after free +char *p = malloc(10); +free(p); +p[0] = 'a'; + +// ? Bad: uninitialized read +int x; +if (x > 0) { /* UB */ } + +// ? Bad: signed overflow +int sum = a + b; +``` + +### Avoid pointer arithmetic past the object + +```c +// ? Bad: pointer past the end then dereference +int arr[4]; +int *p = arr + 4; +int v = *p; // UB +``` + +--- + +## Integer Types and Overflow + +### Avoid signed/unsigned surprises + +```c +// ? Bad: negative converted to large size_t +int len = -1; +size_t n = len; + +// ? Good: validate before converting +if (len < 0) { + return -1; +} +size_t n = (size_t)len; +``` + +### Check for overflow in size calculations + +```c +// ? Bad: potential overflow in multiplication +size_t bytes = count * sizeof(Item); + +// ? Good: check before multiplying +if (count > SIZE_MAX / sizeof(Item)) { + return NULL; +} +size_t bytes = count * sizeof(Item); +``` + +--- + +## Error Handling + +### Always check return values + +```c +// ? Bad: ignore errors +fread(buf, 1, size, f); + +// ? Good: handle errors +size_t read = fread(buf, 1, size, f); +if (read != size && ferror(f)) { + return -1; +} +``` + +### Consistent error contracts + +- Use a clear convention: 0 for success, negative for failure. +- Document ownership rules on success and failure. +- If using `errno`, set it only for actual failures. + +--- + +## Concurrency + +### volatile is not synchronization + +```c +// ? Bad: data race +volatile int stop = 0; +void worker(void) { + while (!stop) { /* ... */ } +} + +// ? Good: C11 atomics +_Atomic int stop = 0; +void worker(void) { + while (!atomic_load(&stop)) { /* ... */ } +} +``` + +### Use mutexes for shared state + +Protect shared data with `pthread_mutex_t` or equivalent. Avoid holding locks while doing I/O. + +--- + +## Macros and Preprocessor + +### Parenthesize arguments + +```c +// ? Bad: macro with side effects +#define MIN(a, b) ((a) < (b) ? (a) : (b)) +int x = MIN(i++, j++); + +// ? Good: static inline function +static inline int min_int(int a, int b) { + return a < b ? a : b; +} +``` + +--- + +## API Design and Const + +### Const-correctness and sizes + +```c +// ? Good: explicit size and const input +int hash_bytes(const uint8_t *data, size_t len, uint8_t *out); +``` + +### Document nullability + +Clearly document whether pointers may be NULL. Prefer returning error codes instead of NULL when possible. + +--- + +## Tooling and Build Checks + +```bash +# Warnings +clang -Wall -Wextra -Werror -Wconversion -Wshadow -std=c11 ... + +# Sanitizers (debug builds) +clang -fsanitize=address,undefined -fno-omit-frame-pointer -g ... +clang -fsanitize=thread -fno-omit-frame-pointer -g ... + +# Static analysis +clang-tidy src/*.c -- -std=c11 +cppcheck --enable=warning,performance,portability src/ + +# Formatting +clang-format -i src/*.c include/*.h +``` + +--- + +## Review Checklist + +### Memory and UB +- [ ] All buffers have explicit size parameters +- [ ] No out-of-bounds access or pointer arithmetic past objects +- [ ] No use after free or uninitialized reads +- [ ] Signed overflow and shift rules are respected + +### API and Design +- [ ] Ownership rules are documented and consistent +- [ ] const-correctness is applied for inputs +- [ ] Error contracts are clear and consistent + +### Concurrency +- [ ] No data races on shared state +- [ ] volatile is not used for synchronization +- [ ] Locks are held for minimal time + +### Tooling and Tests +- [ ] Builds clean with warnings enabled +- [ ] Sanitizers run on critical code paths +- [ ] Static analysis results are addressed diff --git a/.trellis/spec/review/reference/code-review-best-practices.md b/.trellis/spec/review/reference/code-review-best-practices.md new file mode 100644 index 00000000..8c6b9cdb --- /dev/null +++ b/.trellis/spec/review/reference/code-review-best-practices.md @@ -0,0 +1,136 @@ +# Code Review Best Practices + +Comprehensive guidelines for conducting effective code reviews. + +## Review Philosophy + +### Goals of Code Review + +**Primary Goals:** +- Catch bugs and edge cases before production +- Ensure code maintainability and readability +- Share knowledge across the team +- Enforce coding standards consistently +- Improve design and architecture decisions + +**Secondary Goals:** +- Mentor junior developers +- Build team culture and trust +- Document design decisions through discussions + +### What Code Review is NOT + +- A gatekeeping mechanism to block progress +- An opportunity to show off knowledge +- A place to nitpick formatting (use linters) +- A way to rewrite code to personal preference + +## Review Timing + +### When to Review + +| Trigger | Action | +|---------|--------| +| PR opened | Review within 24 hours, ideally same day | +| Changes requested | Re-review within 4 hours | +| Blocking issue found | Communicate immediately | + +### Time Allocation + +- **Small PR (<100 lines)**: 10-15 minutes +- **Medium PR (100-400 lines)**: 20-40 minutes +- **Large PR (>400 lines)**: Request to split, or 60+ minutes + +## Review Depth Levels + +### Level 1: Skim Review (5 minutes) +- Check PR description and linked issues +- Verify CI/CD status +- Look at file changes overview +- Identify if deeper review needed + +### Level 2: Standard Review (20-30 minutes) +- Full code walkthrough +- Logic verification +- Test coverage check +- Security scan + +### Level 3: Deep Review (60+ minutes) +- Architecture evaluation +- Performance analysis +- Security audit +- Edge case exploration + +## Communication Guidelines + +### Tone and Language + +**Use collaborative language:** +- "What do you think about..." instead of "You should..." +- "Could we consider..." instead of "This is wrong" +- "I'm curious about..." instead of "Why didn't you..." + +**Be specific and actionable:** +- Include code examples when suggesting changes +- Link to documentation or past discussions +- Explain the "why" behind suggestions + +### Handling Disagreements + +1. **Seek to understand**: Ask clarifying questions +2. **Acknowledge valid points**: Show you've considered their perspective +3. **Provide data**: Use benchmarks, docs, or examples +4. **Escalate if needed**: Involve senior dev or architect +5. **Know when to let go**: Not every hill is worth dying on + +## Review Prioritization + +### Must Fix (Blocking) +- Security vulnerabilities +- Data corruption risks +- Breaking changes without migration +- Critical performance issues +- Missing error handling for user-facing features + +### Should Fix (Important) +- Test coverage gaps +- Moderate performance concerns +- Code duplication +- Unclear naming or structure +- Missing documentation for complex logic + +### Nice to Have (Non-blocking) +- Style preferences beyond linting +- Minor optimizations +- Additional test cases +- Documentation improvements + +## Anti-Patterns to Avoid + +### Reviewer Anti-Patterns +- **Rubber stamping**: Approving without actually reviewing +- **Bike shedding**: Debating trivial details extensively +- **Scope creep**: "While you're at it, can you also..." +- **Ghosting**: Requesting changes then disappearing +- **Perfectionism**: Blocking for minor style preferences + +### Author Anti-Patterns +- **Mega PRs**: Submitting 1000+ line changes +- **No context**: Missing PR description or linked issues +- **Defensive responses**: Arguing every suggestion +- **Silent updates**: Making changes without responding to comments + +## Metrics and Improvement + +### Track These Metrics +- Time to first review +- Review cycle time +- Number of review rounds +- Defect escape rate +- Review coverage percentage + +### Continuous Improvement +- Hold retrospectives on review process +- Share learnings from escaped bugs +- Update checklists based on common issues +- Celebrate good reviews and catches diff --git a/.trellis/spec/review/reference/common-bugs-checklist.md b/.trellis/spec/review/reference/common-bugs-checklist.md new file mode 100644 index 00000000..97e2e637 --- /dev/null +++ b/.trellis/spec/review/reference/common-bugs-checklist.md @@ -0,0 +1,1227 @@ +# Common Bugs Checklist + +Language-specific bugs and issues to watch for during code review. + +## Universal Issues + +### Logic Errors +- [ ] Off-by-one errors in loops and array access +- [ ] Incorrect boolean logic (De Morgan's law violations) +- [ ] Missing null/undefined checks +- [ ] Race conditions in concurrent code +- [ ] Incorrect comparison operators (== vs ===, = vs ==) +- [ ] Integer overflow/underflow +- [ ] Floating point comparison issues + +### Resource Management +- [ ] Memory leaks (unclosed connections, listeners) +- [ ] File handles not closed +- [ ] Database connections not released +- [ ] Event listeners not removed +- [ ] Timers/intervals not cleared + +### Error Handling +- [ ] Swallowed exceptions (empty catch blocks) +- [ ] Generic exception handling hiding specific errors +- [ ] Missing error propagation +- [ ] Incorrect error types thrown +- [ ] Missing finally/cleanup blocks + +## TypeScript/JavaScript + +### Type Issues +```typescript +// ❌ Using any defeats type safety +function process(data: any) { return data.value; } + +// ✅ Use proper types +interface Data { value: string; } +function process(data: Data) { return data.value; } +``` + +### Async/Await Pitfalls +```typescript +// ❌ Missing await +async function fetch() { + const data = fetchData(); // Missing await! + return data.json(); +} + +// ❌ Unhandled promise rejection +async function risky() { + const result = await fetchData(); // No try-catch + return result; +} + +// ✅ Proper error handling +async function safe() { + try { + const result = await fetchData(); + return result; + } catch (error) { + console.error('Fetch failed:', error); + throw error; + } +} +``` + +### React Specific + +#### Hooks 规则违反 +```tsx +// ❌ 条件调用 Hooks — 违反 Hooks 规则 +function BadComponent({ show }) { + if (show) { + const [value, setValue] = useState(0); // Error! + } + return <div>...</div>; +} + +// ✅ Hooks 必须在顶层无条件调用 +function GoodComponent({ show }) { + const [value, setValue] = useState(0); + if (!show) return null; + return <div>{value}</div>; +} + +// ❌ 循环中调用 Hooks +function BadLoop({ items }) { + items.forEach(item => { + const [selected, setSelected] = useState(false); // Error! + }); +} + +// ✅ 将状态提升或使用不同的数据结构 +function GoodLoop({ items }) { + const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); + return items.map(item => ( + <Item key={item.id} selected={selectedIds.has(item.id)} /> + )); +} +``` + +#### useEffect 常见错误 +```tsx +// ❌ 依赖数组不完整 — stale closure +function StaleClosureExample({ userId, onSuccess }) { + const [data, setData] = useState(null); + useEffect(() => { + fetchData(userId).then(result => { + setData(result); + onSuccess(result); // onSuccess 可能是 stale 的! + }); + }, [userId]); // 缺少 onSuccess 依赖 +} + +// ✅ 完整的依赖数组 +useEffect(() => { + fetchData(userId).then(result => { + setData(result); + onSuccess(result); + }); +}, [userId, onSuccess]); + +// ❌ 无限循环 — 在 effect 中更新依赖 +function InfiniteLoop() { + const [count, setCount] = useState(0); + useEffect(() => { + setCount(count + 1); // 触发重渲染,又触发 effect + }, [count]); // 无限循环! +} + +// ❌ 缺少清理函数 — 内存泄漏 +function MemoryLeak({ userId }) { + const [user, setUser] = useState(null); + useEffect(() => { + fetchUser(userId).then(setUser); // 组件卸载后仍然调用 setUser + }, [userId]); +} + +// ✅ 正确的清理 +function NoLeak({ userId }) { + const [user, setUser] = useState(null); + useEffect(() => { + let cancelled = false; + fetchUser(userId).then(data => { + if (!cancelled) setUser(data); + }); + return () => { cancelled = true; }; + }, [userId]); +} + +// ❌ useEffect 用于派生状态(反模式) +function BadDerived({ items }) { + const [total, setTotal] = useState(0); + useEffect(() => { + setTotal(items.reduce((a, b) => a + b.price, 0)); + }, [items]); // 不必要的 effect + 额外渲染 +} + +// ✅ 直接计算或用 useMemo +function GoodDerived({ items }) { + const total = useMemo( + () => items.reduce((a, b) => a + b.price, 0), + [items] + ); +} + +// ❌ useEffect 用于事件响应 +function BadEvent() { + const [query, setQuery] = useState(''); + useEffect(() => { + if (query) logSearch(query); // 应该在事件处理器中 + }, [query]); +} + +// ✅ 副作用在事件处理器中 +function GoodEvent() { + const handleSearch = (q: string) => { + setQuery(q); + logSearch(q); + }; +} +``` + +#### useMemo / useCallback 误用 +```tsx +// ❌ 过度优化 — 常量不需要 memo +function OverOptimized() { + const config = useMemo(() => ({ api: '/v1' }), []); // 无意义 + const noop = useCallback(() => {}, []); // 无意义 +} + +// ❌ 空依赖的 useMemo(可能隐藏 bug) +function EmptyDeps({ user }) { + const greeting = useMemo(() => `Hello ${user.name}`, []); + // user 变化时 greeting 不更新! +} + +// ❌ useCallback 依赖总是变化 +function UselessCallback({ data }) { + const process = useCallback(() => { + return data.map(transform); + }, [data]); // 如果 data 每次都是新引用,完全无效 +} + +// ❌ useMemo/useCallback 没有配合 React.memo +function Parent() { + const data = useMemo(() => compute(), []); + const handler = useCallback(() => {}, []); + return <Child data={data} onClick={handler} />; + // Child 没有用 React.memo,这些优化毫无意义 +} + +// ✅ 正确的优化组合 +const MemoChild = React.memo(function Child({ data, onClick }) { + return <button onClick={onClick}>{data}</button>; +}); + +function Parent() { + const data = useMemo(() => expensiveCompute(), [dep]); + const handler = useCallback(() => {}, []); + return <MemoChild data={data} onClick={handler} />; +} +``` + +#### 组件设计问题 +```tsx +// ❌ 在组件内定义组件 +function Parent() { + // 每次渲染都创建新的 Child 函数,导致完全重新挂载 + const Child = () => <div>child</div>; + return <Child />; +} + +// ✅ 组件定义在外部 +const Child = () => <div>child</div>; +function Parent() { + return <Child />; +} + +// ❌ Props 总是新引用 — 破坏 memo +function BadProps() { + return ( + <MemoComponent + style={{ color: 'red' }} // 每次渲染新对象 + onClick={() => handle()} // 每次渲染新函数 + items={data.filter(x => x)} // 每次渲染新数组 + /> + ); +} + +// ❌ 直接修改 props +function MutateProps({ user }) { + user.name = 'Changed'; // 永远不要这样做! + return <div>{user.name}</div>; +} +``` + +#### Server Components 错误 (React 19+) +```tsx +// ❌ 在 Server Component 中使用客户端 API +// app/page.tsx (默认是 Server Component) +export default function Page() { + const [count, setCount] = useState(0); // Error! + useEffect(() => {}, []); // Error! + return <button onClick={() => {}}>Click</button>; // Error! +} + +// ✅ 交互逻辑移到 Client Component +// app/counter.tsx +'use client'; +export function Counter() { + const [count, setCount] = useState(0); + return <button onClick={() => setCount(c => c + 1)}>{count}</button>; +} + +// app/page.tsx +import { Counter } from './counter'; +export default async function Page() { + const data = await fetchData(); // Server Component 可以直接 await + return <Counter initialCount={data.count} />; +} + +// ❌ 在父组件标记 'use client',整个子树变成客户端 +// layout.tsx +'use client'; // 坏主意!所有子组件都变成客户端组件 +export default function Layout({ children }) { ... } +``` + +#### 测试常见错误 +```tsx +// ❌ 使用 container 查询 +const { container } = render(<Component />); +const button = container.querySelector('button'); // 不推荐 + +// ✅ 使用 screen 和语义查询 +render(<Component />); +const button = screen.getByRole('button', { name: /submit/i }); + +// ❌ 使用 fireEvent +fireEvent.click(button); + +// ✅ 使用 userEvent +await userEvent.click(button); + +// ❌ 测试实现细节 +expect(component.state.isOpen).toBe(true); + +// ✅ 测试行为 +expect(screen.getByRole('dialog')).toBeVisible(); + +// ❌ 等待同步查询 +await screen.getByText('Hello'); // getBy 是同步的 + +// ✅ 异步用 findBy +await screen.findByText('Hello'); // findBy 会等待 +``` + +### React Common Mistakes Checklist +- [ ] Hooks 不在顶层调用(条件/循环中) +- [ ] useEffect 依赖数组不完整 +- [ ] useEffect 缺少清理函数 +- [ ] useEffect 用于派生状态计算 +- [ ] useMemo/useCallback 过度使用 +- [ ] useMemo/useCallback 没配合 React.memo +- [ ] 在组件内定义子组件 +- [ ] Props 是新对象/函数引用(传给 memo 组件时) +- [ ] 直接修改 props +- [ ] 列表缺少 key 或用 index 作为 key +- [ ] Server Component 使用客户端 API +- [ ] 'use client' 放在父组件导致整个树客户端化 +- [ ] 测试使用 container 查询而非 screen +- [ ] 测试实现细节而非行为 + +### React 19 Actions & Forms 错误 + +```tsx +// === useActionState 错误 === + +// ❌ 在 Action 中直接 setState 而不是返回状态 +const [state, action] = useActionState(async (prev, formData) => { + setSomeState(newValue); // 错误!应该返回新状态 +}, initialState); + +// ✅ 返回新状态 +const [state, action] = useActionState(async (prev, formData) => { + const result = await submitForm(formData); + return { ...prev, data: result }; // 返回新状态 +}, initialState); + +// ❌ 忘记处理 isPending +const [state, action] = useActionState(submitAction, null); +return <button>Submit</button>; // 用户可以重复点击 + +// ✅ 使用 isPending 禁用按钮 +const [state, action, isPending] = useActionState(submitAction, null); +return <button disabled={isPending}>Submit</button>; + +// === useFormStatus 错误 === + +// ❌ 在 form 同级调用 useFormStatus +function Form() { + const { pending } = useFormStatus(); // 永远是 undefined! + return <form><button disabled={pending}>Submit</button></form>; +} + +// ✅ 在子组件中调用 +function SubmitButton() { + const { pending } = useFormStatus(); + return <button disabled={pending}>Submit</button>; +} +function Form() { + return <form><SubmitButton /></form>; +} + +// === useOptimistic 错误 === + +// ❌ 用于关键业务操作 +function PaymentButton() { + const [optimisticPaid, setPaid] = useOptimistic(false); + const handlePay = async () => { + setPaid(true); // 危险:显示已支付但可能失败 + await processPayment(); + }; +} + +// ❌ 没有处理回滚后的 UI 状态 +const [optimisticLikes, addLike] = useOptimistic(likes); +// 失败后 UI 回滚,但用户可能困惑为什么点赞消失了 + +// ✅ 提供失败反馈 +const handleLike = async () => { + addLike(1); + try { + await likePost(); + } catch { + toast.error('点赞失败,请重试'); // 通知用户 + } +}; +``` + +### React 19 Forms Checklist +- [ ] useActionState 返回新状态而不是 setState +- [ ] useActionState 正确使用 isPending 禁用提交 +- [ ] useFormStatus 在 form 子组件中调用 +- [ ] useOptimistic 不用于关键业务(支付、删除等) +- [ ] useOptimistic 失败时有用户反馈 +- [ ] Server Action 正确标记 'use server' + +### Suspense & Streaming 错误 + +```tsx +// === Suspense 边界错误 === + +// ❌ 整个页面一个 Suspense——慢内容阻塞快内容 +function BadPage() { + return ( + <Suspense fallback={<FullPageLoader />}> + <FastHeader /> {/* 快 */} + <SlowMainContent /> {/* 慢——阻塞整个页面 */} + <FastFooter /> {/* 快 */} + </Suspense> + ); +} + +// ✅ 独立边界,互不阻塞 +function GoodPage() { + return ( + <> + <FastHeader /> + <Suspense fallback={<ContentSkeleton />}> + <SlowMainContent /> + </Suspense> + <FastFooter /> + </> + ); +} + +// ❌ 没有 Error Boundary +function NoErrorHandling() { + return ( + <Suspense fallback={<Loading />}> + <DataFetcher /> {/* 抛错导致白屏 */} + </Suspense> + ); +} + +// ✅ Error Boundary + Suspense +function WithErrorHandling() { + return ( + <ErrorBoundary fallback={<ErrorFallback />}> + <Suspense fallback={<Loading />}> + <DataFetcher /> + </Suspense> + </ErrorBoundary> + ); +} + +// === use() Hook 错误 === + +// ❌ 在组件外创建 Promise(每次渲染新 Promise) +function BadUse() { + const data = use(fetchData()); // 每次渲染都创建新 Promise! + return <div>{data}</div>; +} + +// ✅ 在父组件创建,通过 props 传递 +function Parent() { + const dataPromise = useMemo(() => fetchData(), []); + return <Child dataPromise={dataPromise} />; +} +function Child({ dataPromise }) { + const data = use(dataPromise); + return <div>{data}</div>; +} + +// === Next.js Streaming 错误 === + +// ❌ 在 layout.tsx 中 await 慢数据——阻塞所有子页面 +// app/layout.tsx +export default async function Layout({ children }) { + const config = await fetchSlowConfig(); // 阻塞整个应用! + return <ConfigProvider value={config}>{children}</ConfigProvider>; +} + +// ✅ 将慢数据放在页面级别或使用 Suspense +// app/layout.tsx +export default function Layout({ children }) { + return ( + <Suspense fallback={<ConfigSkeleton />}> + <ConfigProvider>{children}</ConfigProvider> + </Suspense> + ); +} +``` + +### Suspense Checklist +- [ ] 慢内容有独立的 Suspense 边界 +- [ ] 每个 Suspense 有对应的 Error Boundary +- [ ] fallback 是有意义的骨架屏(不是简单 spinner) +- [ ] use() 的 Promise 不在渲染时创建 +- [ ] 没有在 layout 中 await 慢数据 +- [ ] 嵌套层级不超过 3 层 + +### TanStack Query 错误 + +```tsx +// === 查询配置错误 === + +// ❌ queryKey 不包含查询参数 +function BadQuery({ userId, filters }) { + const { data } = useQuery({ + queryKey: ['users'], // 缺少 userId 和 filters! + queryFn: () => fetchUsers(userId, filters), + }); + // userId 或 filters 变化时数据不会更新 +} + +// ✅ queryKey 包含所有影响数据的参数 +function GoodQuery({ userId, filters }) { + const { data } = useQuery({ + queryKey: ['users', userId, filters], + queryFn: () => fetchUsers(userId, filters), + }); +} + +// ❌ staleTime: 0 导致过度请求 +const { data } = useQuery({ + queryKey: ['data'], + queryFn: fetchData, + // 默认 staleTime: 0,每次组件挂载/窗口聚焦都会 refetch +}); + +// ✅ 设置合理的 staleTime +const { data } = useQuery({ + queryKey: ['data'], + queryFn: fetchData, + staleTime: 5 * 60 * 1000, // 5 分钟内不会自动 refetch +}); + +// === useSuspenseQuery 错误 === + +// ❌ useSuspenseQuery + enabled(不支持) +const { data } = useSuspenseQuery({ + queryKey: ['user', userId], + queryFn: () => fetchUser(userId), + enabled: !!userId, // 错误!useSuspenseQuery 不支持 enabled +}); + +// ✅ 条件渲染实现 +function UserQuery({ userId }) { + const { data } = useSuspenseQuery({ + queryKey: ['user', userId], + queryFn: () => fetchUser(userId), + }); + return <UserProfile user={data} />; +} + +function Parent({ userId }) { + if (!userId) return <SelectUser />; + return ( + <Suspense fallback={<UserSkeleton />}> + <UserQuery userId={userId} /> + </Suspense> + ); +} + +// === Mutation 错误 === + +// ❌ Mutation 成功后不 invalidate 查询 +const mutation = useMutation({ + mutationFn: updateUser, + // 忘记 invalidate,UI 显示旧数据 +}); + +// ✅ 成功后 invalidate 相关查询 +const mutation = useMutation({ + mutationFn: updateUser, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }); + }, +}); + +// ❌ 乐观更新不处理回滚 +const mutation = useMutation({ + mutationFn: updateTodo, + onMutate: async (newTodo) => { + queryClient.setQueryData(['todos'], (old) => [...old, newTodo]); + // 没有保存旧数据,失败后无法回滚! + }, +}); + +// ✅ 完整的乐观更新 +const mutation = useMutation({ + mutationFn: updateTodo, + onMutate: async (newTodo) => { + await queryClient.cancelQueries({ queryKey: ['todos'] }); + const previous = queryClient.getQueryData(['todos']); + queryClient.setQueryData(['todos'], (old) => [...old, newTodo]); + return { previous }; + }, + onError: (err, newTodo, context) => { + queryClient.setQueryData(['todos'], context.previous); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['todos'] }); + }, +}); + +// === v5 迁移错误 === + +// ❌ 使用废弃的 API +const { data, isLoading } = useQuery(['key'], fetchFn); // v4 语法 + +// ✅ v5 单一对象参数 +const { data, isPending } = useQuery({ + queryKey: ['key'], + queryFn: fetchFn, +}); + +// ❌ 混淆 isPending 和 isLoading +if (isLoading) return <Spinner />; +// v5 中 isLoading = isPending && isFetching + +// ✅ 根据意图选择 +if (isPending) return <Spinner />; // 没有缓存数据 +// 或 +if (isFetching) return <Refreshing />; // 正在后台刷新 +``` + +### TanStack Query Checklist +- [ ] queryKey 包含所有影响数据的参数 +- [ ] 设置了合理的 staleTime(不是默认 0) +- [ ] useSuspenseQuery 不使用 enabled +- [ ] Mutation 成功后 invalidate 相关查询 +- [ ] 乐观更新有完整的回滚逻辑 +- [ ] v5 使用单一对象参数语法 +- [ ] 理解 isPending vs isLoading vs isFetching + +### TypeScript/JavaScript Common Mistakes +- [ ] `==` instead of `===` +- [ ] Modifying array/object during iteration +- [ ] `this` context lost in callbacks +- [ ] Missing `key` prop in lists +- [ ] Closure capturing loop variable +- [ ] parseInt without radix parameter + +## Vue 3 + +### 响应性丢失 +```vue +<!-- ❌ 解构 reactive 丢失响应性 --> +<script setup> +const state = reactive({ count: 0 }) +const { count } = state // count 不是响应式的! +</script> + +<!-- ✅ 使用 toRefs --> +<script setup> +const state = reactive({ count: 0 }) +const { count } = toRefs(state) // count.value 是响应式的 +</script> +``` + +### Props 响应性传递 +```vue +<!-- ❌ 传递 props 值到 composable 丢失响应性 --> +<script setup> +const props = defineProps<{ id: string }>() +const { data } = useFetch(props.id) // id 变化时不会重新获取! +</script> + +<!-- ✅ 使用 toRef 或 getter --> +<script setup> +const props = defineProps<{ id: string }>() +const { data } = useFetch(() => props.id) // getter 保持响应性 +// 或 +const { data } = useFetch(toRef(props, 'id')) +</script> +``` + +### Watch 清理 +```vue +<!-- ❌ 异步 watch 无清理,导致竞态 --> +<script setup> +watch(id, async (newId) => { + const data = await fetchData(newId) + result.value = data // 旧请求可能覆盖新结果! +}) +</script> + +<!-- ✅ 使用 onCleanup 取消旧请求 --> +<script setup> +watch(id, async (newId, _, onCleanup) => { + const controller = new AbortController() + onCleanup(() => controller.abort()) + + const data = await fetchData(newId, controller.signal) + result.value = data +}) +</script> +``` + +### Computed 副作用 +```vue +<!-- ❌ computed 中修改其他状态 --> +<script setup> +const total = computed(() => { + sideEffect.value++ // 副作用!每次访问都会执行 + return items.value.reduce((a, b) => a + b, 0) +}) +</script> + +<!-- ✅ computed 只做纯计算 --> +<script setup> +const total = computed(() => { + return items.value.reduce((a, b) => a + b, 0) +}) +// 副作用放 watch +watch(total, () => { sideEffect.value++ }) +</script> +``` + +### 模板常见错误 +```vue +<!-- ❌ v-if 和 v-for 同时使用(v-if 优先级更高) --> +<template> + <div v-for="item in items" v-if="item.visible" :key="item.id"> + {{ item.name }} + </div> +</template> + +<!-- ✅ 使用 computed 或 template 包裹 --> +<template> + <template v-for="item in items" :key="item.id"> + <div v-if="item.visible">{{ item.name }}</div> + </template> +</template> +``` + +### Common Mistakes +- [ ] 解构 reactive 对象丢失响应性 +- [ ] props 传递给 composable 时未保持响应性 +- [ ] watch 异步回调无清理函数 +- [ ] computed 中产生副作用 +- [ ] v-for 使用 index 作为 key(列表会重排时) +- [ ] v-if 和 v-for 在同一元素上 +- [ ] defineProps 未使用 TypeScript 类型声明 +- [ ] withDefaults 对象默认值未使用工厂函数 +- [ ] 直接修改 props(而不是 emit) +- [ ] watchEffect 依赖不明确导致过度触发 + +## Python + +### Mutable Default Arguments +```python +# ❌ Bug: List shared across all calls +def add_item(item, items=[]): + items.append(item) + return items + +# ✅ Correct +def add_item(item, items=None): + if items is None: + items = [] + items.append(item) + return items +``` + +### Exception Handling +```python +# ❌ Catching everything, including KeyboardInterrupt +try: + risky_operation() +except: + pass + +# ✅ Catch specific exceptions +try: + risky_operation() +except ValueError as e: + logger.error(f"Invalid value: {e}") + raise +``` + +### Class Attributes +```python +# ❌ Shared mutable class attribute +class User: + permissions = [] # Shared across all instances! + +# ✅ Initialize in __init__ +class User: + def __init__(self): + self.permissions = [] +``` + +### Common Mistakes +- [ ] Using `is` instead of `==` for value comparison +- [ ] Forgetting `self` parameter in methods +- [ ] Modifying list while iterating +- [ ] String concatenation in loops (use join) +- [ ] Not closing files (use `with` statement) + +## Rust + +### 所有权与借用 + +```rust +// ❌ Use after move +let s = String::from("hello"); +let s2 = s; +println!("{}", s); // Error: s was moved + +// ✅ Clone if needed (but consider if clone is necessary) +let s = String::from("hello"); +let s2 = s.clone(); +println!("{}", s); // OK + +// ❌ 用 clone() 绕过借用检查器(反模式) +fn process(data: &Data) { + let owned = data.clone(); // 不必要的 clone + do_something(owned); +} + +// ✅ 正确使用借用 +fn process(data: &Data) { + do_something(data); // 传递引用 +} + +// ❌ 在结构体中存储借用(通常是坏主意) +struct Parser<'a> { + input: &'a str, // 生命周期复杂化 + position: usize, +} + +// ✅ 使用拥有的数据 +struct Parser { + input: String, // 拥有数据,简化生命周期 + position: usize, +} + +// ❌ 迭代时修改集合 +let mut vec = vec![1, 2, 3]; +for item in &vec { + vec.push(*item); // Error: cannot borrow as mutable +} + +// ✅ 收集到新集合 +let vec = vec![1, 2, 3]; +let new_vec: Vec<_> = vec.iter().map(|x| x * 2).collect(); +``` + +### Unsafe 代码审查 + +```rust +// ❌ unsafe 没有安全注释 +unsafe { + ptr::write(dest, value); +} + +// ✅ 必须有 SAFETY 注释说明不变量 +// SAFETY: dest 指针由 Vec::as_mut_ptr() 获得,保证: +// 1. 指针有效且已对齐 +// 2. 目标内存未被其他引用借用 +// 3. 写入不会超出分配的容量 +unsafe { + ptr::write(dest, value); +} + +// ❌ unsafe fn 没有 # Safety 文档 +pub unsafe fn from_raw_parts(ptr: *mut T, len: usize) -> Self { ... } + +// ✅ 必须文档化安全契约 +/// Creates a new instance from raw parts. +/// +/// # Safety +/// +/// - `ptr` must have been allocated via `GlobalAlloc` +/// - `len` must be less than or equal to the allocated capacity +/// - The caller must ensure no other references to the memory exist +pub unsafe fn from_raw_parts(ptr: *mut T, len: usize) -> Self { ... } + +// ❌ 跨模块 unsafe 不变量 +mod a { + pub fn set_flag() { FLAG = true; } // 安全代码影响 unsafe +} +mod b { + pub unsafe fn do_thing() { + if FLAG { /* assumes FLAG means something */ } + } +} + +// ✅ 将 unsafe 边界封装在单一模块 +mod safe_wrapper { + // 所有 unsafe 逻辑在一个模块内 + // 对外提供 safe API +} +``` + +### 异步/并发 + +```rust +// ❌ 在异步上下文中阻塞 +async fn bad_fetch(url: &str) -> Result<String> { + let resp = reqwest::blocking::get(url)?; // 阻塞整个运行时! + Ok(resp.text()?) +} + +// ✅ 使用异步版本 +async fn good_fetch(url: &str) -> Result<String> { + let resp = reqwest::get(url).await?; + Ok(resp.text().await?) +} + +// ❌ 跨 .await 持有 Mutex +async fn bad_lock(mutex: &Mutex<Data>) { + let guard = mutex.lock().unwrap(); + some_async_op().await; // 持锁跨越 await! + drop(guard); +} + +// ✅ 缩短锁持有时间 +async fn good_lock(mutex: &Mutex<Data>) { + let data = { + let guard = mutex.lock().unwrap(); + guard.clone() // 获取数据后立即释放锁 + }; + some_async_op().await; + // 处理 data +} + +// ❌ 在异步函数中使用 std::sync::Mutex +async fn bad_async_mutex(mutex: &std::sync::Mutex<Data>) { + let _guard = mutex.lock().unwrap(); // 可能死锁 + tokio::time::sleep(Duration::from_secs(1)).await; +} + +// ✅ 使用 tokio::sync::Mutex(如果必须跨 await) +async fn good_async_mutex(mutex: &tokio::sync::Mutex<Data>) { + let _guard = mutex.lock().await; + tokio::time::sleep(Duration::from_secs(1)).await; +} + +// ❌ 忘记 Future 是惰性的 +fn bad_spawn() { + let future = async_operation(); // 没有执行! + // future 被丢弃,什么都没发生 +} + +// ✅ 必须 await 或 spawn +async fn good_spawn() { + async_operation().await; // 执行 + // 或 + tokio::spawn(async_operation()); // 后台执行 +} + +// ❌ spawn 任务缺少 'static +async fn bad_spawn_lifetime(data: &str) { + tokio::spawn(async { + println!("{}", data); // Error: data 不是 'static + }); +} + +// ✅ 使用 move 或 Arc +async fn good_spawn_lifetime(data: String) { + tokio::spawn(async move { + println!("{}", data); // OK: 拥有数据 + }); +} +``` + +### 错误处理 + +```rust +// ❌ 生产代码中使用 unwrap/expect +fn bad_parse(input: &str) -> i32 { + input.parse().unwrap() // panic! +} + +// ✅ 正确传播错误 +fn good_parse(input: &str) -> Result<i32, ParseIntError> { + input.parse() +} + +// ❌ 吞掉错误信息 +fn bad_error_handling() -> Result<()> { + match operation() { + Ok(v) => Ok(v), + Err(_) => Err(anyhow!("operation failed")) // 丢失原始错误 + } +} + +// ✅ 使用 context 添加上下文 +fn good_error_handling() -> Result<()> { + operation().context("failed to perform operation")?; + Ok(()) +} + +// ❌ 库代码使用 anyhow(应该用 thiserror) +// lib.rs +pub fn parse_config(path: &str) -> anyhow::Result<Config> { + // 调用者无法区分错误类型 +} + +// ✅ 库代码用 thiserror 定义错误类型 +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("failed to read config file: {0}")] + Io(#[from] std::io::Error), + #[error("invalid config format: {0}")] + Parse(#[from] serde_json::Error), +} + +pub fn parse_config(path: &str) -> Result<Config, ConfigError> { + // 调用者可以 match 不同错误 +} + +// ❌ 忽略 must_use 返回值 +fn bad_ignore_result() { + some_fallible_operation(); // 警告:unused Result +} + +// ✅ 显式处理或标记忽略 +fn good_handle_result() { + let _ = some_fallible_operation(); // 显式忽略 + // 或 + some_fallible_operation().ok(); // 转换为 Option +} +``` + +### 性能陷阱 + +```rust +// ❌ 不必要的 collect +fn bad_process(items: &[i32]) -> i32 { + items.iter() + .filter(|x| **x > 0) + .collect::<Vec<_>>() // 不必要的分配 + .iter() + .sum() +} + +// ✅ 惰性迭代 +fn good_process(items: &[i32]) -> i32 { + items.iter() + .filter(|x| **x > 0) + .sum() +} + +// ❌ 循环中重复分配 +fn bad_loop() -> String { + let mut result = String::new(); + for i in 0..1000 { + result = result + &i.to_string(); // 每次迭代都重新分配! + } + result +} + +// ✅ 预分配或使用 push_str +fn good_loop() -> String { + let mut result = String::with_capacity(4000); // 预分配 + for i in 0..1000 { + write!(result, "{}", i).unwrap(); // 原地追加 + } + result +} + +// ❌ 过度使用 clone +fn bad_clone(data: &HashMap<String, Vec<u8>>) -> Vec<u8> { + data.get("key").cloned().unwrap_or_default() +} + +// ✅ 返回引用或使用 Cow +fn good_ref(data: &HashMap<String, Vec<u8>>) -> &[u8] { + data.get("key").map(|v| v.as_slice()).unwrap_or(&[]) +} + +// ❌ 大结构体按值传递 +fn bad_pass(data: LargeStruct) { ... } // 拷贝整个结构体 + +// ✅ 传递引用 +fn good_pass(data: &LargeStruct) { ... } + +// ❌ Box<dyn Trait> 用于小型已知类型 +fn bad_trait_object() -> Box<dyn Iterator<Item = i32>> { + Box::new(vec![1, 2, 3].into_iter()) +} + +// ✅ 使用 impl Trait +fn good_impl_trait() -> impl Iterator<Item = i32> { + vec![1, 2, 3].into_iter() +} + +// ❌ retain 比 filter+collect 慢(某些场景) +vec.retain(|x| x.is_valid()); // O(n) 但常数因子大 + +// ✅ 如果不需要原地修改,考虑 filter +let vec: Vec<_> = vec.into_iter().filter(|x| x.is_valid()).collect(); +``` + +### 生命周期与引用 + +```rust +// ❌ 返回局部变量的引用 +fn bad_return_ref() -> &str { + let s = String::from("hello"); + &s // Error: s will be dropped +} + +// ✅ 返回拥有的数据或静态引用 +fn good_return_owned() -> String { + String::from("hello") +} + +// ❌ 生命周期过度泛化 +fn bad_lifetime<'a, 'b>(x: &'a str, y: &'b str) -> &'a str { + x // 'b 没有被使用 +} + +// ✅ 简化生命周期 +fn good_lifetime(x: &str, _y: &str) -> &str { + x // 编译器自动推断 +} + +// ❌ 结构体持有多个相关引用但生命周期独立 +struct Bad<'a, 'b> { + name: &'a str, + data: &'b [u8], // 通常应该是同一个生命周期 +} + +// ✅ 相关数据使用相同生命周期 +struct Good<'a> { + name: &'a str, + data: &'a [u8], +} +``` + +### Rust 审查清单 + +**所有权与借用** +- [ ] clone() 是有意为之,不是绕过借用检查器 +- [ ] 避免在结构体中存储借用(除非必要) +- [ ] Rc/Arc 使用合理,没有隐藏不必要的共享状态 +- [ ] 没有不必要的 RefCell(运行时检查 vs 编译时) + +**Unsafe 代码** +- [ ] 每个 unsafe 块有 SAFETY 注释 +- [ ] unsafe fn 有 # Safety 文档 +- [ ] 安全不变量被清晰记录 +- [ ] unsafe 边界尽可能小 + +**异步/并发** +- [ ] 没有在异步上下文中阻塞 +- [ ] 没有跨 .await 持有 std::sync 锁 +- [ ] spawn 的任务满足 'static 约束 +- [ ] Future 被正确 await 或 spawn +- [ ] 锁的顺序一致(避免死锁) + +**错误处理** +- [ ] 库代码使用 thiserror,应用代码使用 anyhow +- [ ] 错误有足够的上下文信息 +- [ ] 没有在生产代码中 unwrap/expect +- [ ] must_use 返回值被正确处理 + +**性能** +- [ ] 避免不必要的 collect() +- [ ] 大数据结构传引用 +- [ ] 字符串拼接使用 String::with_capacity 或 write! +- [ ] impl Trait 优于 Box<dyn Trait>(当可能时) + +**类型系统** +- [ ] 善用 newtype 模式增加类型安全 +- [ ] 枚举穷尽匹配(没有 _ 通配符隐藏新变体) +- [ ] 生命周期尽可能简化 + +## SQL + +### Injection Vulnerabilities +```sql +-- ❌ String concatenation (SQL injection risk) +query = "SELECT * FROM users WHERE id = " + user_id + +-- ✅ Parameterized queries +query = "SELECT * FROM users WHERE id = ?" +cursor.execute(query, (user_id,)) +``` + +### Performance Issues +- [ ] Missing indexes on filtered/joined columns +- [ ] SELECT * instead of specific columns +- [ ] N+1 query patterns +- [ ] Missing LIMIT on large tables +- [ ] Inefficient subqueries vs JOINs + +### Common Mistakes +- [ ] Not handling NULL comparisons correctly +- [ ] Missing transactions for related operations +- [ ] Incorrect JOIN types +- [ ] Case sensitivity issues +- [ ] Date/timezone handling errors + +## API Design + +### REST Issues +- [ ] Inconsistent resource naming +- [ ] Wrong HTTP methods (POST for idempotent operations) +- [ ] Missing pagination for list endpoints +- [ ] Incorrect status codes +- [ ] Missing rate limiting + +### Data Validation +- [ ] Missing input validation +- [ ] Incorrect data type validation +- [ ] Missing length/range checks +- [ ] Not sanitizing user input +- [ ] Trusting client-side validation + +## Testing + +### Test Quality Issues +- [ ] Testing implementation details instead of behavior +- [ ] Missing edge case tests +- [ ] Flaky tests (non-deterministic) +- [ ] Tests with external dependencies +- [ ] Missing negative tests (error cases) +- [ ] Overly complex test setup diff --git a/.trellis/spec/review/reference/cpp.md b/.trellis/spec/review/reference/cpp.md new file mode 100644 index 00000000..58743f68 --- /dev/null +++ b/.trellis/spec/review/reference/cpp.md @@ -0,0 +1,385 @@ +# C++ Code Review Guide + +> C++ code review guide focused on memory safety, lifetime, API design, and performance. Examples assume C++17/20. + +## Table of Contents + +- [Ownership and RAII](#ownership-and-raii) +- [Lifetime and References](#lifetime-and-references) +- [Copy and Move Semantics](#copy-and-move-semantics) +- [Const-Correctness and API Design](#const-correctness-and-api-design) +- [Error Handling and Exception Safety](#error-handling-and-exception-safety) +- [Concurrency](#concurrency) +- [Performance and Allocation](#performance-and-allocation) +- [Templates and Type Safety](#templates-and-type-safety) +- [Tooling and Build Checks](#tooling-and-build-checks) +- [Review Checklist](#review-checklist) + +--- + +## Ownership and RAII + +### Prefer RAII and smart pointers + +Use RAII to express ownership. Default to `std::unique_ptr`, use `std::shared_ptr` only for shared lifetime. + +```cpp +// ? Bad: manual new/delete with early returns +Foo* make_foo() { + Foo* foo = new Foo(); + if (!foo->Init()) { + delete foo; + return nullptr; + } + return foo; +} + +// ? Good: RAII with unique_ptr +std::unique_ptr<Foo> make_foo() { + auto foo = std::make_unique<Foo>(); + if (!foo->Init()) { + return {}; + } + return foo; +} +``` + +### Wrap C resources + +```cpp +// ? Good: wrap FILE* with unique_ptr +using FilePtr = std::unique_ptr<FILE, decltype(&fclose)>; + +FilePtr open_file(const char* path) { + return FilePtr(fopen(path, "rb"), &fclose); +} +``` + +--- + +## Lifetime and References + +### Avoid dangling references and views + +`std::string_view` and `std::span` do not own data. Make sure the owner outlives the view. + +```cpp +// ? Bad: returning string_view to a temporary +std::string_view bad_view() { + std::string s = make_name(); + return s; // dangling +} + +// ? Good: return owning string +std::string good_name() { + return make_name(); +} + +// ? Good: view tied to caller-owned data +std::string_view good_view(const std::string& s) { + return s; +} +``` + +### Lambda captures + +```cpp +// ? Bad: capture reference that escapes +std::function<void()> make_task() { + int value = 42; + return [&]() { use(value); }; // dangling +} + +// ? Good: capture by value +std::function<void()> make_task() { + int value = 42; + return [value]() { use(value); }; +} +``` + +--- + +## Copy and Move Semantics + +### Rule of 0/3/5 + +Prefer the Rule of 0 by using RAII types. If you own a resource, define or delete copy and move operations. + +```cpp +// ? Bad: raw ownership with default copy +struct Buffer { + int* data; + size_t size; + explicit Buffer(size_t n) : data(new int[n]), size(n) {} + ~Buffer() { delete[] data; } + // copy ctor/assign are implicitly generated -> double delete +}; + +// ? Good: Rule of 0 with std::vector +struct Buffer { + std::vector<int> data; + explicit Buffer(size_t n) : data(n) {} +}; +``` + +### Delete unwanted copies + +```cpp +struct Socket { + Socket() = default; + ~Socket() { close(); } + + Socket(const Socket&) = delete; + Socket& operator=(const Socket&) = delete; + Socket(Socket&&) noexcept = default; + Socket& operator=(Socket&&) noexcept = default; +}; +``` + +--- + +## Const-Correctness and API Design + +### Use const and explicit + +```cpp +class User { +public: + const std::string& name() const { return name_; } + void set_name(std::string name) { name_ = std::move(name); } + +private: + std::string name_; +}; + +struct Millis { + explicit Millis(int v) : value(v) {} + int value; +}; +``` + +### Avoid object slicing + +```cpp +struct Shape { virtual ~Shape() = default; }; +struct Circle : Shape { void draw() const; }; + +// ? Bad: slices Circle into Shape +void draw(Shape shape); + +// ? Good: pass by reference +void draw(const Shape& shape); +``` + +### Use override and final + +```cpp +struct Base { + virtual void run() = 0; +}; + +struct Worker final : Base { + void run() override {} +}; +``` + +--- + +## Error Handling and Exception Safety + +### Prefer RAII for cleanup + +```cpp +// ? Good: RAII handles cleanup on exceptions +void process() { + std::vector<int> data = load_data(); // safe cleanup + do_work(data); +} +``` + +### Do not throw from destructors + +```cpp +struct File { + ~File() noexcept { close(); } + void close(); +}; +``` + +### Use expected results for normal failures + +```cpp +// ? Expected error: use optional or expected +std::optional<int> parse_int(const std::string& s) { + try { + return std::stoi(s); + } catch (...) { + return std::nullopt; + } +} +``` + +--- + +## Concurrency + +### Protect shared data + +```cpp +// ? Bad: data race +int counter = 0; +void inc() { counter++; } + +// ? Good: atomic +std::atomic<int> counter{0}; +void inc() { counter.fetch_add(1, std::memory_order_relaxed); } +``` + +### Use RAII locks + +```cpp +std::mutex mu; +std::vector<int> data; + +void add(int v) { + std::lock_guard<std::mutex> lock(mu); + data.push_back(v); +} +``` + +--- + +## Performance and Allocation + +### Avoid repeated allocations + +```cpp +// ? Bad: repeated reallocation +std::vector<int> build(int n) { + std::vector<int> out; + for (int i = 0; i < n; ++i) { + out.push_back(i); + } + return out; +} + +// ? Good: reserve upfront +std::vector<int> build(int n) { + std::vector<int> out; + out.reserve(static_cast<size_t>(n)); + for (int i = 0; i < n; ++i) { + out.push_back(i); + } + return out; +} +``` + +### String concatenation + +```cpp +// ? Bad: repeated allocation +std::string join(const std::vector<std::string>& parts) { + std::string out; + for (const auto& p : parts) { + out += p; + } + return out; +} + +// ? Good: reserve total size +std::string join(const std::vector<std::string>& parts) { + size_t total = 0; + for (const auto& p : parts) { + total += p.size(); + } + std::string out; + out.reserve(total); + for (const auto& p : parts) { + out += p; + } + return out; +} +``` + +--- + +## Templates and Type Safety + +### Prefer constrained templates (C++20) + +```cpp +// ? Bad: overly generic +template <typename T> +T add(T a, T b) { + return a + b; +} + +// ? Good: constrained +template <typename T> +requires std::is_integral_v<T> +T add(T a, T b) { + return a + b; +} +``` + +### Use static_assert for invariants + +```cpp +template <typename T> +struct Packet { + static_assert(std::is_trivially_copyable_v<T>, + "Packet payload must be trivially copyable"); + T payload; +}; +``` + +--- + +## Tooling and Build Checks + +```bash +# Warnings +clang++ -Wall -Wextra -Werror -Wconversion -Wshadow -std=c++20 ... + +# Sanitizers (debug builds) +clang++ -fsanitize=address,undefined -fno-omit-frame-pointer -g ... +clang++ -fsanitize=thread -fno-omit-frame-pointer -g ... + +# Static analysis +clang-tidy src/*.cpp -- -std=c++20 + +# Formatting +clang-format -i src/*.cpp include/*.h +``` + +--- + +## Review Checklist + +### Safety and Lifetime +- [ ] Ownership is explicit (RAII, unique_ptr by default) +- [ ] No dangling references or views +- [ ] Rule of 0/3/5 followed for resource-owning types +- [ ] No raw new/delete in business logic +- [ ] Destructors are noexcept and do not throw + +### API and Design +- [ ] const-correctness is applied consistently +- [ ] Constructors are explicit where needed +- [ ] Override/final used for virtual functions +- [ ] No object slicing (pass by ref or pointer) + +### Concurrency +- [ ] Shared data is protected (mutex or atomics) +- [ ] Locking order is consistent +- [ ] No blocking while holding locks + +### Performance +- [ ] Unnecessary allocations avoided (reserve, move) +- [ ] Copies avoided in hot paths +- [ ] Algorithmic complexity is reasonable + +### Tooling and Tests +- [ ] Builds clean with warnings enabled +- [ ] Sanitizers run on critical code paths +- [ ] Static analysis (clang-tidy) results are addressed diff --git a/.trellis/spec/review/reference/performance-review-guide.md b/.trellis/spec/review/reference/performance-review-guide.md new file mode 100644 index 00000000..87a8ba73 --- /dev/null +++ b/.trellis/spec/review/reference/performance-review-guide.md @@ -0,0 +1,752 @@ +# Performance Review Guide + +性能审查指南,覆盖前端、后端、数据库、算法复杂度和 API 性能。 + +## 目录 + +- [前端性能 (Core Web Vitals)](#前端性能-core-web-vitals) +- [JavaScript 性能](#javascript-性能) +- [内存管理](#内存管理) +- [数据库性能](#数据库性能) +- [API 性能](#api-性能) +- [算法复杂度](#算法复杂度) +- [性能审查清单](#性能审查清单) + +--- + +## 前端性能 (Core Web Vitals) + +### 2024 核心指标 + +| 指标 | 全称 | 目标值 | 含义 | +|------|------|--------|------| +| **LCP** | Largest Contentful Paint | ≤ 2.5s | 最大内容绘制时间 | +| **INP** | Interaction to Next Paint | ≤ 200ms | 交互响应时间(2024 年替代 FID)| +| **CLS** | Cumulative Layout Shift | ≤ 0.1 | 累积布局偏移 | +| **FCP** | First Contentful Paint | ≤ 1.8s | 首次内容绘制 | +| **TBT** | Total Blocking Time | ≤ 200ms | 主线程阻塞时间 | + +### LCP 优化检查 + +```javascript +// ❌ LCP 图片懒加载 - 延迟关键内容 +<img src="hero.jpg" loading="lazy" /> + +// ✅ LCP 图片立即加载 +<img src="hero.jpg" fetchpriority="high" /> + +// ❌ 未优化的图片格式 +<img src="hero.png" /> // PNG 文件过大 + +// ✅ 现代图片格式 + 响应式 +<picture> + <source srcset="hero.avif" type="image/avif" /> + <source srcset="hero.webp" type="image/webp" /> + <img src="hero.jpg" alt="Hero" /> +</picture> +``` + +**审查要点:** +- [ ] LCP 元素是否设置 `fetchpriority="high"`? +- [ ] 是否使用 WebP/AVIF 格式? +- [ ] 是否有服务端渲染或静态生成? +- [ ] CDN 是否配置正确? + +### FCP 优化检查 + +```html +<!-- ❌ 阻塞渲染的 CSS --> +<link rel="stylesheet" href="all-styles.css" /> + +<!-- ✅ 关键 CSS 内联 + 异步加载其余 --> +<style>/* 首屏关键样式 */</style> +<link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'" /> + +<!-- ❌ 阻塞渲染的字体 --> +@font-face { + font-family: 'CustomFont'; + src: url('font.woff2'); +} + +<!-- ✅ 字体显示优化 --> +@font-face { + font-family: 'CustomFont'; + src: url('font.woff2'); + font-display: swap; /* 先用系统字体,加载后切换 */ +} +``` + +### INP 优化检查 + +```javascript +// ❌ 长任务阻塞主线程 +button.addEventListener('click', () => { + // 耗时 500ms 的同步操作 + processLargeData(data); + updateUI(); +}); + +// ✅ 拆分长任务 +button.addEventListener('click', async () => { + // 让出主线程 + await scheduler.yield?.() ?? new Promise(r => setTimeout(r, 0)); + + // 分批处理 + for (const chunk of chunks) { + processChunk(chunk); + await scheduler.yield?.(); + } + updateUI(); +}); + +// ✅ 使用 Web Worker 处理复杂计算 +const worker = new Worker('heavy-computation.js'); +worker.postMessage(data); +worker.onmessage = (e) => updateUI(e.data); +``` + +### CLS 优化检查 + +```css +/* ❌ 未指定尺寸的媒体 */ +img { width: 100%; } + +/* ✅ 预留空间 */ +img { + width: 100%; + aspect-ratio: 16 / 9; +} + +/* ❌ 动态插入内容导致布局偏移 */ +.ad-container { } + +/* ✅ 预留固定高度 */ +.ad-container { + min-height: 250px; +} +``` + +**CLS 审查清单:** +- [ ] 图片/视频是否有 width/height 或 aspect-ratio? +- [ ] 字体加载是否使用 `font-display: swap`? +- [ ] 动态内容是否预留空间? +- [ ] 是否避免在现有内容上方插入内容? + +--- + +## JavaScript 性能 + +### 代码分割与懒加载 + +```javascript +// ❌ 一次性加载所有代码 +import { HeavyChart } from './charts'; +import { PDFExporter } from './pdf'; +import { AdminPanel } from './admin'; + +// ✅ 按需加载 +const HeavyChart = lazy(() => import('./charts')); +const PDFExporter = lazy(() => import('./pdf')); + +// ✅ 路由级代码分割 +const routes = [ + { + path: '/dashboard', + component: lazy(() => import('./pages/Dashboard')), + }, + { + path: '/admin', + component: lazy(() => import('./pages/Admin')), + }, +]; +``` + +### Bundle 体积优化 + +```javascript +// ❌ 导入整个库 +import _ from 'lodash'; +import moment from 'moment'; + +// ✅ 按需导入 +import debounce from 'lodash/debounce'; +import { format } from 'date-fns'; + +// ❌ 未使用 Tree Shaking +export default { + fn1() {}, + fn2() {}, // 未使用但被打包 +}; + +// ✅ 命名导出支持 Tree Shaking +export function fn1() {} +export function fn2() {} +``` + +**Bundle 审查清单:** +- [ ] 是否使用动态 import() 进行代码分割? +- [ ] 大型库是否按需导入? +- [ ] 是否分析过 bundle 大小?(webpack-bundle-analyzer) +- [ ] 是否有未使用的依赖? + +### 列表渲染优化 + +```javascript +// ❌ 渲染大列表 +function List({ items }) { + return ( + <ul> + {items.map(item => <li key={item.id}>{item.name}</li>)} + </ul> + ); // 10000 条数据 = 10000 个 DOM 节点 +} + +// ✅ 虚拟列表 - 只渲染可见项 +import { FixedSizeList } from 'react-window'; + +function VirtualList({ items }) { + return ( + <FixedSizeList + height={400} + itemCount={items.length} + itemSize={35} + > + {({ index, style }) => ( + <div style={style}>{items[index].name}</div> + )} + </FixedSizeList> + ); +} +``` + +**大数据审查要点:** +- [ ] 列表超过 100 项是否使用虚拟滚动? +- [ ] 表格是否支持分页或虚拟化? +- [ ] 是否有不必要的全量渲染? + +--- + +## 内存管理 + +### 常见内存泄漏 + +#### 1. 未清理的事件监听 + +```javascript +// ❌ 组件卸载后事件仍在监听 +useEffect(() => { + window.addEventListener('resize', handleResize); +}, []); + +// ✅ 清理事件监听 +useEffect(() => { + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); +}, []); +``` + +#### 2. 未清理的定时器 + +```javascript +// ❌ 定时器未清理 +useEffect(() => { + setInterval(fetchData, 5000); +}, []); + +// ✅ 清理定时器 +useEffect(() => { + const timer = setInterval(fetchData, 5000); + return () => clearInterval(timer); +}, []); +``` + +#### 3. 闭包引用 + +```javascript +// ❌ 闭包持有大对象引用 +function createHandler() { + const largeData = new Array(1000000).fill('x'); + + return function handler() { + // largeData 被闭包引用,无法被回收 + console.log(largeData.length); + }; +} + +// ✅ 只保留必要数据 +function createHandler() { + const largeData = new Array(1000000).fill('x'); + const length = largeData.length; // 只保留需要的值 + + return function handler() { + console.log(length); + }; +} +``` + +#### 4. 未清理的订阅 + +```javascript +// ❌ WebSocket/EventSource 未关闭 +useEffect(() => { + const ws = new WebSocket('wss://...'); + ws.onmessage = handleMessage; +}, []); + +// ✅ 清理连接 +useEffect(() => { + const ws = new WebSocket('wss://...'); + ws.onmessage = handleMessage; + return () => ws.close(); +}, []); +``` + +### 内存审查清单 + +```markdown +- [ ] useEffect 是否都有清理函数? +- [ ] 事件监听是否在组件卸载时移除? +- [ ] 定时器是否被清理? +- [ ] WebSocket/SSE 连接是否关闭? +- [ ] 大对象是否及时释放? +- [ ] 是否有全局变量累积数据? +``` + +### 检测工具 + +| 工具 | 用途 | +|------|------| +| Chrome DevTools Memory | 堆快照分析 | +| MemLab (Meta) | 自动化内存泄漏检测 | +| Performance Monitor | 实时内存监控 | + +--- + +## 数据库性能 + +### N+1 查询问题 + +```python +# ❌ N+1 问题 - 1 + N 次查询 +users = User.objects.all() # 1 次查询 +for user in users: + print(user.profile.bio) # N 次查询(每个用户一次) + +# ✅ Eager Loading - 2 次查询 +users = User.objects.select_related('profile').all() +for user in users: + print(user.profile.bio) # 无额外查询 + +# ✅ 多对多关系用 prefetch_related +posts = Post.objects.prefetch_related('tags').all() +``` + +```javascript +// TypeORM 示例 +// ❌ N+1 问题 +const users = await userRepository.find(); +for (const user of users) { + const posts = await user.posts; // 每次循环都查询 +} + +// ✅ Eager Loading +const users = await userRepository.find({ + relations: ['posts'], +}); +``` + +### 索引优化 + +```sql +-- ❌ 全表扫描 +SELECT * FROM orders WHERE status = 'pending'; + +-- ✅ 添加索引 +CREATE INDEX idx_orders_status ON orders(status); + +-- ❌ 索引失效:函数操作 +SELECT * FROM users WHERE YEAR(created_at) = 2024; + +-- ✅ 范围查询可用索引 +SELECT * FROM users +WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'; + +-- ❌ 索引失效:LIKE 前缀通配符 +SELECT * FROM products WHERE name LIKE '%phone%'; + +-- ✅ 前缀匹配可用索引 +SELECT * FROM products WHERE name LIKE 'phone%'; +``` + +### 查询优化 + +```sql +-- ❌ SELECT * 获取不需要的列 +SELECT * FROM users WHERE id = 1; + +-- ✅ 只查询需要的列 +SELECT id, name, email FROM users WHERE id = 1; + +-- ❌ 大表无 LIMIT +SELECT * FROM logs WHERE type = 'error'; + +-- ✅ 分页查询 +SELECT * FROM logs WHERE type = 'error' LIMIT 100 OFFSET 0; + +-- ❌ 在循环中执行查询 +for id in user_ids: + cursor.execute("SELECT * FROM users WHERE id = %s", (id,)) + +-- ✅ 批量查询 +cursor.execute("SELECT * FROM users WHERE id IN %s", (tuple(user_ids),)) +``` + +### 数据库审查清单 + +```markdown +🔴 必须检查: +- [ ] 是否存在 N+1 查询? +- [ ] WHERE 子句列是否有索引? +- [ ] 是否避免了 SELECT *? +- [ ] 大表查询是否有 LIMIT? + +🟡 建议检查: +- [ ] 是否使用了 EXPLAIN 分析查询计划? +- [ ] 复合索引列顺序是否正确? +- [ ] 是否有未使用的索引? +- [ ] 是否有慢查询日志监控? +``` + +--- + +## API 性能 + +### 分页实现 + +```javascript +// ❌ 返回全部数据 +app.get('/users', async (req, res) => { + const users = await User.findAll(); // 可能返回 100000 条 + res.json(users); +}); + +// ✅ 分页 + 限制最大数量 +app.get('/users', async (req, res) => { + const page = parseInt(req.query.page) || 1; + const limit = Math.min(parseInt(req.query.limit) || 20, 100); // 最大 100 + const offset = (page - 1) * limit; + + const { rows, count } = await User.findAndCountAll({ + limit, + offset, + order: [['id', 'ASC']], + }); + + res.json({ + data: rows, + pagination: { + page, + limit, + total: count, + totalPages: Math.ceil(count / limit), + }, + }); +}); +``` + +### 缓存策略 + +```javascript +// ✅ Redis 缓存示例 +async function getUser(id) { + const cacheKey = `user:${id}`; + + // 1. 检查缓存 + const cached = await redis.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + + // 2. 查询数据库 + const user = await db.users.findById(id); + + // 3. 写入缓存(设置过期时间) + await redis.setex(cacheKey, 3600, JSON.stringify(user)); + + return user; +} + +// ✅ HTTP 缓存头 +app.get('/static-data', (req, res) => { + res.set({ + 'Cache-Control': 'public, max-age=86400', // 24 小时 + 'ETag': 'abc123', + }); + res.json(data); +}); +``` + +### 响应压缩 + +```javascript +// ✅ 启用 Gzip/Brotli 压缩 +const compression = require('compression'); +app.use(compression()); + +// ✅ 只返回必要字段 +// 请求: GET /users?fields=id,name,email +app.get('/users', async (req, res) => { + const fields = req.query.fields?.split(',') || ['id', 'name']; + const users = await User.findAll({ + attributes: fields, + }); + res.json(users); +}); +``` + +### 限流保护 + +```javascript +// ✅ 速率限制 +const rateLimit = require('express-rate-limit'); + +const limiter = rateLimit({ + windowMs: 60 * 1000, // 1 分钟 + max: 100, // 最多 100 次请求 + message: { error: 'Too many requests, please try again later.' }, +}); + +app.use('/api/', limiter); +``` + +### API 审查清单 + +```markdown +- [ ] 列表接口是否有分页? +- [ ] 是否限制了每页最大数量? +- [ ] 热点数据是否有缓存? +- [ ] 是否启用了响应压缩? +- [ ] 是否有速率限制? +- [ ] 是否只返回必要字段? +``` + +--- + +## 算法复杂度 + +### 常见复杂度对比 + +| 复杂度 | 名称 | 10 条 | 1000 条 | 100 万条 | 示例 | +|--------|------|-------|---------|----------|------| +| O(1) | 常数 | 1 | 1 | 1 | 哈希查找 | +| O(log n) | 对数 | 3 | 10 | 20 | 二分查找 | +| O(n) | 线性 | 10 | 1000 | 100 万 | 遍历数组 | +| O(n log n) | 线性对数 | 33 | 10000 | 2000 万 | 快速排序 | +| O(n²) | 平方 | 100 | 100 万 | 1 万亿 | 嵌套循环 | +| O(2ⁿ) | 指数 | 1024 | ∞ | ∞ | 递归斐波那契 | + +### 代码审查中的识别 + +```javascript +// ❌ O(n²) - 嵌套循环 +function findDuplicates(arr) { + const duplicates = []; + for (let i = 0; i < arr.length; i++) { + for (let j = i + 1; j < arr.length; j++) { + if (arr[i] === arr[j]) { + duplicates.push(arr[i]); + } + } + } + return duplicates; +} + +// ✅ O(n) - 使用 Set +function findDuplicates(arr) { + const seen = new Set(); + const duplicates = new Set(); + for (const item of arr) { + if (seen.has(item)) { + duplicates.add(item); + } + seen.add(item); + } + return [...duplicates]; +} +``` + +```javascript +// ❌ O(n²) - 每次循环都调用 includes +function removeDuplicates(arr) { + const result = []; + for (const item of arr) { + if (!result.includes(item)) { // includes 是 O(n) + result.push(item); + } + } + return result; +} + +// ✅ O(n) - 使用 Set +function removeDuplicates(arr) { + return [...new Set(arr)]; +} +``` + +```javascript +// ❌ O(n) 查找 - 每次都遍历 +const users = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }, ...]; + +function getUser(id) { + return users.find(u => u.id === id); // O(n) +} + +// ✅ O(1) 查找 - 使用 Map +const userMap = new Map(users.map(u => [u.id, u])); + +function getUser(id) { + return userMap.get(id); // O(1) +} +``` + +### 空间复杂度考虑 + +```javascript +// ⚠️ O(n) 空间 - 创建新数组 +const doubled = arr.map(x => x * 2); + +// ✅ O(1) 空间 - 原地修改(如果允许) +for (let i = 0; i < arr.length; i++) { + arr[i] *= 2; +} + +// ⚠️ 递归深度过大可能栈溢出 +function factorial(n) { + if (n <= 1) return 1; + return n * factorial(n - 1); // O(n) 栈空间 +} + +// ✅ 迭代版本 O(1) 空间 +function factorial(n) { + let result = 1; + for (let i = 2; i <= n; i++) { + result *= i; + } + return result; +} +``` + +### 复杂度审查问题 + +```markdown +💡 "这个嵌套循环的复杂度是 O(n²),数据量大时会有性能问题" +🔴 "这里用 Array.includes() 在循环中,整体是 O(n²),建议用 Set" +🟡 "这个递归深度可能导致栈溢出,建议改为迭代或尾递归" +``` + +--- + +## 性能审查清单 + +### 🔴 必须检查(阻塞级) + +**前端:** +- [ ] LCP 图片是否懒加载?(不应该) +- [ ] 是否有 `transition: all`? +- [ ] 是否动画 width/height/top/left? +- [ ] 列表 >100 项是否虚拟化? + +**后端:** +- [ ] 是否存在 N+1 查询? +- [ ] 列表接口是否有分页? +- [ ] 是否有 SELECT * 查大表? + +**通用:** +- [ ] 是否有 O(n²) 或更差的嵌套循环? +- [ ] useEffect/事件监听是否有清理? + +### 🟡 建议检查(重要级) + +**前端:** +- [ ] 是否使用代码分割? +- [ ] 大型库是否按需导入? +- [ ] 图片是否使用 WebP/AVIF? +- [ ] 是否有未使用的依赖? + +**后端:** +- [ ] 热点数据是否有缓存? +- [ ] WHERE 列是否有索引? +- [ ] 是否有慢查询监控? + +**API:** +- [ ] 是否启用响应压缩? +- [ ] 是否有速率限制? +- [ ] 是否只返回必要字段? + +### 🟢 优化建议(建议级) + +- [ ] 是否分析过 bundle 大小? +- [ ] 是否使用 CDN? +- [ ] 是否有性能监控? +- [ ] 是否做过性能基准测试? + +--- + +## 性能度量阈值 + +### 前端指标 + +| 指标 | 好 | 需改进 | 差 | +|------|-----|--------|-----| +| LCP | ≤ 2.5s | 2.5-4s | > 4s | +| INP | ≤ 200ms | 200-500ms | > 500ms | +| CLS | ≤ 0.1 | 0.1-0.25 | > 0.25 | +| FCP | ≤ 1.8s | 1.8-3s | > 3s | +| Bundle Size (JS) | < 200KB | 200-500KB | > 500KB | + +### 后端指标 + +| 指标 | 好 | 需改进 | 差 | +|------|-----|--------|-----| +| API 响应时间 | < 100ms | 100-500ms | > 500ms | +| 数据库查询 | < 50ms | 50-200ms | > 200ms | +| 页面加载 | < 3s | 3-5s | > 5s | + +--- + +## 工具推荐 + +### 前端性能 + +| 工具 | 用途 | +|------|------| +| [Lighthouse](https://developer.chrome.com/docs/lighthouse/) | Core Web Vitals 测试 | +| [WebPageTest](https://www.webpagetest.org/) | 详细性能分析 | +| [webpack-bundle-analyzer](https://github.com/webpack-contrib/webpack-bundle-analyzer) | Bundle 分析 | +| [Chrome DevTools Performance](https://developer.chrome.com/docs/devtools/performance/) | 运行时性能分析 | + +### 内存检测 + +| 工具 | 用途 | +|------|------| +| [MemLab](https://github.com/facebookincubator/memlab) | 自动化内存泄漏检测 | +| Chrome Memory Tab | 堆快照分析 | + +### 后端性能 + +| 工具 | 用途 | +|------|------| +| EXPLAIN | 数据库查询计划分析 | +| [pganalyze](https://pganalyze.com/) | PostgreSQL 性能监控 | +| [New Relic](https://newrelic.com/) / [Datadog](https://www.datadoghq.com/) | APM 监控 | + +--- + +## 参考资源 + +- [Core Web Vitals - web.dev](https://web.dev/articles/vitals) +- [Optimizing Core Web Vitals - Vercel](https://vercel.com/guides/optimizing-core-web-vitals-in-2024) +- [MemLab - Meta Engineering](https://engineering.fb.com/2022/09/12/open-source/memlab/) +- [Big O Cheat Sheet](https://www.bigocheatsheet.com/) +- [N+1 Query Problem - Stack Overflow](https://stackoverflow.com/questions/97197/what-is-the-n1-selects-problem-in-orm-object-relational-mapping) +- [API Performance Optimization](https://algorithmsin60days.com/blog/optimizing-api-performance/) diff --git a/.trellis/spec/review/reference/qt.md b/.trellis/spec/review/reference/qt.md new file mode 100644 index 00000000..24fef2b5 --- /dev/null +++ b/.trellis/spec/review/reference/qt.md @@ -0,0 +1,186 @@ +# Qt Code Review Guide + +> Code review guidelines focusing on object model, signals/slots, event loop, and GUI performance. Examples based on Qt 5.15 / Qt 6. + +## Table of Contents + +- [Object Model & Memory Management](#object-model--memory-management) +- [Signals & Slots](#signals--slots) +- [Containers & Strings](#containers--strings) +- [Threads & Concurrency](#threads--concurrency) +- [GUI & Widgets](#gui--widgets) +- [Meta-Object System](#meta-object-system) +- [Review Checklist](#review-checklist) + +--- + +## Object Model & Memory Management + +### Use Parent-Child Ownership Mechanism +Qt's `QObject` hierarchy automatically manages memory. For `QObject`, prefer setting a parent object over manual `delete` or smart pointers. + +```cpp +// ❌ Manual management prone to memory leaks +QWidget* w = new QWidget(); +QLabel* l = new QLabel(); +l->setParent(w); +// ... If w is deleted, l is automatically deleted. But if w leaks, l also leaks. + +// ✅ Specify parent in constructor +QWidget* w = new QWidget(this); // Owned by 'this' +QLabel* l = new QLabel(w); // Owned by 'w' +``` + +### Use Smart Pointers with QObject +If a `QObject` has no parent, use `QScopedPointer` or `std::unique_ptr` with a custom deleter (use `deleteLater` if cross-thread). Avoid `std::shared_ptr` for `QObject` unless necessary, as it confuses the parent-child ownership system. + +```cpp +// ✅ Scoped pointer for local/member QObject without parent +QScopedPointer<MyObject> obj(new MyObject()); + +// ✅ Safe pointer to prevent dangling pointers +QPointer<MyObject> safePtr = obj.data(); +if (safePtr) { + safePtr->doSomething(); +} +``` + +### Use `deleteLater()` +For asynchronous deletion, especially in slots or event handlers, use `deleteLater()` instead of `delete` to ensure pending events in the event loop are processed. + +--- + +## Signals & Slots + +### Prefer Function Pointer Syntax +Use compile-time checked syntax (Qt 5+). + +```cpp +// ❌ String-based (runtime check only, slower) +connect(sender, SIGNAL(valueChanged(int)), receiver, SLOT(updateValue(int))); + +// ✅ Compile-time check +connect(sender, &Sender::valueChanged, receiver, &Receiver::updateValue); +``` + +### Connection Types +Be explicit or aware of connection types when crossing threads. +- `Qt::AutoConnection` (Default): Direct if same thread, Queued if different thread. +- `Qt::QueuedConnection`: Always posts event (thread-safe across threads). +- `Qt::DirectConnection`: Immediate call (dangerous if accessing non-thread-safe data across threads). + +### Avoid Loops +Check logic that might cause infinite signal loops (e.g., `valueChanged` -> `setValue` -> `valueChanged`). Block signals or check for equality before setting values. + +```cpp +void MyClass::setValue(int v) { + if (m_value == v) return; // ? Good: Break loop + m_value = v; + emit valueChanged(v); +} +``` + +--- + +## Containers & Strings + +### QString Efficiency +- Use `QStringLiteral("...")` for compile-time string creation to avoid runtime allocation. +- Use `QLatin1String` for comparison with ASCII literals (in Qt 5). +- Prefer `arg()` for formatting (or `QStringBuilder`'s `%` operator). + +```cpp +// ❌ Runtime conversion +if (str == "test") ... + +// ✅ Prefer QLatin1String for comparison with ASCII literals (in Qt 5) +if (str == QLatin1String("test")) ... // Qt 5 +if (str == u"test"_s) ... // Qt 6 +``` + +### Container Selection +- **Qt 6**: `QList` is now the default choice (unified with `QVector`). +- **Qt 5**: Prefer `QVector` over `QList` for contiguous memory and cache performance, unless stable references are needed. +- Be aware of Implicit Sharing (Copy-on-Write). Passing containers by value is cheap *until* modified. Use `const &` for read-only access. + +```cpp +// ❌ Forces deep copy if function modifies 'list' +void process(QVector<int> list) { + list[0] = 1; +} + +// ✅ Read-only reference +void process(const QVector<int>& list) { ... } +``` + +--- + +## Threads & Concurrency + +### Subclassing QThread vs Worker Object +Prefer the "Worker Object" pattern over subclassing `QThread` implementation details. + +```cpp +// ❌ Business logic inside QThread::run() +class MyThread : public QThread { + void run() override { ... } +}; + +// ✅ Worker object moved to thread +QThread* thread = new QThread; +Worker* worker = new Worker; +worker->moveToThread(thread); +connect(thread, &QThread::started, worker, &Worker::process); +thread->start(); +``` + +### GUI Thread Safety +**NEVER** access UI widgets (`QWidget` and subclasses) from a background thread. Use signals/slots to communicate updates to the main thread. + +--- + +## GUI & Widgets + +### Logic Separation +Keep business logic out of UI classes (`MainWindow`, `Dialog`). UI classes should only handle display and user input forwarding. + +### Layouts +Avoid fixed sizes (`setGeometry`, `resize`). Use layouts (`QVBoxLayout`, `QGridLayout`) to handle different DPIs and window resizing gracefully. + +### Blocking Event Loop +Never execute long-running operations on the main thread (freezes GUI). +- **Bad**: `Sleep()`, `while(busy)`, synchronous network calls. +- **Good**: `QProcess`, `QThread`, `QtConcurrent`, or asynchronous APIs (`QNetworkAccessManager`). + +--- + +## Meta-Object System + +### Properties & Enums +Use `Q_PROPERTY` for values exposed to QML or needing introspection. +Use `Q_ENUM` to enable string conversion for enums. + +```cpp +class MyObject : public QObject { + Q_OBJECT + Q_PROPERTY(int value READ value WRITE setValue NOTIFY valueChanged) +public: + enum State { Idle, Running }; + Q_ENUM(State) + // ... +}; +``` + +### qobject_cast +Use `qobject_cast<T*>` for QObjects instead of `dynamic_cast`. It is faster and doesn't require RTTI. + +--- + +## Review Checklist + +- [ ] **Memory**: Is parent-child relationship correct? Are dangling pointers avoided (using `QPointer`)? +- [ ] **Signals**: Are connections checked? Do lambdas use safe captures (context object)? +- [ ] **Threads**: Is UI accessed only from main thread? Are long tasks offloaded? +- [ ] **Strings**: Are `QStringLiteral` or `tr()` used appropriately? +- [ ] **Style**: Naming conventions (camelCase for methods, PascalCase for classes). +- [ ] **Resources**: Are resources (images, styles) loaded from `.qrc`? \ No newline at end of file diff --git a/.trellis/spec/review/reference/security-review-guide.md b/.trellis/spec/review/reference/security-review-guide.md new file mode 100644 index 00000000..80d10bc0 --- /dev/null +++ b/.trellis/spec/review/reference/security-review-guide.md @@ -0,0 +1,265 @@ +# Security Review Guide + +Security-focused code review checklist based on OWASP Top 10 and best practices. + +## Authentication & Authorization + +### Authentication +- [ ] Passwords hashed with strong algorithm (bcrypt, argon2) +- [ ] Password complexity requirements enforced +- [ ] Account lockout after failed attempts +- [ ] Secure password reset flow +- [ ] Multi-factor authentication for sensitive operations +- [ ] Session tokens are cryptographically random +- [ ] Session timeout implemented + +### Authorization +- [ ] Authorization checks on every request +- [ ] Principle of least privilege applied +- [ ] Role-based access control (RBAC) properly implemented +- [ ] No privilege escalation paths +- [ ] Direct object reference checks (IDOR prevention) +- [ ] API endpoints protected appropriately + +### JWT Security +```typescript +// ❌ Insecure JWT configuration +jwt.sign(payload, 'weak-secret'); + +// ✅ Secure JWT configuration +jwt.sign(payload, process.env.JWT_SECRET, { + algorithm: 'RS256', + expiresIn: '15m', + issuer: 'your-app', + audience: 'your-api' +}); + +// ❌ Not verifying JWT properly +const decoded = jwt.decode(token); // No signature verification! + +// ✅ Verify signature and claims +const decoded = jwt.verify(token, publicKey, { + algorithms: ['RS256'], + issuer: 'your-app', + audience: 'your-api' +}); +``` + +## Input Validation + +### SQL Injection Prevention +```python +# ❌ Vulnerable to SQL injection +query = f"SELECT * FROM users WHERE id = {user_id}" + +# ✅ Use parameterized queries +cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,)) + +# ✅ Use ORM with proper escaping +User.objects.filter(id=user_id) +``` + +### XSS Prevention +```typescript +// ❌ Vulnerable to XSS +element.innerHTML = userInput; + +// ✅ Use textContent for plain text +element.textContent = userInput; + +// ✅ Use DOMPurify for HTML +element.innerHTML = DOMPurify.sanitize(userInput); + +// ✅ React automatically escapes (but watch dangerouslySetInnerHTML) +return <div>{userInput}</div>; // Safe +return <div dangerouslySetInnerHTML={{__html: userInput}} />; // Dangerous! +``` + +### Command Injection Prevention +```python +# ❌ Vulnerable to command injection +os.system(f"convert {filename} output.png") + +# ✅ Use subprocess with list arguments +subprocess.run(['convert', filename, 'output.png'], check=True) + +# ✅ Validate and sanitize input +import shlex +safe_filename = shlex.quote(filename) +``` + +### Path Traversal Prevention +```typescript +// ❌ Vulnerable to path traversal +const filePath = `./uploads/${req.params.filename}`; + +// ✅ Validate and sanitize path +const path = require('path'); +const safeName = path.basename(req.params.filename); +const filePath = path.join('./uploads', safeName); + +// Verify it's still within uploads directory +if (!filePath.startsWith(path.resolve('./uploads'))) { + throw new Error('Invalid path'); +} +``` + +## Data Protection + +### Sensitive Data Handling +- [ ] No secrets in source code +- [ ] Secrets stored in environment variables or secret manager +- [ ] Sensitive data encrypted at rest +- [ ] Sensitive data encrypted in transit (HTTPS) +- [ ] PII handled according to regulations (GDPR, etc.) +- [ ] Sensitive data not logged +- [ ] Secure data deletion when required + +### Configuration Security +```yaml +# ❌ Secrets in config files +database: + password: "super-secret-password" + +# ✅ Reference environment variables +database: + password: ${DATABASE_PASSWORD} +``` + +### Error Messages +```typescript +// ❌ Leaking sensitive information +catch (error) { + return res.status(500).json({ + error: error.stack, // Exposes internal details + query: sqlQuery // Exposes database structure + }); +} + +// ✅ Generic error messages +catch (error) { + logger.error('Database error', { error, userId }); // Log internally + return res.status(500).json({ + error: 'An unexpected error occurred' + }); +} +``` + +## API Security + +### Rate Limiting +- [ ] Rate limiting on all public endpoints +- [ ] Stricter limits on authentication endpoints +- [ ] Per-user and per-IP limits +- [ ] Graceful handling when limits exceeded + +### CORS Configuration +```typescript +// ❌ Overly permissive CORS +app.use(cors({ origin: '*' })); + +// ✅ Restrictive CORS +app.use(cors({ + origin: ['https://your-app.com'], + methods: ['GET', 'POST'], + credentials: true +})); +``` + +### HTTP Headers +```typescript +// Security headers to set +app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + } + }, + hsts: { maxAge: 31536000, includeSubDomains: true }, + noSniff: true, + xssFilter: true, + frameguard: { action: 'deny' } +})); +``` + +## Cryptography + +### Secure Practices +- [ ] Using well-established algorithms (AES-256, RSA-2048+) +- [ ] Not implementing custom cryptography +- [ ] Using cryptographically secure random number generation +- [ ] Proper key management and rotation +- [ ] Secure key storage (HSM, KMS) + +### Common Mistakes +```typescript +// ❌ Weak random generation +const token = Math.random().toString(36); + +// ✅ Cryptographically secure random +const crypto = require('crypto'); +const token = crypto.randomBytes(32).toString('hex'); + +// ❌ MD5/SHA1 for passwords +const hash = crypto.createHash('md5').update(password).digest('hex'); + +// ✅ Use bcrypt or argon2 +const bcrypt = require('bcrypt'); +const hash = await bcrypt.hash(password, 12); +``` + +## Dependency Security + +### Checklist +- [ ] Dependencies from trusted sources only +- [ ] No known vulnerabilities (npm audit, cargo audit) +- [ ] Dependencies kept up to date +- [ ] Lock files committed (package-lock.json, Cargo.lock) +- [ ] Minimal dependency usage +- [ ] License compliance verified + +### Audit Commands +```bash +# Node.js +npm audit +npm audit fix + +# Python +pip-audit +safety check + +# Rust +cargo audit + +# General +snyk test +``` + +## Logging & Monitoring + +### Secure Logging +- [ ] No sensitive data in logs (passwords, tokens, PII) +- [ ] Logs protected from tampering +- [ ] Appropriate log retention +- [ ] Security events logged (login attempts, permission changes) +- [ ] Log injection prevented + +```typescript +// ❌ Logging sensitive data +logger.info(`User login: ${email}, password: ${password}`); + +// ✅ Safe logging +logger.info('User login attempt', { email, success: true }); +``` + +## Security Review Severity Levels + +| Severity | Description | Action | +|----------|-------------|--------| +| **Critical** | Immediate exploitation possible, data breach risk | Block merge, fix immediately | +| **High** | Significant vulnerability, requires specific conditions | Block merge, fix before release | +| **Medium** | Moderate risk, defense in depth concern | Should fix, can merge with tracking | +| **Low** | Minor issue, best practice violation | Nice to fix, non-blocking | +| **Info** | Suggestion for improvement | Optional enhancement | diff --git a/.trellis/spec/shared/cpp-conventions.md b/.trellis/spec/shared/cpp-conventions.md new file mode 100644 index 00000000..97ecb5b5 --- /dev/null +++ b/.trellis/spec/shared/cpp-conventions.md @@ -0,0 +1,605 @@ +# C++ 编码约定 + +> DDE 应用的 C++17 编码规范和最佳实践。 + +--- + +## 概览 + +本指南定义 DDE 应用的 C++ 编码标准,遵循 C++17 特性和 Qt/DTK 集成。 + +--- + +## C++17 特性使用 + +### 结构化绑定 + +```cpp +// ✅ 推荐 +auto [success, error] = parseResult(); + +// ✅ 推荐 +for (const auto &[key, value] : map) { + use(key, value); +} +``` + +### If 初始化 + +```cpp +// ✅ 推荐 +if (auto result = function(); result.isValid()) { + process(result); +} + +// 等价于 +auto result = function(); +if (result.isValid()) { + process(result); +} +``` + +### Optional + +```cpp +// ✅ 推荐 +std::optional<int> parseNumber(const QString &str) { + bool ok; + int value = str.toInt(&ok); + return ok ? std::optional<int>(value) : std::nullopt; +} + +// 使用 +if (auto value = parseNumber(input)) { + process(*value); +} +``` + +--- + +## RAII 资源管理 + +### 智能指针选择 + +```cpp +// ✅ 推荐: QSharedPointer 共享所有权 +QSharedPointer<Resource> resource = QSharedPointer<Resource>::create(); + +// ✅ 推荐: QScopedPointer 独占所有权 +QScopedPointer<Worker> worker(new Worker()); + +// ✅ 推荐: QWeakPointer 非拥有引用 +QWeakPointer<Resource> weakRef = resource; + +// ❌ 避免: 除非特殊需要,不用 std::unique_ptr 与 Qt 对象混用 +``` + +### Qt 对象父子关系 + +```cpp +// ✅ 推荐: 父子关系自动管理 +DWidget *parent = new DWindow; +DLabel *child = new DLabel("Text", parent); + +// ✅ 推荐: 使用 deleteLater 安全删除 +widget->deleteLater(); +``` + +### 文件资源 + +```cpp +// ✅ 推荐: 使用 QFile 的 RAII +void processFile(const QString &path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return; + } + // 文件自动关闭 +} + +// ✅ 推荐: 临时文件 +QTemporaryFile tempFile; +if (tempFile.open()) { + // 自动删除 +} +``` + +--- + +## Pimpl 模式 + +### 标准 Pimpl + +```cpp +// MyClass.h +class MyClass : public QObject { + Q_OBJECT +public: + explicit MyClass(QObject *parent = nullptr); + ~MyClass(); + + void setValue(int value); + int value() const; + +private: + class Private; + QScopedPointer<Private> d; +}; + +// MyClass.cpp +class MyClass::Private { +public: + int value = 0; + DLabel *label = nullptr; + QTimer *timer = nullptr; +}; + +MyClass::MyClass(QObject *parent) + : QObject(parent) + , d(new Private()) +{ +} + +MyClass::~MyClass() = default; +``` + +### 何时使用 Pimpl + +- **需要 ABI 稳定性** - 公共 API +- **降低编译依赖** - 减少头文件包含 +- **隐藏实现细节** - 不暴露内部结构 + +--- + +## 异常处理 + +### 策略 + +DDE 应用优先使用错误码而非异常,因为 Qt 主要使用错误码模式。 + +```cpp +// ✅ 推荐: 使用 Result 模式 +struct Result { + bool success = false; + QString error; + QVariant data; + + static Result ok(const QVariant &data = {}) { + return {true, QString(), data}; + } + + static Result fail(const QString &error) { + return {false, error, QVariant()}; + } +}; +``` + +### 异常捕获 + +```cpp +// ✅ 推荐: 捕获所有异常 +try { + // 可能抛出异常的代码 +} catch (const std::exception &e) { + qWarning() << "Exception:" << e.what(); + showError(tr("Operation failed")); +} catch (...) { + qWarning() << "Unknown exception"; + showError(tr("Unknown error")); +} +``` + +--- + +## 命名约定 + +> 本规范遵循 Google C++ 风格指南,deepin/DDE 特例外见下文。 + +### 文件命名 + +**遵循 deepin-styleguide 规范:** + +```cpp +// ✅ 正确 - 文件名全小写,使用下划线连接 +class MyWidget; // mywidget.h .cpp +class FileManager; // file_manager.h .cpp +class DMainWindow; // d_main_window.h .cpp + +// ✅ 正确 - 头文件使用 .h,源文件使用 .cpp +mywidget.h / mywidget.cpp + +// ✅ 备选 - 只有在下划线无法使用时才考虑连字符 +my-useful-class.cpp // 适用于某些构建系统不支持下划线的情况 + +// ❌ 错误 - 无连接符,难以阅读 +mywidget.cpp // 不适用 +myusefulclass.cpp + +// ❌ 错误 - 名称不清晰 +MyWidget.h // 大驼峰 +``` + +### 类和函数 + +```cpp +// 类: 大驼峰 +class MyPlugin : public QObject { }; + +// 函数: 小驼峰 +void initializePlugin(); +QString filePath(); +bool isValid(); + +// 成员变量: 见下方 "D-Pointer 模式特殊命名" + +// 静态常量: 下划线分隔 + k 前缀 +static const int kMaxRetries = 3; +static const QString kDefaultPath = "/tmp"; +``` + +### D-Pointer 模式特殊命名 + +> 基于 deepin-styleguide Qt 命名规范。 + +**保留名称**:`d_ptr`, `q_ptr`, `dd_ptr`, `qq_ptr` 保留给 D-Pointer 模式使用。 + +**成员变量命名规则**: + +```cpp +// ✅ 正确 - Private 类中成员变量不加任何前缀 +class MyClassPrivate { +public: + QString name; // 不加 m_ 前缀 + int value; // 不加 m_ 前缀 + DLabel *label; // 不加 m_ 前缀 + static int count; // 不加 m_ 前缀 +}; + +// ✅ 正确 - 普通类中的成员变量使用 m_ 前缀 +class MyClass : public QObject { +private: + QString m_name; // 普通类使用 m_ 前缀 + int m_value; // 普通类使用 m_ 前缀 +}; + +// ✅ 正确 - 结构体成员变量不加前缀(与 Private 类风格一致) +struct ConfigData { + QString name; // 不加前缀 + int value; // 不加前缀 +}; + +// ❌ 错误 - Private 类中错误地使用了 m_ 前缀 +class MyClassPrivate { +public: + QString m_name; // 不应该在 Private 类中使用 m_ 前缀 +}; + +// ❌ 错误 - 普通类中错误地没有使用 m_ 前缀 +class MyClass { +private: + QString name; // 应该使用 m_ 前缀 +}; + +// ❌ 错误 - 使用了保留名称 +QObject *d_ptr; // d_ptr 是保留名称 +QObject *q_ptr; // q_ptr 是保留名称 +``` + +**何时使用 Private 类**: + +```cpp +// 使用 D-Pointer 模式(通过 d-> 访问 Private 类成员) +class MyClass : public QObject { + Q_OBJECT +public: + MyClass(QObject *parent = nullptr); + ~MyClass(); + + QString name() const; + void setName(const QString &name); + +private: + class Private; + QScopedPointer<Private> d; +}; + +// 优势: +// 1. 封装:隐藏实现细节 +// 2. ABI 稳定性:不暴露内部成员 +// 3. 编译速度:减少头文件依赖 + +// 注意:在 D-Pointer 模式中访问 Private 类成员: +// d->name(不加 m_ 前缀) +``` + +--- + +## Const 正确性 + +### 常量正确 + +```cpp +// ✅ 推荐: 不修改成员的函数标记为 const +int value() const { + return m_value; +} + +// ✅ 推荐: 参数可以是 const 的就标记为 const +void process(const QString &text); +void processData(const QByteArray &data); + +// ✅ 推荐: 优先使用 const 引用避免拷贝 +QString processLargeString(const QString &input); +// 而不是 +QString processLargeString(QString input); +``` + +### 避免 mutable + +```cpp +// ❌ 避免: 除非必要,不用 mutable +// mutable 破坏了 const 的语义 +// 如果需要,考虑重新设计 +int value() const { + return ++m_cachedValue; // ❌ mutable +} +``` + +--- + +## 头文件管理 + +> 基于 deepin-styleguide Qt 头文件规范。 + +### 自包含头文件 + +**每个 `.h` 文件必须能够自给自足(self-contained)。** + +```cpp +// ✅ 正确 - 自包含头文件 +// myclass.h +#ifndef MYCLASS_H_ +#define MYCLASS_H_ + +#include <QObject> // 包含所有需要的头文件 + +class MyClass : public QObject { + Q_OBJECT +public: + explicit MyClass(QObject *parent = nullptr); +}; + +#endif // MYCLASS_H_ + +// myclass.cpp +#include "myclass.h" // 可以直接使用,无需额外的 include + +// ❌ 错误 - 头文件依赖其他头文件 +// myclass.h(假设没有 #include <QObject>) +class MyClass : public QObject { // 编译失败!QObject 未定义 + // ... +}; + +// myclass.cpp +#include <QObject> // 需要先包含才能使用 +#include "myclass.h" +``` + +### 头文件保护宏 + +**格式:`<PROJECT>_<PATH>_<FILE>_H_`** + +```cpp +// ✅ 正确 - 基于项目路径生成 +// 项目: myapp +// 路径: src/core +// 文件: myclass + +#ifndef MYAPP_CORE_MYCLASS_H_ +#define MYAPP_CORE_MYCLASS_H_ + +// 代码 + +#endif // MYAPP_CORE_MYCLASS_H_ + +// ✅ 正确 - 更简单的格式 +#ifndef MYCLASS_H_ +#define MYCLASS_H_ + +// 代码 + +#endif // MYCLASS_H_ +``` + +### 前置声明策略 + +**避免使用前置声明,直接使用 `#include`。** + +```cpp +// ❌ 避免 - 前置声明 +class MyClass; // 前置声明 + +void process(MyClass *obj); // 使用前置声明 + +// ✅ 推荐 - 直接 include +#include "myclass.h" + +void process(MyClass *obj); // 直接使用 +``` + +**原因**: +1. 避免隐藏依赖关系 +2. 减少重构时的编译错误 +3. 保持代码可维护性 + +### 内联函数限制 + +**只有不超过 10 行的函数才应定义为内联函数。** + +```cpp +// ✅ 推荐 - 简短函数可以内联 +inline int square(int x) { + return x * x; +} + +// ✅ 推荐 - getter/setter 可以内联 +class MyClass { +public: + int value() const { return m_value; } // 简短,可以内联 + void setValue(int value) { m_value = value; } +}; + +// ❌ 避免 - 复杂函数不应内联 +inline void complexFunction() { // 太长了! + // ... 10+ 行代码 ... +} +``` + +### #include 顺序 + +**使用标准顺序:相关头文件 → C 库 → C++ 库 → 其他库 → 本项目。** + +```cpp +// myclass.cpp + +// 1. 相关头文件(优先位置) +#include "myclass.h" + +// 2. C 系统库 +#include <sys/types.h> +#include <unistd.h> + +// 3. C++ 标准库 +#include <memory> +#include <vector> +#include <algorithm> + +// 4. 其他库(Qt 等) +#include <QObject> +#include <QList> +#include <QDebug> + +// 5. 本项目内头文件 +#include "otherclass.h" +#include "utils.h" +``` + +**好处**: +1. 相关头文件在前,如果缺少依赖会立即发现 +2. 按类别分组,易于阅读和维护 +3. 避免隐藏依赖 + +--- + +## 禁用特性 + +### 禁止使用 + +```cpp +// ❌ 禁止: 原始指针直接 new 没有父对象 +QObject *obj = new QObject(); + +// ❌ 禁止: 裸指针 delete +delete widget; +delete[] array; + +// ❌ 禁止: malloc/free +void *ptr = malloc(100); +free(ptr); + +// ❌ 禁止: C 风格转换 +QObject *obj = (QObject*)ptr; + +// ❌ 禁止: 数组作为原始数组 +char buffer[1024]; +``` + +### 推荐替代 + +```cpp +// ✅ 推荐: 智能指针 +QSharedPointer<Object> obj = QSharedPointer<Object>::create(); + +// ✅ 推荐: Qt 容器 +QVector<int> numbers; +QMap<QString, int> mapping; +QList<QObject*> objects; + +// ✅ 推荐: QByteArray/QByteArray +QByteArray data = QByteArray::fromRawData(ptr, size); +QDataBuffer buffer; +``` + +--- + +## 内存管理 + +### Parent-Child 模式 + +```cpp +// ✅ 推荐: 始终为没有明确所有者的对象设置父对象 +class MyWidget : public DWidget { +public: + explicit MyWidget(QWidget *parent = nullptr) : DWidget(parent) { + m_label = new DLabel(this); // 自动删除 + m_button = new DPushButton(this); // 自动删除 + } +private: + DLabel *m_label; + DPushButton *m_button; +}; +``` + +### 避免循环引用 + +```cpp +// ❌ 避免: Parent 和 Child 相互引用 +class Parent : public QObject { + Q_OBJECT +public: + void setChild(Child *child) { + m_child = child; + child->setParent(this); // 可能导致问题 + } +private: + Child *m_child; +}; + +// ✅ 推荐: 只由 Parent 拥有 Child +class Parent : public QObject { + Q_OBJECT +public: + void setChild(Child *child) { + child->setParent(this); + // 不保存指针 + } +}; + +// 或使用 QWeakPointer +``` + +--- + +## 快速参考 + +| 特性 | 使用 | +|------|------| +| 结构化绑定 | `auto [a, b] = pair;` | +| Optional | `std::optional<int>` | +| 共享所有权 | `QSharedPointer` | +| 独占所有权 | `QScopedPointer` | +| 非拥有引用 | `QWeakPointer` | +| 文件资源 | `QFile` RAII | +| 错误处理 | `Result` 模式 | +| 类命名 | 大驼峰 | +| 函数命名 | 小驼峰 | +| 成员变量 | 普通类 `m_` 前缀,Private 类无前缀 | +| 文件命名 | 小写 + 下划线(`my_widget.h`) | +| 头文件保护 | `<PROJECT>_<PATH>_<FILE>_H_` | + +--- + +## 参考资源 + +- [Google C++ Style Guide](https://google.github.io/styleguide/cppguide.html) - 通用 C++ 规范 +- [deepin-styleguide](https://gitlab.deepin.org/styleguide) - deepin/DDE 特有规范 diff --git a/.trellis/spec/shared/dbus-conventions.md b/.trellis/spec/shared/dbus-conventions.md new file mode 100644 index 00000000..386adde6 --- /dev/null +++ b/.trellis/spec/shared/dbus-conventions.md @@ -0,0 +1,437 @@ +# DBus 接口命名规范 + +> DDE 桌面环境 DBus 接口的命名和设计规范。 + +--- + +## 概览 + +本规范定义 DDE 组件间 DBus 通信的标准命名格式,确保接口的一致性和可维护性。 + +遵循 [DBus API Design Guidelines](https://dbus.freedesktop.org/doc/dbus-api-design.html) 并在此基础上定义 deepin/DDE 特有的约定。 + +--- + +## 命名结构 + +### 标准格式 + +DBus 接口由四个部分组成:**服务名**、**对象路径**、**接口**、**方法/属性/信号**。 + +``` +服务名: org.deepin.DDE1.Accounts +对象路径: /org/deepin/dde/accounts # 注意路径中的转换 +接口: org.deepin.DDE1.Accounts +方法: SetBrightness +属性: Volume +信号: BrightnessChanged +``` + +### 命名规则分解 + +| 部分 | 格式 | 示例 | +|------|------|------| +| **域名** | 倒置域名 | `org.deepin`, `org.desktopspec` | +| **项目名** | 大小写混合 + 版本号 | `DDE1`, `Manual1` | +| **组件名** | 大小写混合 | `Accounts`, `Search` | + +--- + +## 服务命名规范 + +### 新格式(推荐) + +```cpp +// ✅ 正确 - 明确的版本号 +org.deepin.DDE1.Accounts +org.deepin.Manual1.Search +org.desktopspec.ConfigManager + +// ✅ 正确 - 多级组件 +org.deepin.DDE1.Calendar.Event +org.deepin.DDE1.FileManager.Mount +``` + +### 旧格式(已废弃) + +```cpp +// ❌ 废弃 - daemon 意义不明确 +com.deepin.daemon.Accounts +com.deepin.daemon.Display + +// ⚠️ 迁移路径 +// 旧: com.deepin.daemon.Accounts +// 新: org.deepin.DDE1.Accounts +``` + +--- + +## 对象路径规范 + +### 命名转换 + +对象路径将服务名中的点号`.`替换为斜杠`/`,并将大写字母转换为小写(除路径中的单词边界)。 + +```cpp +// ✅ 正确 - 标准转换规则 +// 服务名: org.deepin.DDE1.Accounts +// 路径: /org/deepin/dde/accounts + +// 服务名: org.deepin.DDE1.FileManager.Mount +// 路径: /org/deepin/dde/file-manager/mount + +// 服务名: org.deepin.Manual1.Search +// 路径: /org/deepin/manual1/search +``` + +### 路径变体 + +对于有多个实例的对象,可以在路径末尾添加唯一标识。 + +```cpp +// ✅ 正确 - 单个实例 +/org/deepin/dde/accounts + +// ✅ 正确 - 多个实例 +/org/deepin/dde/accounts/user1 +/org/deepin/dde/accounts/user2 + +// ✅ 正确 - 使用 ID +/org/deepin/dde/mount/sd-1234 +``` + +--- + +## 接口命名规范 + +### 基本规则 + +接口名与服务名使用相同的命名格式(大小写混合 + 版本号)。 + +```cpp +// ✅ 正确 +org.deepin.DDE1.Accounts +org.desktopspec.ConfigManager + +// ❌ 错误 - 缺少版本号 +org.deepin.dde.Accounts // 应该是 DDE1 +org.freedesktop.portal // 缺少版本号 +``` + +### 接口分段 + +一个服务可以提供多个接口,接口应该按照功能进行分段。 + +```cpp +// ✅ 正确 - 多接口服务 +org.deepin.DDE1.Calendar.Event // 事件相关 +org.deepin.DDE1.Calendar.Schedule // 日程相关 +org.deepin.DDE1.Calendar.Widget // 窗口部件 +``` + +--- + +## 方法命名规范 + +### 基本规则 + +方法名使用**大小写混合**格式,每个单词首字母大写(PascalCase)。 + +```cpp +// ✅ 正确 +SetBrightness(int brightness) +GetUserInfo(const QString &username) +CreateFolder(const QString &path) + +// ❌ 错误 +set_brightness // 下划线命名 +get_user_info // 下划线命名 +createfolder // 不区分大小写 +``` + +### 命名模式 + +| 模式 | 示例 | 说明 | +|------|------|------| +| Get | `GetVolume()` | 获取值 | +| Set | `SetVolume(int)` | 设置值 | +| Is | `IsValid()` | 检查状态(返回 bool) | +| Has | `HasPermission()` | 检查拥有(返回 bool) | +| Create | `CreateFolder()` | 创建对象 | +| Delete | `DeleteFolder()` | 删除对象 | +| Add | `AddItem()` | 添加元素 | +| Remove | `RemoveItem()` | 移除元素 | + +### 异步方法 + +异步方法应使用明确的命名约定。 + +```cpp +// ✅ 正确 - 异步操作 +void CreateFolderAsync(const QString &path) +void RequestPermissionsAsync() + +// ✅ 正确 - 信号方式 +QDBusPendingCall reply = interface.asyncCall("CreateFolder", path); +``` + +--- + +## 属性命名规范 + +### 基本规则 + +属性名使用**大小写混合**格式,与方法命名风格一致。 + +```cpp +// ✅ 正确 +Volume // 音量 +Brightness // 亮度 +UserName // 用户名 +FilePath // 文件路径 + +// ❌ 错误 +volume // 全小写 +file_path // 下划线 +``` + +### 只读属性 + +只读属性应明确标记。 + +```cpp +// ✅ 正确 - DBus XML 中定义 +<property name="UserName" type="s" access="read" /> + +// ✅ 正确 - QDBusAbstractAdaptor 中 +Q_PROPERTY(QString UserName READ userName) +``` + +### 读写属性 + +读写属性使用标准的 Get/Set 方法。 + +```cpp +// ✅ 正确 +<property name="Volume" type="i" access="readwrite" /> + +// 对应的方法 +int Volume() const; +void SetVolume(int volume); +``` + +--- + +## 信号命名规范 + +### 基本规则 + +信号名使用**大小写混合**格式,通常表示状态变化。 + +```cpp +// ✅ 正确 +VolumeChanged(int newVolume) +BrightnessChanged(int newBrightness) +StatusChanged(int newStatus) + +// ❌ 错误 +volume_changed // 下划线 +OnVolumeChanged // 不要加 On 前缀 +``` + +### 命名模式 + +| 模式 | 示例 | 说明 | +|------|------|------| +| Changed | `VolumeChanged()` | 值变化 | +| Added | `ItemAdded(QString item)` | 添加元素 | +| Removed | `ItemRemoved(QString item)` | 移除元素 | +| Started | `ProcessStarted()` | 开始 | +| Finished | `ProcessFinished()` | 完成 | +| Failed | `ProcessFailed(QString error)` | 失败 | + +### 信号参数 + +信号应包含变化的相关信息。 + +```cpp +// ✅ 正确 - 提供变化后的值 +void VolumeChanged(int volume) +void ItemAdded(const QString &item, int index) + +// ✅ 正确 - 提供完整状态 +void StatusChanged(int oldStatus, int newStatus) + +// ⚠️ 备选 - 简化版本(适用于频繁触发的信号) +void DataChanged() +``` + +--- + +## 完整示例 + +### 服务端代码 + +```cpp +// VolumeService.h +class VolumeService : public QObject, public QDBusAbstractAdaptor { + Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "org.deepin.DDE1.Volume") + Q_PROPERTY(int Volume READ volume WRITE setVolume NOTIFY volumeChanged) + +public: + explicit VolumeService(QObject *parent = nullptr); + + // ✅ 正确 - 方法命名 + int volume() const; + void setVolume(int volume); + bool isMuted() const; + void setMuted(bool muted); + +signals: + // ✅ 正确 - 信号命名 + void VolumeChanged(int volume); + void MutedChanged(bool muted); +}; +``` + +### DBus XML 描述 + +```xml +<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" +"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd"> + +<node> + <!-- ✅ 正确 - 接口定义 --> + <interface name="org.deepin.DDE1.Volume"> + + <!-- ✅ 正确 - 属性定义 --> + <property name="Volume" type="i" access="readwrite" /> + <property name="Muted" type="b" access="readwrite" /> + + <!-- ✅ 正确 - 方法定义 --> + <method name="SetVolume"> + <arg name="volume" type="i" direction="in" /> + </method> + + <method name="GetVolume"> + <arg name="volume" type="i" direction="out" /> + </method> + + <method name="IsMuted"> + <arg name="muted" type="b" direction="out" /> + </method> + + <!-- ✅ 正确 - 信号定义 --> + <signal name="VolumeChanged"> + <arg name="volume" type="i" /> + </signal> + + <signal name="MutedChanged"> + <arg name="muted" type="b" /> + </signal> + + </interface> +</node> +``` + +### 客户端代码 + +```cpp +// 客户端使用 +QDBusInterface iface( + "org.deepin.DDE1.Volume", // ✅ 服务名 + "/org/deepin/dde/volume", // ✅ 对象路径 + "org.deepin.DDE1.Volume", // ✅ 接口 + QDBusConnection::sessionBus() +); + +// ✅ 正确 - 方法调用 +iface.call("SetVolume", 50); +QDBusReply<int> reply = iface.call("GetVolume"); + +// ✅ 正确 - 连接信号 +QDBusConnection::sessionBus().connect( + "org.deepin.DDE1.Volume", + "/org/deepin/dde/volume", + "org.deepin.DDE1.Volume", + "VolumeChanged", + this, + SLOT(onVolumeChanged(int)) +); +``` + +--- + +## 迁移指南 + +### 从旧格式迁移 + +```cpp +// ❌ 旧格式(废弃) +服务名: com.deepin.daemon.Accounts +路径: /com/deepin/daemon/Accounts +接口: com.deepin.daemon.Accounts +方法: SetUserName + +// ✅ 新格式(推荐) +服务名: org.deepin.DDE1.Accounts +路径: /org/deepin/dde/accounts +接口: org.deepin.DDE1.Accounts +方法: SetUserName +``` + +### 迁移步骤 + +1. **评估影响范围**:确认哪些服务仍在使用旧格式 +2. **更新服务实现**:修改服务注册时的名称 +3. **更新客户端代码**:修改接口调用 +4. **提供兼容层**:在过渡期同时支持新旧格式 +5. **通知所有使用者**:发布变更日志 + +--- + +## 快速参考 + +| 元素 | 格式规则 | 正确示例 | +|------|---------|----------| +| 服务名 | `org.domain.Project1.Version` | `org.deepin.DDE1.Accounts` | +| 对象路径 | `/org/domain/project/version/component` | `/org/deepin/dde/accounts` | +| 接口 | 与服务名相同 | `org.deepin.DDE1.Accounts` | +| 方法 | PascalCase | `SetUserName` | +| 属性 | PascalCase | `UserName` | +| 信号 | PascalCase + Changed/Added/Removed | `UserNameChanged` | + +--- + +## 反模式 + +```cpp +// ❌ 反模式 - 不要这样做 + +// 1. 混用命名风格 +void set_volume(int); // 不要用下划线 +void SetuserName(int); // 不要混合大小写 + +// 2. 描述不清的名称 +void DoSomething(int x); // 应该用具体名称 +void Method1(); // 应该有明确含义 + +// 3. 缺少版本号 +org.deepin.dde.accounts // 应该是 DDE1 + +// 4. 不一致的命名 +void SetUserName(); // PascalCase +void get_password(); // 混合了驼峰和下划线 + +// 5. 不必要的前缀 +void OnVolumeChanged(); // 不需要 On 前缀 +``` + +--- + +## 参考资源 + +- [DBus API Design Guidelines](https://dbus.freedesktop.org/doc/dbus-api-design.html) +- [dbus-specification](https://dbus.freedesktop.org/doc/dbus-specification.html) +- [Qt D-Bus Documentation](https://doc.qt.io/qt-6/qdbus.html) diff --git a/.trellis/spec/shared/deepin-terminology.md b/.trellis/spec/shared/deepin-terminology.md new file mode 100644 index 00000000..8dd5ef80 --- /dev/null +++ b/.trellis/spec/shared/deepin-terminology.md @@ -0,0 +1,215 @@ +# deepin/DDE 品牌术语规范 + +> 规范 deepin 和 DDE 品牌名称在文档、代码中的正确使用方式。 + +--- + +## 概览 + +本规范定义 deepin 和 DDE 品牌术语的标准使用方式,确保项目内文档和代码的一致性。 + +--- + +## deepin 品牌规范 + +### 基本规则 + +**deepin** 品牌名称在任何文档、图片、代码注释中都应使用**全小写**,即使是段落首字母。 + +```cpp +// ✅ 正确 +// deepin 开源项目 +// deepin desktop environment + +// ❌ 错误 +// Deepin 开源项目 +// DEEPIN 开源项目 +``` + +### 代码中的使用 + +在代码中,deepin 也应使用全小写,除非所在语言的编码规范强制要求全大写或首字母大写。 + +```cpp +// ✅ 正确 +#define DEEPIN_MACRO XXXX // 宏命名遵循编码规范 +const int kDeepinNumber = 1; // 常量命名遵循编码规范 + +// ✅ 正确 - 版权信息 +// Copyright (c) 2021. deepin All rights reserved. +``` + +### 文件名中的使用 + +在文件路径和文件名中,deepin 必须使用全小写。 + +```bash +# ✅ 正确 +/usr/lib/deepin-daemon/dde-system-daemon +/usr/share/deepin/msc/res + +# ❌ 错误 +/usr/share/Deepin/msc/res +/usr/lib/DeepinDaemon +``` + +--- + +## DDE 品牌规范 + +### 基本规则 + +**DDE** 是 Deepin Desktop Environment 的缩写。 + +- **文档中**:使用全大写 `DDE` +- **文件名中**:使用全小写 `dde` + +```cpp +// ✅ 正确 - 文档中 +The DDE is comprised of the Desktop Environment, deepin Window Manager, Control Center, Launcher and Dock. + +// ❌ 错误 - 文档中 +Use dde in other os. // 文档中不允许全小写 +Login to Dde. // 文档中不允许混合大小写 + +// ✅ 正确 - 文件名中 +/usr/lib/deepin-daemon/dde-system-daemon +org.deepin.DDE1.Accounts + +// ❌ 错误 - 文件名中 +/usr/lib/deepin-daemon/DDE-System-Daemon +com.deepin.daemon // 旧命名风格,已废弃 +``` + +### 专有名词 + +**Deepin Desktop Environment** 是专有名词,不要拆开使用或修改大小写。 + +```cpp +// ✅ 正确 +Deepin Desktop Environment + +// ❌ 错误 +deepin Desktop Environment +deepin desktop environment +``` + +--- + +## 文档和注释格式 + +### deepin 风格注释 + +在文档中,应该使用标准化的注释格式标记重要信息。 + +```markdown +<!-- ✅ 正确 - 重要信息标记 --> +> **注意**: deepin 桌面环境使用轻量级设计,避免过度装饰。 +> **警告**: 此操作会导致数据丢失,请谨慎使用。 +> **提示**: 快捷键 Ctrl+S 可以保存当前配置。 + +<!-- ✅ 正确 - 代码注释 --> +// 注意 deepin 品牌名称必须全小写 +// 警告此函数非线程安全 +// 提示缓存会自动清理,无需手动删除 +``` + +--- + +## 项目和文件创建 + +### 新项目创建 + +在创建新项目前,应当检查是否可以扩展现有的 deepin 项目。 + +```bash +# ✅ 正确 - 扩展现有项目 +# 不创建新的项目,而是在现有的组件中添加新功能 +# 例如:在 dde-dock 中添加新插件,而不是创建 d-dock-extensions + +# ✅ 正确 - 创建新项目 +# 当功能无法在现有项目中扩展时,创建新项目 +deepin-new-feature # 全小写 + 连字符 +``` + +### 项目命名 + +deepin 项目应使用**全小写**,单词使用连字符(`-`)连接。 + +```bash +# ✅ 正确 +plymouth-theme-deepin +deepin-font-manager + +# ❌ 错误 +Roboto-Autotest # 大写 +deepin_font_manager # 使用下划线 +``` + +### 应用程序 + +应用程序(桌面文件中)应使用**倒置域名**格式。 + +```ini +# ✅ 正确 - desktop 文件 +[Desktop Entry] +Name=Deepin Music +# 文件: org.deepin.lianliankan.desktop + +# ✅ 正确 - DBus 服务名 +org.deepin.dde-launcher +org.deepin.dde-file-manager + +# ❌ 错误 +deepin-music # 应用程序应该使用倒置域名 +com.deepin.daemon # 旧命名风格,已废弃 +``` + +### 专有名词例外 + +当 deepin/DDE 与其他名词组成专有名词时,可以使用大小写混合。 + +```ini +# ✅ 正确 - 专有名词 +[Desktop Entry] +Name=Deepin Music # 这是一个专有名词,不能拆开 + +# ⚠️ 注意 +这是一个专有名词,在任何情况下都不可以拆开使用。 +``` + +--- + +## DBus 命名规范 + +详见 [DBus 接口命名规范](./dbus-conventions.md)。 + +### 快速参考 + +| 元素 | 格式 | 示例 | +|------|------|------| +| 服务名 | `org.deepin.DDE1.Component` | `org.deepin.DDE1.Accounts` | +| 对象路径 | `/org/deepin/dde/Component1` | `/org/deepin/dde/accounts` | +| 接口 | `org.deepin.DDE1.Component` | `org.deepin.DDE1.Accounts` | +| 方法 | 大小写混合 | `SetBrightness` | +| 属性 | 大小写混合 | `Volume` | +| 信号 | 大小写混合 | `BrightnessChanged` | + +--- + +## 快速参考 + +| 场景 | deepin 格式 | DDE 格式 | +|------|------------|----------| +| 文档/注释 | 全小写 `deepin` | 全大写 `DDE` | +| 文件名 | 全小写 `deepin` | 全小写 `dde` | +| 宏/常量 | 编码规范优先 | 编码规范优先 | +| 专有名词 | `Deepin Desktop Environment` | `Deepin Desktop Environment` | +| 项目命名 | `deepin-font-manager` | `dde-dock` | + +--- + +## 参考资源 + +- [DBus API Design Guidelines](https://dbus.freedesktop.org/doc/dbus-api-design.html) +- [Filesystem Hierarchy Standard (FHS)](https://refspecs.linuxfoundation.org/FHS_3.0/fhs-3.0.html) diff --git a/.trellis/spec/shared/git-conventions.md b/.trellis/spec/shared/git-conventions.md new file mode 100644 index 00000000..9e6b11e3 --- /dev/null +++ b/.trellis/spec/shared/git-conventions.md @@ -0,0 +1,272 @@ +# Git 约定 + +> DDE 项目的 Git 提交和分支管理约定。 + +--- + +## 概览 + +本指南定义 DDE 项目的 Git 工作流约定,参考现有 DDE 项目规范。 + +--- + +## 提交信息格式 + +### Conventional Commits + +``` +<type>(<scope>): <subject> + +[body] + +[footer] +``` + +### Type 类型 + +| Type | 描述 | 示例 | +|------|------|------| +| `feat` | 新功能 | feat(filemanager): 添加搜索功能 | +| `fix` | Bug 修复 | fix(dialog): 修复对话框内存泄漏 | +| `docs` | 文档更新 | docs(readme): 更新安装说明 | +| `style` | 代码格式 | style(cpplint): 修复格式问题 | +| `refactor` | 重构 | refactor(model): 简化数据模型 | +| `test` | 测试 | test(unit): 添加文件操作测试 | +| `chore` | 构建/工具 | chore(cmake): 更新 Qt6 支持 | + +### Subject 规则 + +- 使用中文描述 +- 不超过 50 字符 +- 句子末尾不加句号 +- 使用祈使语气开头 + +``` +✅ 好: +feat(filemanager): 添加文件重命名功能 +fix(dialog): 修复空指针崩溃 +style(cmake): 格式化代码 + +❌ 不好: +feat(filemanager): 添加文件重命名功能。 // 有句号 +feat(filemanager): 这是一个新功能 // 过于冗长 +feat(filemanager): new feature added // 应该用中文 +``` + +### Body 规则 + +- 描述做了什么以及为什么 +- 每行不超过 72 字符 +- 可以包含多行 + +``` +feat(filemanager): 添加文件重命名功能 + +- 实现右键菜单重命名选项 +- 支持批量重命名 +- 添加重命名验证 + +相关: issue#123 +``` + +--- + +## 分支命名 + +### 功能分支 + +``` +feature/<short-feature-name> + +示例: +feature/file-search +feature/dark-theme +feature/plugin-api +``` + +### 修复分支 + +``` +fix/<short-bug-description> + +示例: +fix/crash-on-window-close +fix/memory-leak-dialog +fix/dbus-connection-fail +``` + +### 发布分支 + +``` +release/<version> + +示例: +release/1.0.0 +release/1.1.0 +``` + +--- + +## 工作流 + +### 功能开发流程 + +``` +1. 从 main 创建功能分支 + git checkout -b feature/feature-name + +2. 开发功能 + +3. 提交代码 + git add . + git commit -m "feat(scope): description" + +4. 推送到远程 + git push origin feature/feature-name + +5. 创建 Merge Request (MR) + +6. 代码审查通过后合并 +``` + +### Bug 修复流程 + +``` +1. 从 main 创建修复分支 + git checkout -b fix/bug-description + +2. 修复 Bug + +3. 提交代码 + git add . + git commit -m "fix(scope): description" + +4. 推送和创建 MR + +5. 审查通过后合并 +``` + +--- + +## Merge Request 模板 + +### 功能 MR + +```markdown +## 功能描述 + +简要描述这个功能。 + +## 变更内容 + +- 添加了 ... +- 修改了 ... +- 删除了 ... + +## 相关 Issue + +关联 Issue: #123 + +## 测试 + +- [ ] 单元测试通过 +- [ ] 手动测试通过 +- [ ] 代码审查通过 + +## 截图 + +<如果适用,添加截图> +``` + +### Bug 修复 MR + +```markdown +## Bug 描述 + +简要描述 Bug。 + +## 复现步骤 + +1. 步骤 1 +2. 步骤 2 +3. ... + +## 修复方案 + +描述修复方案。 + +## 验证 + +- [ ] Bug 已修复 +- [ ] 没有引入新问题 +- [ ] 代码审查通过 +``` + +--- + +## 版本号 + +### 语义化版本 + +``` +MAJOR.MINOR.PATCH + +示例: +1.0.0 - 初始版本 +1.1.0 - 新功能 (minor) +1.1.1 - Bug 修复 (patch) +2.0.0 - 重大变更 (major) +``` + +### 版本规则 + +| 变更类型 | 版本变更 | +|---------|---------| +| 新功能,向后兼容 | MINOR + 1 | +| Bug 修复 | PATCH + 1 | +| 不兼容的 API 变更 | MAJOR + 1 | + +--- + +## 忽略文件 + +### .gitignore + +```gitignore +# 构建产物 +build/ +*.o +*.so +*.a + +# Qt +*.autosave +*.ui.qml +*.pro.user + +# IDE +.vscode/ +.idea/ +*.swp +*~ + +# 临时文件 +tmp/ +temp/ +*.tmp +*.log +``` + +--- + +## 快速参考 + +| 任务 | 命令 | +|------|------| +| 创建功能分支 | `git checkout -b feature/name` | +| 修改提交 | `git commit -m "fix(scope): desc"` | +| 推送分支 | `git push origin feature/name` | +| 拉取最新 | `git pull origin main` | +| 撤销提交 | `git reset HEAD~1` | +| 修改最近提交 | `git commit --amend` | +| 查看历史 | `git log --oneline -10` | diff --git a/.trellis/spec/shared/index.md b/.trellis/spec/shared/index.md new file mode 100644 index 00000000..5b3998d0 --- /dev/null +++ b/.trellis/spec/shared/index.md @@ -0,0 +1,59 @@ +# 共享规范层 + +> 跨层共享的开发规范和约定。 + +--- + +## 规范索引 + +| 文档 | 描述 | 何时阅读 | +|------|------|----------| +| [cpp-conventions.md](cpp-conventions.md) | C++17 编码约定、RAII、智能指针、D-Pointer 命名 | 编写 C++ 代码 | +| [git-conventions.md](git-conventions.md) | Git 提交和分支管理约定 | 提交代码 | +| [internationalization.md](internationalization.md) | 国际化规范、tr() 使用、品牌名称规范 | 添加用户文本 | +| [deepin-terminology.md](deepin-terminology.md) | deepin/DDE 品牌术语规范 | 编写文档/代码 | +| [dbus-conventions.md](dbus-conventions.md) | DBus 接口命名规范 | 使用 DBus | + +--- + +## 开发前检查清单 + +在编写共享代码前: + +- [x] 阅读 [cpp-conventions.md](cpp-conventions.md) 了解 C++17 特性和 RAII +- [ ] 项目是否需要 Qt6/Qt5 双版本支持 +- [ ] 是否涉及国际化 +- [ ] 检查提交信息格式约定 +- [ ] 如果使用 DBus,阅读 [dbus-conventions.md](dbus-conventions.md) +- [ ] 查阅品牌名称规范 [deepin-terminology.md](deepin-terminology.md) + +--- + +## 质量检查 + +共享代码完成后: + +- [ ] C++17 特性正确使用 +- [ ] RAII 资源管理 +- [ ] 智能指针正确使用 +- [ ] D-Pointer 模式命名正确(Private 类不加 m_ 前缀) +- [ ] 文件命名使用小写+下划线 +- [ ] Git 提交信息符合规范 +- [ ] tr() 用于所有面向用户的文本 +- [ ] 代码注释使用英文 +- [ ] 品牌名称使用正确(deepin 小写、DDE 文档大写) + +--- + +## 核心规则摘要 + +| 规则 | 要求 | +|------|------| +| C++ 标准 | C++17 | +| 资源管理 | RAII、smart pointer | +| 文件命名 | 小写+下划线(`my_widget.h`) | +| D-Pointer 命名 | Private 类无前缀、普通类 `m_` 前缀 | +| Git 提交 | conventional commits | +| 国际化 | tr() 用于用户文本 | +| 品牌名称 | deepin 小写、DDE 文档大写 | +| 代码注释 | 英文 | diff --git a/.trellis/spec/shared/internationalization.md b/.trellis/spec/shared/internationalization.md new file mode 100644 index 00000000..fdf16ef7 --- /dev/null +++ b/.trellis/spec/shared/internationalization.md @@ -0,0 +1,327 @@ +# 国际化规范 + +> DDE 应用的国际化 (i18n) 规范。 + +--- + +## 概览 + +本指南定义 DDE 应用的国际化标准,包括翻译字符串管理、翻译文件组织和处理流程。 + +--- + +## tr() 调用规则 + +### 基本使用 + +```cpp +// ✅ 推荐: 所有面向用户的文本使用 tr() +button->setText(tr("Save")); +dialog->setTitle(tr("Confirm Action")); +label->setText(tr("Hello World")); + +// ❌ 禁止: 硬编码用户可见文本 +button->setText("保存"); // 不会翻译 +``` + +### tr() 参数 + +```cpp +// 无参数 +tr("Save") + +// 上下文参数(用于消除歧义) +tr("File", "Noun - File to open") +tr("File", "Verb - File a document") + +// 数量参数 +tr("%n file(s)", "", count) // 自动处理单复数 +``` + +### 动态文本 + +```cpp +// ✅ 推荐: 使用参数化字符串 +label->setText(tr("%1 files selected").arg(count)); + +// ❌ 避免字符串拼接(可能导致顺序问题) +label->setText(tr("Files selected: ") + QString::number(count)); + +// ✅ 推荐: 复数形式 +label->setText(tr("%n file(s)", "", count)); +// 英文: "1 file" / "2 files" +// 中文: 指定复数形式 +``` + +--- + +## 上下文前缀 + +### DTK 组件 + +`DWIDGET_USE_NAMESPACE` 宏已定义 `tr()` 为 DTK 命名空间,可以使用 `Dtk::Widget::tr()`。 + +```cpp +#include <DApplication> +DWIDGET_USE_NAMESPACE + +// 等价于 Dtk::Widget::tr() +QString text = tr("Save"); +``` + +--- + +## 翻译文件 + +### 文件命名 + +``` +<appname>_<language>.ts + +示例: +myapp_zh_CN.ts // 简体中文 +myapp_en.ts // 英语 +myapp_ja.ts // 日语 +``` + +### CMake 配置 + +```cmake +# 查找翻译工具 +find_package(Qt6LinguistTools REQUIRED) + +# 添加翻译文件 +set(TS_FILES + translations/myapp_zh_CN.ts + translations/myapp_en.ts +) + +# 创建翻译目标 +qt_add_translations(myapp + TS_FILES ${TS_FILES} + SOURCE_TARGETS myapp +) + +# 更新翻译命令 +add_custom_target(update_ts + COMMAND ${Qt6_LUPDATE_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/src + -ts ${TS_FILES} + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + COMMENT "Updating translation files..." +) + +# 创建翻译命令 +add_custom_target(l_release + COMMAND ${Qt6_LRELEASE_EXECUTABLE} + ${TS_FILES} + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + COMMENT "Creating translation files..." +) +``` + +--- + +## 翻译组织 + +### 按模块组织 + +``` +translations/ +├── core/ +│ ├── core_zh_CN.ts +│ └── core_en.ts +├── widgets/ +│ ├── widgets_zh_CN.ts +│ └── widgets_en.ts +└── plugin/ + ├── plugin_zh_CN.ts + └── plugin_en.ts +``` + +### 加载翻译 + +```cpp +int main(int argc, char *argv[]) { + DApplication a(argc, argv); + + // 加载翻译 + a.loadTranslator(); + + // 或加载特定翻译 + QTranslator translator; + if (translator.load(":/translations/myapp_zh_CN")) { + a.installTranslator(&translator); + } + + // ... +} +``` + +--- + +## 翻译字符串规则 + +### DO + +- **面向用户的文本** 使用 `tr()` +- **菜单项** 使用 `tr()` +- **对话框** 使用 `tr()` +- **错误消息** 使用 `tr()` +- **按钮文本** 使用 `tr()` + +### DON'T + +```cpp +// ❌ 不要翻译的内容 +// 1. 代码注释 +// Initialize the widget +m_widget = new DWidget(this); + +// 2. 日志 +qDebug() << "Connection failed"; // 日志可以用英文 + +// 3. 技术术语(如果行业通用) +USB, RGB, API, JSON + +// 4. 只在开发环境显示的内容 +DEBUG: "Connection established" +``` + +--- + +## 特殊字符处理 + +### 格式化字符串 + +```cpp +// ✅ 推荐: 使用 %1 占位符 +tr("File: %1").arg(filename); + +// ✅ 推荐: HTML 实体 +tr("Save & Close"); + +// ✅ 推荐: 避免在文本中嵌入变量 +tr("The operation failed: %1").arg(error); +// 而不是 +tr("The operation failed: ") + error; +``` + +### 快捷键 + +```cpp +// ✅ 推荐: 使用 & 标记快捷键 +menu->addAction(tr("&File")); +menu->addAction(tr("&Edit")); +menu->addAction(tr("&View")); + +// 快捷键会显示为带下划线 +``` + +--- + +## 翻译清单 + +### 翻译前 + +- [ ] 所有 `tr()` 调用已添加 +- [ ] 复数形式正确处理 +- [ ] 参数化字符串使用 `%1` 占位符 +- [ ] 上下文需要的地方添加了注释 + +### 翻译后 + +- [ ] 翻译文本符合文化习惯 +- [ ] 变量占位符完整保留 +- [ ] 字符串结束符 `\n` 正确处理 +- [ ] 标签 XML/HTML 正确处理 + +--- + +## 代码注释 + +### 用于翻译上下文 + +```cpp +// Translators: "File" as noun (document), not verb (action) +tr("File"); + +// Translators: Context for ambiguous terms +tr("Server", "Computer that provides services"); +tr("Server", "To serve clients"); + +// Translators: Explain placeholders +// %1 is the number of files +tr("%1 file(s)", "", count); +``` + +--- + +## 品牌名称规范 + +> 详见 [deepin/DDE 品牌术语规范](./deepin-terminology.md)。 + +### deepin 品牌名称 + +**deepin** 品牌名称在任何文档、图片、代码注释中都应使用**全小写**,即使是段落首字母。 + +```cpp +// ✅ 正确 - 文档和注释 +// deepin 桌面环境 +// deepin 开源项目 + +// ✅ 正确 - 版权信息 +// Copyright (c) 2021. deepin All rights reserved. + +// ❌ 错误 +// Deepin 桌面环境 +// DEEPIN 项目 +``` + +### DDE 品牌名称 + +**DDE** 是 Deepin Desktop Environment 的缩写。 + +- **文档中**:使用全大写 `DDE` +- **文件名中**:使用全小写 `dde` + +```cpp +// ✅ 正确 - 文档中 +DDE 桌面环境 (Deepin Desktop Environment) +DDE 启动器、DDE 任务栏 + +// ✅ 正确 - 文件名中 +dde-dock +dde-launcher +org.deepin.DDE1.Accounts + +// ❌ 错误 +dde 桌面环境 // 文档中不应该小写 +DDE-System-Daemon // 文件名不应该大写 +``` + +### 翻译中的品牌名称 + +```cpp +// ✅ 正确 - 品牌名称不应翻译 +tr("deepin desktop environment") +tr("DDE Settings") +tr("deepin Music") + +// ❌ 错误 - 品牌名称不应翻译 +tr("深度桌面环境") // 应该使用英文品牌名 +``` + +--- + +## 快速参考 + +| 任务 | 方法 | +|------|------| +| 用户可见文本 | `tr("Text")` | +| 上下文 | `tr("Text", "Context")` | +| 复数形式 | `tr("%n item(s)", "", count)` | +| 参数 | `tr("Text %1").arg(value)` | +| 创建 TS 文件 | `lupdate src -ts app_zh_CN.ts` | +| 创建 QM 文件 | `lrelease app_zh_CN.ts` | +| 加载翻译 | `a.loadTranslator()` | diff --git a/.trellis/tasks/00-bootstrap-guidelines/prd.md b/.trellis/tasks/00-bootstrap-guidelines/prd.md new file mode 100644 index 00000000..8f0976d9 --- /dev/null +++ b/.trellis/tasks/00-bootstrap-guidelines/prd.md @@ -0,0 +1,127 @@ +# Bootstrap: Fill Project Development Guidelines + +## 目的 + +为 util-dfm 项目创建库特定的开发规范。由于项目包含多个独立库(dfm-io、dfm-mount、dfm-search、dfm-burn),每个库有不同的技术栈和编码约定,需要创建特定于每个库的规范文档。 + +--- + +## 已完成工作 + +### 1. 创建库特定规范目录 + +``` +.trellis/spec/ +├── dfm-io/ # dfm-io 库规范 +├── dfm-mount/ # dfm-mount 库规范 +├── dfm-search/ # dfm-search 库规范 +└── dfm-burn/ # dfm-burn 库规范 +``` + +### 2. 创建的规范文档 + +每个库创建了以下关键文档: + +#### dfm-io (GIO/GLib + Qt) +- `index.md` - 规范索引 +- `error-handling.md` - GError + DFMIOErrorCode 错误处理 +- `naming-conventions.md` - 类名、方法名、变量名约定 + +#### dfm-mount (UDisks2 + GDBus + GIO) +- `index.md` - 规范索引 +- `error-handling.md` - UDisks2/GIO/GDBus 错误转换 +- `naming-conventions.md` - 类名、方法名、变量名约定 + +#### dfm-search (Lucene++ + Qt + std::error_code) +- `index.md` - 规范索引 +- `error-handling.md` - std::error_code + ErrorCategory 模式 +- `naming-conventions.md` - m_ 前缀成员变量命名 + +#### dfm-burn (xorriso + Qt) +- `index.md` - 规范索引 +- `error-handling.md` - lastError() 模式 +- `naming-conventions.md` - 类名、方法名约定 + +### 3. 更新主 README + +更新 `.trellis/spec/README.md`,明确说明: +- 四个库的不同技术栈 +- 每个库的错误处理方式 +- 开发前必须选择正确的库规范 + +--- + +## 核心要点 + +### 差异总结 + +| 方面 | dfm-io | dfm-mount | dfm-search | dfm-burn | +|------|--------|-----------|------------|----------| +| **错误处理** | GError + DFMIOErrorCode | DeviceError (多来源转换) | std::error_code | lastError() | +| **智能指针** | QSharedDataPointer, QSharedPointer | UDisksX_autoptr | std::unique_ptr | QScopedPointer, QSharedData | +| **成员变量** | d/q 指针模式 | d/q 指针模式 | m_ 前缀 | 小驼峰 | +| **GLib 集成** | 大量使用 g_autoptr | UDisks2 autoptr | 无 | 无 | +| **线程模型** | GIO 异步回调 | GDBus 异步回调 | QThread + 信号槽 | DirectConnection | + +### 共同规则 + +1. **禁用 signals/slots 关键字**:所有库使用 `Q_SIGNALS`/`Q_SLOTS` 宏 +2. **Qt5/Qt6 双版本支持** +3. **国际化**:用户文本使用 `tr()` + +--- + +## 待完成 + +以下文档可以后续根据需要补充: + +### dfm-io +- `memory-management.md` - P-impl 模式、智能指针、GLib 对象管理 +- `code-patterns.md` - 信号槽、回调、文件组织 +- `signals-and-callbacks.md` - Q_SIGNALS、GIO 信号桥接 + +### dfm-mount +- `memory-management.md` - GLib autoptr、UDisksX_autoptr +- `code-patterns.md` - DBus 集成、GIO 信号桥接 +- `dbus-integration.md` - UDisks2 调用模式 + +### dfm-search +- `memory-management.md` - std::unique_ptr、PIMPL +- `code-patterns.md` - 策略模式、线程模型 +- `signal-threading.md` - 线程间信号通信 + +### dfm-burn +- `memory-management.md` - QScopedPointer、QSharedData +- `code-patterns.md` - 信号连接、DirectConnection + +--- + +## 完成检查清单 + +- [x] 创建库特定规范目录 +- [x] 为每个库创建 index.md +- [x] 为每个库创建 error-handling.md +- [x] 为每个库创建 naming-conventions.md +- [x] 更新主 README 说明库差异 +- [ ] 额外的内存管理文档(可选) +- [ ] 额外的代码模式文档(可选) + +--- + +## 使用方式 + +开发前,根据目标库阅读相应规范: + +```bash +# 开发 dfm-io +cat .trellis/spec/dfm-io/index.md + +# 开发 dfm-mount +cat .trellis/spec/dfm-mount/index.md + +# 开发 dfm-search +cat .trellis/spec/dfm-search/index.md + +# 开发 dfm-burn +cat .trellis/spec/dfm-burn/index.md +``` diff --git a/.trellis/tasks/00-bootstrap-guidelines/task.json b/.trellis/tasks/00-bootstrap-guidelines/task.json new file mode 100644 index 00000000..abce04ee --- /dev/null +++ b/.trellis/tasks/00-bootstrap-guidelines/task.json @@ -0,0 +1,35 @@ +{ + "id": "00-bootstrap-guidelines", + "name": "Bootstrap Guidelines", + "description": "Fill in project development guidelines for AI agents", + "status": "in_progress", + "dev_type": "docs", + "priority": "P1", + "creator": "Zhang Sheng", + "assignee": "Zhang Sheng", + "createdAt": "2026-03-31", + "completedAt": null, + "commit": null, + "subtasks": [ + { + "name": "Fill backend guidelines", + "status": "pending" + }, + { + "name": "Fill frontend guidelines", + "status": "pending" + }, + { + "name": "Add code examples", + "status": "pending" + } + ], + "children": [], + "parent": null, + "relatedFiles": [ + ".trellis/spec/backend/", + ".trellis/spec/frontend/" + ], + "notes": "First-time setup task created by trellis init (fullstack project)", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/workflow.md b/.trellis/workflow.md new file mode 100644 index 00000000..d1fe61ea --- /dev/null +++ b/.trellis/workflow.md @@ -0,0 +1,416 @@ +# Development Workflow + +> Based on [Effective Harnesses for Long-Running Agents](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents) + +--- + +## Table of Contents + +1. [Quick Start (Do This First)](#quick-start-do-this-first) +2. [Workflow Overview](#workflow-overview) +3. [Session Start Process](#session-start-process) +4. [Development Process](#development-process) +5. [Session End](#session-end) +6. [File Descriptions](#file-descriptions) +7. [Best Practices](#best-practices) + +--- + +## Quick Start (Do This First) + +### Step 0: Initialize Developer Identity (First Time Only) + +> **Multi-developer support**: Each developer/Agent needs to initialize their identity first + +```bash +# Check if already initialized +python3 ./.trellis/scripts/get_developer.py + +# If not initialized, run: +python3 ./.trellis/scripts/init_developer.py <your-name> +# Example: python3 ./.trellis/scripts/init_developer.py cursor-agent +``` + +This creates: +- `.trellis/.developer` - Your identity file (gitignored, not committed) +- `.trellis/workspace/<your-name>/` - Your personal workspace directory + +**Naming suggestions**: +- Human developers: Use your name, e.g., `john-doe` +- Cursor AI: `cursor-agent` or `cursor-<task>` +- Claude Code: `claude-agent` or `claude-<task>` +- iFlow cli: `iflow-agent` or `iflow-<task>` + +### Step 1: Understand Current Context + +```bash +# Get full context in one command +python3 ./.trellis/scripts/get_context.py + +# Or check manually: +python3 ./.trellis/scripts/get_developer.py # Your identity +python3 ./.trellis/scripts/task.py list # Active tasks +git status && git log --oneline -10 # Git state +``` + +### Step 2: Read Project Guidelines [MANDATORY] + +**CRITICAL**: Read guidelines before writing any code: + +```bash +# Read frontend guidelines index (if applicable) +cat .trellis/spec/frontend/index.md + +# Read backend guidelines index (if applicable) +cat .trellis/spec/backend/index.md +``` + +**Why read both?** +- Understand the full project architecture +- Know coding standards for the entire codebase +- See how frontend and backend interact +- Learn the overall code quality requirements + +### Step 3: Before Coding - Read Specific Guidelines (Required) + +Based on your task, read the **detailed** guidelines: + +**Frontend Task**: +```bash +cat .trellis/spec/frontend/hook-guidelines.md # For hooks +cat .trellis/spec/frontend/component-guidelines.md # For components +cat .trellis/spec/frontend/type-safety.md # For types +``` + +**Backend Task**: +```bash +cat .trellis/spec/backend/database-guidelines.md # For DB operations +cat .trellis/spec/backend/type-safety.md # For types +cat .trellis/spec/backend/logging-guidelines.md # For logging +``` + +--- + +## Workflow Overview + +### Core Principles + +1. **Read Before Write** - Understand context before starting +2. **Follow Standards** - [!] **MUST read `.trellis/spec/` guidelines before coding** +3. **Incremental Development** - Complete one task at a time +4. **Record Promptly** - Update tracking files immediately after completion +5. **Document Limits** - [!] **Max 2000 lines per journal document** + +### File System + +``` +.trellis/ +|-- .developer # Developer identity (gitignored) +|-- scripts/ +| |-- __init__.py # Python package init +| |-- common/ # Shared utilities (Python) +| | |-- __init__.py +| | |-- paths.py # Path utilities +| | |-- developer.py # Developer management +| | +-- git_context.py # Git context implementation +| |-- multi_agent/ # Multi-agent pipeline scripts +| | |-- __init__.py +| | |-- start.py # Start worktree agent +| | |-- status.py # Monitor agent status +| | |-- create_pr.py # Create PR +| | +-- cleanup.py # Cleanup worktree +| |-- init_developer.py # Initialize developer identity +| |-- get_developer.py # Get current developer name +| |-- task.py # Manage tasks +| |-- get_context.py # Get session context +| +-- add_session.py # One-click session recording +|-- workspace/ # Developer workspaces +| |-- index.md # Workspace index + Session template +| +-- {developer}/ # Per-developer directories +| |-- index.md # Personal index (with @@@auto markers) +| +-- journal-N.md # Journal files (sequential numbering) +|-- tasks/ # Task tracking +| +-- {MM}-{DD}-{name}/ +| +-- task.json +|-- spec/ # [!] MUST READ before coding +| |-- frontend/ # Frontend guidelines (if applicable) +| | |-- index.md # Start here - guidelines index +| | +-- *.md # Topic-specific docs +| |-- backend/ # Backend guidelines (if applicable) +| | |-- index.md # Start here - guidelines index +| | +-- *.md # Topic-specific docs +| +-- guides/ # Thinking guides +| |-- index.md # Guides index +| |-- cross-layer-thinking-guide.md # Pre-implementation checklist +| +-- *.md # Other guides ++-- workflow.md # This document +``` + +--- + +## Session Start Process + +### Step 1: Get Session Context + +Use the unified context script: + +```bash +# Get all context in one command +python3 ./.trellis/scripts/get_context.py + +# Or get JSON format +python3 ./.trellis/scripts/get_context.py --json +``` + +### Step 2: Read Development Guidelines [!] REQUIRED + +**[!] CRITICAL: MUST read guidelines before writing any code** + +Based on what you'll develop, read the corresponding guidelines: + +**Frontend Development** (if applicable): +```bash +# Read index first, then specific docs based on task +cat .trellis/spec/frontend/index.md +``` + +**Backend Development** (if applicable): +```bash +# Read index first, then specific docs based on task +cat .trellis/spec/backend/index.md +``` + +**Cross-Layer Features**: +```bash +# For features spanning multiple layers +cat .trellis/spec/guides/cross-layer-thinking-guide.md +``` + +### Step 3: Select Task to Develop + +Use the task management script: + +```bash +# List active tasks +python3 ./.trellis/scripts/task.py list + +# Create new task (creates directory with task.json) +python3 ./.trellis/scripts/task.py create "<title>" --slug <task-name> +``` + +--- + +## Development Process + +### Task Development Flow + +``` +1. Create or select task + --> python3 ./.trellis/scripts/task.py create "<title>" --slug <name> or list + +2. Write code according to guidelines + --> Read .trellis/spec/ docs relevant to your task + --> For cross-layer: read .trellis/spec/guides/ + +3. Self-test + --> Run project's lint/test commands (see spec docs) + --> Manual feature testing + +4. Commit code + --> git add <files> + --> git commit -m "type(scope): description" + Format: feat/fix/docs/refactor/test/chore + +5. Record session (one command) + --> python3 ./.trellis/scripts/add_session.py --title "Title" --commit "hash" +``` + +### Code Quality Checklist + +**Must pass before commit**: +- [OK] Lint checks pass (project-specific command) +- [OK] Type checks pass (if applicable) +- [OK] Manual feature testing passes + +**Project-specific checks**: +- See `.trellis/spec/frontend/quality-guidelines.md` for frontend +- See `.trellis/spec/backend/quality-guidelines.md` for backend + +--- + +## Session End + +### One-Click Session Recording + +After code is committed, use: + +```bash +python3 ./.trellis/scripts/add_session.py \ + --title "Session Title" \ + --commit "abc1234" \ + --summary "Brief summary" +``` + +This automatically: +1. Detects current journal file +2. Creates new file if 2000-line limit exceeded +3. Appends session content +4. Updates index.md (sessions count, history table) + +### Pre-end Checklist + +Use `/trellis:finish-work` command to run through: +1. [OK] All code committed, commit message follows convention +2. [OK] Session recorded via `add_session.py` +3. [OK] No lint/test errors +4. [OK] Working directory clean (or WIP noted) +5. [OK] Spec docs updated if needed + +--- + +## File Descriptions + +### 1. workspace/ - Developer Workspaces + +**Purpose**: Record each AI Agent session's work content + +**Structure** (Multi-developer support): +``` +workspace/ +|-- index.md # Main index (Active Developers table) ++-- {developer}/ # Per-developer directory + |-- index.md # Personal index (with @@@auto markers) + +-- journal-N.md # Journal files (sequential: 1, 2, 3...) +``` + +**When to update**: +- [OK] End of each session +- [OK] Complete important task +- [OK] Fix important bug + +### 2. spec/ - Development Guidelines + +**Purpose**: Documented standards for consistent development + +**Structure** (Multi-doc format): +``` +spec/ +|-- frontend/ # Frontend docs (if applicable) +| |-- index.md # Start here +| +-- *.md # Topic-specific docs +|-- backend/ # Backend docs (if applicable) +| |-- index.md # Start here +| +-- *.md # Topic-specific docs ++-- guides/ # Thinking guides + |-- index.md # Start here + +-- *.md # Guide-specific docs +``` + +**When to update**: +- [OK] New pattern discovered +- [OK] Bug fixed that reveals missing guidance +- [OK] New convention established + +### 3. Tasks - Task Tracking + +Each task is a directory containing `task.json`: + +``` +tasks/ +|-- 01-21-my-task/ +| +-- task.json ++-- archive/ + +-- 2026-01/ + +-- 01-15-old-task/ + +-- task.json +``` + +**Commands**: +```bash +python3 ./.trellis/scripts/task.py create "<title>" [--slug <name>] # Create task directory +python3 ./.trellis/scripts/task.py archive <name> # Archive to archive/{year-month}/ +python3 ./.trellis/scripts/task.py list # List active tasks +python3 ./.trellis/scripts/task.py list-archive # List archived tasks +``` + +--- + +## Best Practices + +### [OK] DO - Should Do + +1. **Before session start**: + - Run `python3 ./.trellis/scripts/get_context.py` for full context + - [!] **MUST read** relevant `.trellis/spec/` docs + +2. **During development**: + - [!] **Follow** `.trellis/spec/` guidelines + - For cross-layer features, use `/trellis:check-cross-layer` + - Develop only one task at a time + - Run lint and tests frequently + +3. **After development complete**: + - Use `/trellis:finish-work` for completion checklist + - After fix bug, use `/trellis:break-loop` for deep analysis + - Human commits after testing passes + - Use `add_session.py` to record progress + +### [X] DON'T - Should Not Do + +1. [!] **Don't** skip reading `.trellis/spec/` guidelines +2. [!] **Don't** let journal single file exceed 2000 lines +3. **Don't** develop multiple unrelated tasks simultaneously +4. **Don't** commit code with lint/test errors +5. **Don't** forget to update spec docs after learning something +6. [!] **Don't** execute `git commit` - AI should not commit code + +--- + +## Quick Reference + +### Must-read Before Development + +| Task Type | Must-read Document | +|-----------|-------------------| +| Frontend work | `frontend/index.md` → relevant docs | +| Backend work | `backend/index.md` → relevant docs | +| Cross-Layer Feature | `guides/cross-layer-thinking-guide.md` | + +### Commit Convention + +```bash +git commit -m "type(scope): description" +``` + +**Type**: feat, fix, docs, refactor, test, chore +**Scope**: Module name (e.g., auth, api, ui) + +### Common Commands + +```bash +# Session management +python3 ./.trellis/scripts/get_context.py # Get full context +python3 ./.trellis/scripts/add_session.py # Record session + +# Task management +python3 ./.trellis/scripts/task.py list # List tasks +python3 ./.trellis/scripts/task.py create "<title>" # Create task + +# Slash commands +/trellis:finish-work # Pre-commit checklist +/trellis:break-loop # Post-debug analysis +/trellis:check-cross-layer # Cross-layer verification +``` + +--- + +## Summary + +Following this workflow ensures: +- [OK] Continuity across multiple sessions +- [OK] Consistent code quality +- [OK] Trackable progress +- [OK] Knowledge accumulation in spec docs +- [OK] Transparent team collaboration + +**Core Philosophy**: Read before write, follow standards, record promptly, capture learnings diff --git a/.trellis/workspace/Zhang Sheng/index.md b/.trellis/workspace/Zhang Sheng/index.md new file mode 100644 index 00000000..e652b84d --- /dev/null +++ b/.trellis/workspace/Zhang Sheng/index.md @@ -0,0 +1,40 @@ +# Workspace Index - Zhang Sheng + +> Journal tracking for AI development sessions. + +--- + +## Current Status + +<!-- @@@auto:current-status --> +- **Active File**: `journal-1.md` +- **Total Sessions**: 0 +- **Last Active**: - +<!-- @@@/auto:current-status --> + +--- + +## Active Documents + +<!-- @@@auto:active-documents --> +| File | Lines | Status | +|------|-------|--------| +| `journal-1.md` | ~0 | Active | +<!-- @@@/auto:active-documents --> + +--- + +## Session History + +<!-- @@@auto:session-history --> +| # | Date | Title | Commits | +|---|------|-------|---------| +<!-- @@@/auto:session-history --> + +--- + +## Notes + +- Sessions are appended to journal files +- New journal file created when current exceeds 2000 lines +- Use `add_session.py` to record sessions diff --git a/.trellis/workspace/Zhang Sheng/journal-1.md b/.trellis/workspace/Zhang Sheng/journal-1.md new file mode 100644 index 00000000..e5f843bf --- /dev/null +++ b/.trellis/workspace/Zhang Sheng/journal-1.md @@ -0,0 +1,7 @@ +# Journal - Zhang Sheng (Part 1) + +> AI development session journal +> Started: 2026-03-31 + +--- + diff --git a/.trellis/workspace/index.md b/.trellis/workspace/index.md new file mode 100644 index 00000000..427947fc --- /dev/null +++ b/.trellis/workspace/index.md @@ -0,0 +1,123 @@ +# Workspace Index + +> Records of all AI Agent work records across all developers + +--- + +## Overview + +This directory tracks records for all developers working with AI Agents on this project. + +### File Structure + +``` +workspace/ +|-- index.md # This file - main index ++-- {developer}/ # Per-developer directory + |-- index.md # Personal index with session history + |-- tasks/ # Task files + | |-- *.json # Active tasks + | +-- archive/ # Archived tasks by month + +-- journal-N.md # Journal files (sequential: 1, 2, 3...) +``` + +--- + +## Active Developers + +| Developer | Last Active | Sessions | Active File | +|-----------|-------------|----------|-------------| +| (none yet) | - | - | - | + +--- + +## Getting Started + +### For New Developers + +Run the initialization script: + +```bash +python3 ./.trellis/scripts/init_developer.py <your-name> +``` + +This will: +1. Create your identity file (gitignored) +2. Create your progress directory +3. Create your personal index +4. Create initial journal file + +### For Returning Developers + +1. Get your developer name: + ```bash + python3 ./.trellis/scripts/get_developer.py + ``` + +2. Read your personal index: + ```bash + cat .trellis/workspace/$(python3 ./.trellis/scripts/get_developer.py)/index.md + ``` + +--- + +## Guidelines + +### Journal File Rules + +- **Max 2000 lines** per journal file +- When limit is reached, create `journal-{N+1}.md` +- Update your personal `index.md` when creating new files + +### Session Record Format + +Each session should include: +- Summary: One-line description +- Main Changes: What was modified +- Git Commits: Commit hashes and messages +- Next Steps: What to do next + +--- + +## Session Template + +Use this template when recording sessions: + +```markdown +## Session {N}: {Title} + +**Date**: YYYY-MM-DD +**Task**: {task-name} + +### Summary + +{One-line summary} + +### Main Changes + +- {Change 1} +- {Change 2} + +### Git Commits + +| Hash | Message | +|------|---------| +| `abc1234` | {commit message} | + +### Testing + +- [OK] {Test result} + +### Status + +[OK] **Completed** / # **In Progress** / [P] **Blocked** + +### Next Steps + +- {Next step 1} +- {Next step 2} +``` + +--- + +**Language**: All documentation must be written in **English**. diff --git a/.trellis/worktree.yaml b/.trellis/worktree.yaml new file mode 100644 index 00000000..26485608 --- /dev/null +++ b/.trellis/worktree.yaml @@ -0,0 +1,47 @@ +# Worktree Configuration for Multi-Agent Pipeline +# Used for worktree initialization in multi-agent workflows +# +# All paths are relative to project root + +#------------------------------------------------------------------------------- +# Paths +#------------------------------------------------------------------------------- + +# Worktree storage directory (relative to project root) +worktree_dir: ../trellis-worktrees + +#------------------------------------------------------------------------------- +# Files to Copy +#------------------------------------------------------------------------------- + +# Files to copy to each worktree (each worktree needs independent copy) +# These files contain sensitive info or need worktree-independent config +copy: + # Environment variables (uncomment and customize as needed) + # - .env + # - .env.local + # Workflow config + - .trellis/.developer + +#------------------------------------------------------------------------------- +# Post-Create Hooks +#------------------------------------------------------------------------------- + +# Commands to run after creating worktree +# Executed in worktree directory, in order, abort on failure +post_create: + # Install dependencies (uncomment based on your package manager) + # - npm install + # - pnpm install --frozen-lockfile + # - yarn install --frozen-lockfile + +#------------------------------------------------------------------------------- +# Check Agent Verification (Ralph Loop) +#------------------------------------------------------------------------------- + +# Commands to verify code quality before allowing check agent to finish +# If configured, Ralph Loop will run these commands - all must pass to allow completion +# If not configured or empty, trusts agent's completion markers +verify: + # - pnpm lint + # - pnpm typecheck diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..fdf6ff50 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +<!-- TRELLIS:START --> +# Trellis Instructions + +These instructions are for AI assistants working in this project. + +Use the `/trellis:start` command when starting a new session to: +- Initialize your developer identity +- Understand current project context +- Read relevant guidelines + +Use `@/.trellis/` to learn: +- Development workflow (`workflow.md`) +- Project structure guidelines (`spec/`) +- Developer workspace (`workspace/`) + +Keep this managed block so 'trellis update' can refresh the instructions. + +<!-- TRELLIS:END --> From d27857ffa57c7ac9c4b079b89598f44f8874f6e5 Mon Sep 17 00:00:00 2001 From: Zhang Sheng <zhangsheng@uniontech.com> Date: Tue, 31 Mar 2026 15:28:06 +0800 Subject: [PATCH 02/21] chore(task): archive 00-bootstrap-guidelines --- .../{ => archive/2026-03}/00-bootstrap-guidelines/prd.md | 0 .../{ => archive/2026-03}/00-bootstrap-guidelines/task.json | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) rename .trellis/tasks/{ => archive/2026-03}/00-bootstrap-guidelines/prd.md (100%) rename .trellis/tasks/{ => archive/2026-03}/00-bootstrap-guidelines/task.json (93%) diff --git a/.trellis/tasks/00-bootstrap-guidelines/prd.md b/.trellis/tasks/archive/2026-03/00-bootstrap-guidelines/prd.md similarity index 100% rename from .trellis/tasks/00-bootstrap-guidelines/prd.md rename to .trellis/tasks/archive/2026-03/00-bootstrap-guidelines/prd.md diff --git a/.trellis/tasks/00-bootstrap-guidelines/task.json b/.trellis/tasks/archive/2026-03/00-bootstrap-guidelines/task.json similarity index 93% rename from .trellis/tasks/00-bootstrap-guidelines/task.json rename to .trellis/tasks/archive/2026-03/00-bootstrap-guidelines/task.json index abce04ee..6abed256 100644 --- a/.trellis/tasks/00-bootstrap-guidelines/task.json +++ b/.trellis/tasks/archive/2026-03/00-bootstrap-guidelines/task.json @@ -2,13 +2,13 @@ "id": "00-bootstrap-guidelines", "name": "Bootstrap Guidelines", "description": "Fill in project development guidelines for AI agents", - "status": "in_progress", + "status": "completed", "dev_type": "docs", "priority": "P1", "creator": "Zhang Sheng", "assignee": "Zhang Sheng", "createdAt": "2026-03-31", - "completedAt": null, + "completedAt": "2026-03-31", "commit": null, "subtasks": [ { From a901257ac8f5581d07ac45cf1269fa08d7fff94f Mon Sep 17 00:00:00 2001 From: Zhang Sheng <zhangsheng@uniontech.com> Date: Tue, 31 Mar 2026 15:28:51 +0800 Subject: [PATCH 03/21] chore: record journal as title Log: --- .trellis/workspace/Zhang Sheng/index.md | 9 +- .trellis/workspace/Zhang Sheng/journal-1.md | 101 ++++++++++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/.trellis/workspace/Zhang Sheng/index.md b/.trellis/workspace/Zhang Sheng/index.md index e652b84d..bc2ff651 100644 --- a/.trellis/workspace/Zhang Sheng/index.md +++ b/.trellis/workspace/Zhang Sheng/index.md @@ -8,8 +8,8 @@ <!-- @@@auto:current-status --> - **Active File**: `journal-1.md` -- **Total Sessions**: 0 -- **Last Active**: - +- **Total Sessions**: 1 +- **Last Active**: 2026-03-31 <!-- @@@/auto:current-status --> --- @@ -19,7 +19,7 @@ <!-- @@@auto:active-documents --> | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~0 | Active | +| `journal-1.md` | ~108 | Active | <!-- @@@/auto:active-documents --> --- @@ -29,6 +29,7 @@ <!-- @@@auto:session-history --> | # | Date | Title | Commits | |---|------|-------|---------| +| 1 | 2026-03-31 | Bootstrap: Create Library-Specific Guidelines | `d846cbc` | <!-- @@@/auto:session-history --> --- @@ -37,4 +38,4 @@ - Sessions are appended to journal files - New journal file created when current exceeds 2000 lines -- Use `add_session.py` to record sessions +- Use `add_session.py` to record sessions \ No newline at end of file diff --git a/.trellis/workspace/Zhang Sheng/journal-1.md b/.trellis/workspace/Zhang Sheng/journal-1.md index e5f843bf..2786824e 100644 --- a/.trellis/workspace/Zhang Sheng/journal-1.md +++ b/.trellis/workspace/Zhang Sheng/journal-1.md @@ -5,3 +5,104 @@ --- + + +## Session 1: Bootstrap: Create Library-Specific Guidelines + +**Date**: 2026-03-31 +**Task**: Bootstrap: Create Library-Specific Guidelines + +### Summary + +(Add summary) + +### Main Changes + +# Bootstrap Guidelines - 创建库特定开发规范 + +## 任务目标 +为 util-dfm 项目创建库特定的开发规范,替换通用的 DDE 桌面应用规范。 + +## 为什么需要库特定规范 +util-dfm 包含 4 个独立库,每个库有不同的技术栈和编码约定: +- **dfm-io**: GIO/GLib + Qt +- **dfm-mount**: UDisks2 + GDBus + GIO +- **dfm-search**: Lucene++ + Qt + std::error_code +- **dfm-burn**: xorriso + Qt + +## 完成的工作 + +### 1. 创建库特定规范目录 +``` +.trellis/spec/ +├── dfm-io/ # dfm-io 库规范 +├── dfm-mount/ # dfm-mount 库规范 +├── dfm-search/ # dfm-search 库规范 +└── dfm-burn/ # dfm-burn 库规范 +``` + +### 2. 为每个库创建规范文档 + +#### dfm-io (GIO/GLib + Qt) +- `index.md` - 规范索引 +- `error-handling.md` - GError 转换、DFMIOErrorCode 使用 +- `naming-conventions.md` - D 前缀类名、k 前缀枚举、d/q 指针模式 + +#### dfm-mount (UDisks2 + GDBus + GIO) +- `index.md` - 规范索引 +- `error-handling.md` - DeviceError 多来源转换、GError 处理 +- `naming-conventions.md` - d/q 指针模式、static 回调函数 + +#### dfm-search (Lucene++ + Qt + std::error_code) +- `index.md` - 规范索引 +- `error-handling.md` - std::error_code + ErrorCategory 模式 +- `naming-conventions.md` - m_ 前缀成员变量、策略模式命名 + +#### dfm-burn (xorriso + Qt) +- `index.md` - 规范索引 +- `error-handling.md` - lastError() + errorMsg 字符串模式 +- `naming-conventions.md` - is/has 布尔前缀、小驼峰成员变量 + +### 3. 更新主 README +更新 `.trellis/spec/README.md`,明确说明: +- 四个库的不同技术栈 +- 每个库的错误处理方式 +- 开发前必须选择正确的库规范 + +## 关键差异总结 + +| 方面 | dfm-io | dfm-mount | dfm-search | dfm-burn | +|------|--------|-----------|------------|----------| +| **错误处理** | GError / DFMIOErrorCode | DeviceError 转换 | std::error_code | lastError() 字符串 | +| **智能指针** | QSharedDataPointer | UDisksX_autoptr | std::unique_ptr | QScopedPointer | +| **成员变量** | d/q 指针 | d/q 指针 | m_ 前缀 | 小驼峰 | +| **GLib 集成** | g_autoptr 大量使用 | UDisks2 autoptr | 无 | 无 | + +## 共同规则 +- 禁用 signals/slots 关键字,使用 Q_SIGNALS/Q_SLOTS 宏 +- Qt5/Qt6 双版本支持 +- 国际化:用户文本使用 tr() + +## 待完成(可选) +- memory-management.md - 详细的内存管理规范 +- code-patterns.md - 策略模式、线程模型等代码模式 +- dbus-integration.md (dfm-mount) - UDisks2 调用模式 + + +### Git Commits + +| Hash | Message | +|------|---------| +| `d846cbc` | (see git log) | + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete From 462de8ef622e95da62ddac7ad9ae65c36dd49647 Mon Sep 17 00:00:00 2001 From: Zhang Sheng <zhangsheng@uniontech.com> Date: Tue, 31 Mar 2026 16:21:17 +0800 Subject: [PATCH 04/21] fix: change content index directory path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed the content index directory from GenericConfigLocation to GenericDataLocation and updated the folder name from "index" to "fulltext-index". This modification ensures that the search index files are stored in the appropriate data directory rather than the configuration directory, which is more suitable for large index files and follows better data management practices. Log: Moved search index storage location to data directory Influence: 1. Verify that search index files are now created in the correct data directory 2. Test that full-text search functionality still works correctly after the directory change 3. Check that old index files are properly migrated or new ones are created 4. Ensure search performance is not affected by the directory change 5. Verify that the application handles the directory transition smoothly fix: 更改内容索引目录路径 将内容索引目录从GenericConfigLocation更改为GenericDataLocation,并将文件 夹名称从"index"更新为"fulltext-index"。此修改确保搜索索引文件存储在适当 的数据目录中,而不是配置目录中,这更适合大型索引文件,并遵循更好的数据管 理实践。 Log: 将搜索索引存储位置移至数据目录 Influence: 1. 验证搜索索引文件现在是否在正确的数据目录中创建 2. 测试目录更改后全文搜索功能是否仍然正常工作 3. 检查旧索引文件是否正确迁移或新索引文件是否创建 4. 确保搜索性能不受目录更改的影响 5. 验证应用程序是否能平稳处理目录转换 --- src/dfm-search/dfm-search-lib/utils/searchutility.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dfm-search/dfm-search-lib/utils/searchutility.cpp b/src/dfm-search/dfm-search-lib/utils/searchutility.cpp index 2c0dbb17..7984c37e 100644 --- a/src/dfm-search/dfm-search-lib/utils/searchutility.cpp +++ b/src/dfm-search/dfm-search-lib/utils/searchutility.cpp @@ -621,9 +621,9 @@ bool isContentIndexAvailable() QString contentIndexDirectory() { - QString configDir = QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation); - QDir deepinDir(configDir); - QString indexPath = deepinDir.filePath("deepin/dde-file-manager/index"); + QString dataDir = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation); + QDir deepinDir(dataDir); + QString indexPath = deepinDir.filePath("deepin/dde-file-manager/fulltext-index"); return indexPath; } From dffb51cd71b79ce9b154eefbf937b1cb4b0627b3 Mon Sep 17 00:00:00 2001 From: Zhang Sheng <zhangsheng@uniontech.com> Date: Tue, 31 Mar 2026 17:09:53 +0800 Subject: [PATCH 05/21] refactor: centralize Lucene field names in constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded Lucene field names with centralized constants from LuceneFieldNames header to improve maintainability and reduce errors. Added new header file lucene_field_names.h containing all field name constants organized by index type (FileName and Content). This change ensures consistency across the codebase and makes field name changes easier to manage. Influence: 1. Verify content search functionality still works correctly 2. Test filename search with various query types 3. Check hidden file filtering in both content and filename searches 4. Validate path prefix query optimization 5. Test pinyin and acronym search functionality 6. Verify detailed search results display correctly refactor: 将 Lucene 字段名集中管理为常量 使用 LuceneFieldNames 头文件中的集中式常量替换硬编码的 Lucene 字段名, 提高可维护性并减少错误。新增 lucene_field_names.h 头文件,包含按索引类型 (FileName 和 Content)组织的所有字段名常量。此更改确保代码库的一致性, 并使字段名更改更易于管理。 Influence: 1. 验证内容搜索功能是否正常工作 2. 测试各种查询类型的文件名搜索 3. 检查内容和文件名搜索中的隐藏文件过滤 4. 验证路径前缀查询优化 5. 测试拼音和简拼搜索功能 6. 确认详细搜索结果正确显示 --- .../contentstrategies/indexedstrategy.cpp | 14 ++++--- .../filenamestrategies/indexedstrategy.cpp | 33 ++++++++------- .../dfm-search-lib/utils/lucene_field_names.h | 40 +++++++++++++++++++ 3 files changed, 67 insertions(+), 20 deletions(-) create mode 100644 src/dfm-search/dfm-search-lib/utils/lucene_field_names.h diff --git a/src/dfm-search/dfm-search-lib/contentsearch/contentstrategies/indexedstrategy.cpp b/src/dfm-search/dfm-search-lib/contentsearch/contentstrategies/indexedstrategy.cpp index db59130b..ff5a72ea 100644 --- a/src/dfm-search/dfm-search-lib/contentsearch/contentstrategies/indexedstrategy.cpp +++ b/src/dfm-search/dfm-search-lib/contentsearch/contentstrategies/indexedstrategy.cpp @@ -22,6 +22,7 @@ #include "utils/lucenequeryutils.h" #include "utils/searchutility.h" #include "utils/lucene_cancellation_compat.h" +#include "utils/lucene_field_names.h" using namespace Lucene; @@ -69,7 +70,7 @@ Lucene::QueryPtr ContentIndexedStrategy::buildLuceneQuery(const SearchQuery &que Lucene::QueryParserPtr contentsParser = newLucene<Lucene::QueryParser>( Lucene::LuceneVersion::LUCENE_CURRENT, - L"contents", + LuceneFieldNames::Content::kContents, analyzer); Lucene::QueryPtr mainQuery; @@ -99,7 +100,8 @@ Lucene::QueryPtr ContentIndexedStrategy::buildLuceneQuery(const SearchQuery &que // Add path prefix query optimization if (mainQuery && SearchUtility::isContentIndexAncestorPathsSupported() && SearchUtility::shouldUsePathPrefixQuery(searchPath)) { - QueryPtr pathPrefixQuery = LuceneQueryUtils::buildPathPrefixQuery(searchPath, "ancestor_paths"); + QueryPtr pathPrefixQuery = LuceneQueryUtils::buildPathPrefixQuery(searchPath, + QString::fromWCharArray(LuceneFieldNames::Content::kAncestorPaths)); if (pathPrefixQuery) { BooleanQueryPtr finalQuery = newLucene<BooleanQuery>(); finalQuery->add(mainQuery, BooleanClause::MUST); @@ -126,7 +128,7 @@ QueryPtr ContentIndexedStrategy::buildAdvancedAndQuery(const SearchQuery &query, // It requires its own filenameParser. Lucene::QueryParserPtr filenameParser = newLucene<Lucene::QueryParser>( Lucene::LuceneVersion::LUCENE_CURRENT, - L"filename", + LuceneFieldNames::Content::kFilename, analyzer); Lucene::BooleanQueryPtr overallQuery = newLucene<Lucene::BooleanQuery>(); @@ -259,7 +261,7 @@ void ContentIndexedStrategy::processSearchResults(const Lucene::IndexSearcherPtr // Safely get path Lucene::String pathField; try { - pathField = doc->get(L"path"); + pathField = doc->get(LuceneFieldNames::Content::kPath); if (pathField.empty()) { qWarning() << "Document missing path field at index:" << scoreDoc->doc; continue; @@ -283,7 +285,7 @@ void ContentIndexedStrategy::processSearchResults(const Lucene::IndexSearcherPtr // Safely check hidden status if (Q_LIKELY(!m_options.includeHidden())) { try { - Lucene::String hiddenField = doc->get(L"is_hidden"); + Lucene::String hiddenField = doc->get(LuceneFieldNames::Content::kIsHidden); if (!hiddenField.empty() && QString::fromStdWString(hiddenField).toLower() == "y") { continue; } @@ -303,7 +305,7 @@ void ContentIndexedStrategy::processSearchResults(const Lucene::IndexSearcherPtr if (enableRetrieval) { try { // Safely get contents with null check - Lucene::String contentField = doc->get(L"contents"); + Lucene::String contentField = doc->get(LuceneFieldNames::Content::kContents); if (!contentField.empty()) { const QString content = QString::fromStdWString(contentField); const QString highlightedContent = ContentHighlighter::customHighlight(m_keywords, content, previewLen, enableHTML); diff --git a/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.cpp b/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.cpp index f77441f5..7cd6e5b3 100644 --- a/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.cpp +++ b/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.cpp @@ -5,6 +5,7 @@ #include "utils/cancellablecollector.h" #include "utils/searchutility.h" #include "utils/lucenequeryutils.h" +#include "utils/lucene_field_names.h" #include <unistd.h> #include <sys/types.h> @@ -39,7 +40,7 @@ Lucene::QueryPtr QueryBuilder::buildTypeQuery(const QStringList &types) const QString cleanType = type.trimmed().toLower(); if (!cleanType.isEmpty()) { QueryPtr termQuery = newLucene<TermQuery>( - newLucene<Term>(L"file_type", + newLucene<Term>(LuceneFieldNames::FileName::kFileType, StringUtils::toUnicode(cleanType.toStdString()))); typeQuery->add(termQuery, BooleanClause::SHOULD); } @@ -60,7 +61,7 @@ Lucene::QueryPtr QueryBuilder::buildExtQuery(const QStringList &extensions) cons QString cleanExt = ext.trimmed().toLower(); if (!cleanExt.isEmpty()) { QueryPtr termQuery = newLucene<TermQuery>( - newLucene<Term>(L"file_ext", + newLucene<Term>(LuceneFieldNames::FileName::kFileExt, StringUtils::toUnicode(cleanExt.toStdString()))); extQuery->add(termQuery, BooleanClause::SHOULD); } @@ -81,7 +82,8 @@ Lucene::QueryPtr QueryBuilder::buildPinyinQuery(const QStringList &pinyins, Sear QString cleanPinyin = pinyin.trimmed(); if (!cleanPinyin.isEmpty() && Global::isPinyinSequence(cleanPinyin)) { // 复用buildCommonQuery,指定pinyin字段,让分析器自动处理匹配 - QueryPtr termQuery = buildCommonQuery(cleanPinyin, false, newLucene<ChineseAnalyzer>(), "pinyin", false); + QueryPtr termQuery = buildCommonQuery(cleanPinyin, false, newLucene<ChineseAnalyzer>(), + QString::fromWCharArray(LuceneFieldNames::FileName::kPinyin), false); if (termQuery) { pinyinQuery->add(termQuery, op == SearchQuery::BooleanOperator::AND ? BooleanClause::MUST : BooleanClause::SHOULD); } @@ -103,7 +105,9 @@ Lucene::QueryPtr QueryBuilder::buildPinyinAcronymQuery(const QStringList &acrony QString cleanAcronym = acronym.trimmed(); if (!cleanAcronym.isEmpty()) { // 复用buildCommonQuery,指定pinyin_acronym字段,让分析器自动处理匹配 - QueryPtr termQuery = buildCommonQuery(cleanAcronym, false, newLucene<ChineseAnalyzer>(), "pinyin_acronym", false); + QueryPtr termQuery = buildCommonQuery(cleanAcronym, false, + newLucene<ChineseAnalyzer>(), + QString::fromWCharArray(LuceneFieldNames::FileName::kPinyinAcronym), false); if (termQuery) { acronymQuery->add(termQuery, op == SearchQuery::BooleanOperator::AND ? BooleanClause::MUST : BooleanClause::SHOULD); } @@ -121,7 +125,7 @@ Lucene::QueryPtr QueryBuilder::buildCommonQuery(const QString &keyword, bool cas Lucene::QueryParserPtr parser = newLucene<Lucene::QueryParser>( Lucene::LuceneVersion::LUCENE_CURRENT, - L"file_name", + LuceneFieldNames::FileName::kFileName, analyzer); if (allowWildcard) { @@ -160,12 +164,12 @@ Lucene::QueryPtr QueryBuilder::buildWildcardQuery(const QString &keyword, bool c return nullptr; } - // 对于通配符查询,使用file_name_lower字段(非分词)而非file_name(分词) + // 对于通配符查询,使用 file_name_lower 字段(非分词)而非 file_name(分词) QString processedKeyword = caseSensitive ? keyword : keyword.toLower(); // 直接构建WildcardQuery,不使用QueryParser避免分词干扰 return newLucene<WildcardQuery>( - newLucene<Term>(L"file_name_lower", + newLucene<Term>(LuceneFieldNames::FileName::kFileNameLower, StringUtils::toUnicode(processedKeyword.toStdString()))); } @@ -530,7 +534,7 @@ void FileNameIndexedStrategy::executeIndexQuery(const IndexQuery &query, const Q try { ScoreDocPtr scoreDoc = scoreDocs[i]; DocumentPtr doc = searcher->doc(scoreDoc->doc); - QString path = QString::fromStdWString(doc->get(L"full_path")); + QString path = QString::fromStdWString(doc->get(LuceneFieldNames::FileName::kFullPath)); if (!path.startsWith(searchPath)) { continue; @@ -542,15 +546,15 @@ void FileNameIndexedStrategy::executeIndexQuery(const IndexQuery &query, const Q } if (Q_LIKELY(!m_options.includeHidden())) { - if (QString::fromStdWString(doc->get(L"is_hidden")).toLower() == "y") + if (QString::fromStdWString(doc->get(LuceneFieldNames::FileName::kIsHidden)).toLower() == "y") continue; } // 处理搜索结果 if (Q_UNLIKELY(m_options.detailedResultsEnabled())) { - QString type = QString::fromStdWString(doc->get(L"file_type")); - QString time = QString::fromStdWString(doc->get(L"modify_time_str")); - QString size = QString::fromStdWString(doc->get(L"file_size_str")); + QString type = QString::fromStdWString(doc->get(LuceneFieldNames::FileName::kFileType)); + QString time = QString::fromStdWString(doc->get(LuceneFieldNames::FileName::kModifyTimeStr)); + QString size = QString::fromStdWString(doc->get(LuceneFieldNames::FileName::kFileSizeStr)); m_results.append(processSearchResult(path, type, time, size)); } else { // perf: quickly @@ -719,7 +723,8 @@ Lucene::QueryPtr FileNameIndexedStrategy::buildLuceneQuery(const IndexQuery &que // Add path prefix query optimization if (hasValidQuery && SearchUtility::isFilenameIndexAncestorPathsSupported() && SearchUtility::shouldUsePathPrefixQuery(searchPath)) { - QueryPtr pathPrefixQuery = LuceneQueryUtils::buildPathPrefixQuery(searchPath, "ancestor_paths"); + QueryPtr pathPrefixQuery = LuceneQueryUtils::buildPathPrefixQuery(searchPath, + QString::fromWCharArray(LuceneFieldNames::FileName::kAncestorPaths)); if (pathPrefixQuery) { finalQuery->add(pathPrefixQuery, BooleanClause::MUST); qInfo() << "Using path prefix query for optimization:" << searchPath; @@ -730,7 +735,7 @@ Lucene::QueryPtr FileNameIndexedStrategy::buildLuceneQuery(const IndexQuery &que if (hasValidQuery && Q_LIKELY(!m_options.includeHidden())) { QueryPtr hiddenQuery = Lucene::newLucene<Lucene::TermQuery>( Lucene::newLucene<Lucene::Term>( - Lucene::StringUtils::toUnicode("is_hidden"), + LuceneFieldNames::FileName::kIsHidden, Lucene::StringUtils::toUnicode("Y"))); finalQuery->add(hiddenQuery, Lucene::BooleanClause::MUST_NOT); } diff --git a/src/dfm-search/dfm-search-lib/utils/lucene_field_names.h b/src/dfm-search/dfm-search-lib/utils/lucene_field_names.h new file mode 100644 index 00000000..89954206 --- /dev/null +++ b/src/dfm-search/dfm-search-lib/utils/lucene_field_names.h @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2025 - 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include <dfm-search/dsearch_global.h> + +#include <lucene++/LuceneHeaders.h> + +DFM_SEARCH_BEGIN_NS + +namespace LuceneFieldNames { + +// File name index field names +namespace FileName { +constexpr const wchar_t kFileType[] = L"file_type"; +constexpr const wchar_t kFileExt[] = L"file_ext"; +constexpr const wchar_t kFileName[] = L"file_name"; +constexpr const wchar_t kFileNameLower[] = L"file_name_lower"; +constexpr const wchar_t kFullPath[] = L"full_path"; +constexpr const wchar_t kIsHidden[] = L"is_hidden"; +constexpr const wchar_t kModifyTimeStr[] = L"modify_time_str"; +constexpr const wchar_t kFileSizeStr[] = L"file_size_str"; +constexpr const wchar_t kPinyin[] = L"pinyin"; +constexpr const wchar_t kPinyinAcronym[] = L"pinyin_acronym"; +constexpr const wchar_t kAncestorPaths[] = L"ancestor_paths"; +} // namespace FileName + +// Content index field names +namespace Content { +constexpr const wchar_t kContents[] = L"contents"; +constexpr const wchar_t kFilename[] = L"filename"; +constexpr const wchar_t kPath[] = L"path"; +constexpr const wchar_t kIsHidden[] = L"is_hidden"; +constexpr const wchar_t kAncestorPaths[] = L"ancestor_paths"; +} // namespace Content + +} // namespace LuceneFieldNames + +DFM_SEARCH_END_NS From a02a64f9a19d2944949bb4c584a656eeee71922d Mon Sep 17 00:00:00 2001 From: Zhang Sheng <zhangsheng@uniontech.com> Date: Wed, 8 Apr 2026 10:50:42 +0800 Subject: [PATCH 06/21] feat: add OCR text search support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Implement OCR text search engine with Lucene index support 2. Add OCR text search API classes for options and results handling 3. Extend search type enum to include OCR search type 4. Add OCR-specific error codes and error handling 5. Implement OCR text indexed search strategy with advanced query logic 6. Support mixed AND search across OCR contents and filename fields 7. Add OCR text index directory management and version checking 8. Update search client to support OCR search type 9. Move field names header to public include directory Log: Added OCR text search capability for searching text extracted from images Influence: 1. Test OCR text search with various query types (simple, boolean) 2. Verify mixed AND search behavior across OCR contents and filename fields 3. Test OCR search error handling with short keywords and unsupported wildcards 4. Validate OCR index directory management and version checking 5. Test integration with existing filename and content search functionality 6. Verify search client supports all three search types (filename, content, ocr) feat: 添加 OCR 文本搜索支持 1. 实现基于 Lucene 索引的 OCR 文本搜索引擎 2. 添加 OCR 文本搜索 API 类用于选项和结果处理 3. 扩展搜索类型枚举以包含 OCR 搜索类型 4. 添加 OCR 特定错误码和错误处理机制 5. 实现 OCR 文本索引搜索策略,支持高级查询逻辑 6. 支持跨 OCR 内容和文件名字段的混合 AND 搜索 7. 添加 OCR 文本索引目录管理和版本检查功能 8. 更新搜索客户端以支持 OCR 搜索类型 9. 将字段名称头文件移至公共包含目录 Log: 新增 OCR 文本搜索功能,支持搜索从图像中提取的文本 Influence: 1. 测试使用不同查询类型(简单、布尔)的 OCR 文本搜索 2. 验证跨 OCR 内容和文件名字段的混合 AND 搜索行为 3. 测试短关键词和不支持通配符时的 OCR 搜索错误处理 4. 验证 OCR 索引目录管理和版本检查功能 5. 测试与现有文件名和内容搜索功能的集成 6. 验证搜索客户端支持所有三种搜索类型(文件名、内容、OCR) --- .../dfm-search/dfm-search/dsearch_global.h | 32 ++ .../dfm-search/dfm-search/field_names.h | 9 + .../dfm-search/dfm-search/ocrtextsearchapi.h | 89 ++++ include/dfm-search/dfm-search/searcherror.h | 27 ++ src/dfm-search/dfm-search-client/main.cpp | 84 +++- .../contentstrategies/indexedstrategy.cpp | 3 +- .../dfm-search-lib/core/searchengine.cpp | 4 + .../dfm-search-lib/core/searcherror.cpp | 22 + .../dfm-search-lib/core/searchfactory.cpp | 5 +- .../filenamestrategies/indexedstrategy.cpp | 9 +- .../ocrtextsearch/ocrtextsearchapi.cpp | 41 ++ .../ocrtextsearch/ocrtextsearchengine.cpp | 67 +++ .../ocrtextsearch/ocrtextsearchengine.h | 49 +++ .../ocrtextstrategies/basestrategy.h | 28 ++ .../ocrtextstrategies/indexedstrategy.cpp | 407 ++++++++++++++++++ .../ocrtextstrategies/indexedstrategy.h | 72 ++++ .../dfm-search-lib/utils/searchutility.cpp | 62 +++ .../dfm-search-lib/utils/searchutility.h | 7 + 18 files changed, 994 insertions(+), 23 deletions(-) rename src/dfm-search/dfm-search-lib/utils/lucene_field_names.h => include/dfm-search/dfm-search/field_names.h (79%) create mode 100644 include/dfm-search/dfm-search/ocrtextsearchapi.h create mode 100644 src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextsearchapi.cpp create mode 100644 src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextsearchengine.cpp create mode 100644 src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextsearchengine.h create mode 100644 src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextstrategies/basestrategy.h create mode 100644 src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextstrategies/indexedstrategy.cpp create mode 100644 src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextstrategies/indexedstrategy.h diff --git a/include/dfm-search/dfm-search/dsearch_global.h b/include/dfm-search/dfm-search/dsearch_global.h index add2c0c8..4a148c0a 100644 --- a/include/dfm-search/dfm-search/dsearch_global.h +++ b/include/dfm-search/dfm-search/dsearch_global.h @@ -106,6 +106,30 @@ bool isContentIndexAvailable(); */ QString contentIndexDirectory(); +/** + * @brief Check if the specified path is within the OCR text index directory. + * This function verifies whether a given file path is located within the designated OCR text index directory, + * which is important for ensuring that only relevant files are included in OCR text search operations. + * @param path The file path to check. + * @return True if the path is within the OCR text index directory, false otherwise. + */ +bool isPathInOcrTextIndexDirectory(const QString &path); + +/** + * @brief Check if the OCR text index is available. + * This function checks the status of the OCR text index to determine if it is accessible and ready for search operations. + * @return True if the OCR text index is available, false otherwise. + */ +bool isOcrTextIndexAvailable(); + +/** + * @brief Get the OCR text index directory path. + * This function provides the path to the directory where the OCR text index is stored, + * which is essential for performing searches on indexed OCR-extracted text from images. + * @return The path to the OCR text index directory. + */ +QString ocrTextIndexDirectory(); + /** * @brief Check if the specified path is within the filename index directory. * This function verifies whether a given file path is located within the designated filename index directory, @@ -165,12 +189,20 @@ int fileNameIndexVersion(); */ int contentIndexVersion(); +/** + * @brief Get the version of the OCR text index from the JSON configuration file. + * This function reads the version field from the OCR text index JSON file and returns it as an integer. + * @return The version number of the OCR text index, or -1 if the version cannot be retrieved. + */ +int ocrTextIndexVersion(); + } // namespace Global // Enumeration for different types of search methods enum SearchType { FileName, // Search by file name Content, // Search by content within files + Ocr, // Search by OCR-extracted text from images Custom = 50 // User-defined search type }; Q_ENUM_NS(SearchType) diff --git a/src/dfm-search/dfm-search-lib/utils/lucene_field_names.h b/include/dfm-search/dfm-search/field_names.h similarity index 79% rename from src/dfm-search/dfm-search-lib/utils/lucene_field_names.h rename to include/dfm-search/dfm-search/field_names.h index 89954206..e033a56f 100644 --- a/src/dfm-search/dfm-search-lib/utils/lucene_field_names.h +++ b/include/dfm-search/dfm-search/field_names.h @@ -35,6 +35,15 @@ constexpr const wchar_t kIsHidden[] = L"is_hidden"; constexpr const wchar_t kAncestorPaths[] = L"ancestor_paths"; } // namespace Content +// OCR text index field names +namespace OcrText { +constexpr const wchar_t kOcrContents[] = L"ocr_contents"; +constexpr const wchar_t kFilename[] = L"filename"; +constexpr const wchar_t kPath[] = L"path"; +constexpr const wchar_t kIsHidden[] = L"is_hidden"; +constexpr const wchar_t kAncestorPaths[] = L"ancestor_paths"; +} // namespace OcrText + } // namespace LuceneFieldNames DFM_SEARCH_END_NS diff --git a/include/dfm-search/dfm-search/ocrtextsearchapi.h b/include/dfm-search/dfm-search/ocrtextsearchapi.h new file mode 100644 index 00000000..54d896b4 --- /dev/null +++ b/include/dfm-search/dfm-search/ocrtextsearchapi.h @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later +#ifndef OCRTEXTSEARCHAPI_H +#define OCRTEXTSEARCHAPI_H + +#include <dfm-search/dsearch_global.h> +#include <dfm-search/searchoptions.h> +#include <dfm-search/searchresult.h> + +DFM_SEARCH_BEGIN_NS + +/** + * @brief The OcrTextOptionsAPI class provides OCR text search specific options + * + * This class extends the base SearchOptions with OCR text search specific settings. + * OCR text search is a simplified version of content search without highlighting support. + */ +class OcrTextOptionsAPI +{ +public: + /** + * @brief Constructor + * @param options The SearchOptions object to operate on + */ + explicit OcrTextOptionsAPI(SearchOptions &options); + + /** + * @brief Sets whether the extended AND search behavior across 'ocr_contents' and 'filename' fields is enabled. + * @param enabled True to enable the feature, false to disable it. + * @see isFilenameOcrContentMixedAndSearchEnabled() for a detailed description of the behavior. + */ + void setFilenameOcrContentMixedAndSearchEnabled(bool enabled); + + /** + * @brief Checks if the extended AND search behavior across 'ocr_contents' and 'filename' fields is enabled. + * + * When enabled (returns true), boolean AND queries will search for terms such that: + * 1. All terms must be present, potentially distributed between the 'ocr_contents' and 'filename' fields. + * (e.g., termA in 'ocr_contents', termB in 'filename'). + * 2. A match is explicitly excluded if all search terms are found *only* within the 'filename' field. + * (e.g., termA in 'filename', termB in 'filename' -- this specific case is excluded). + * 3. Matches where all terms are in 'ocr_contents', or mixed between 'ocr_contents' and 'filename' (as in point 1), are included. + * + * If this option is disabled (returns false), or for boolean OR queries, + * the boolean search will be performed exclusively on the 'ocr_contents' field, following the original logic. + * + * @return True if the filename-OCR content mixed AND search is enabled, false otherwise. + */ + bool isFilenameOcrContentMixedAndSearchEnabled() const; + +private: + SearchOptions &m_options; +}; + +/** + * @brief The OcrTextResultAPI class provides OCR text search specific result handling + * + * This class extends the base SearchResult with OCR text search specific features. + * Note: OCR text search does not support content highlighting like content search. + */ +class OcrTextResultAPI +{ +public: + /** + * @brief Constructor + * @param result The SearchResult object to operate on + */ + OcrTextResultAPI(SearchResult &result); + + /** + * @brief Get the OCR extracted text content + * @return The OCR extracted text as QString + */ + QString ocrContent() const; + + /** + * @brief Set the OCR extracted text content + * @param content The OCR extracted text to set + */ + void setOcrContent(const QString &content); + +private: + SearchResult &m_result; +}; + +DFM_SEARCH_END_NS + +#endif // OCRTEXTSEARCHAPI_H diff --git a/include/dfm-search/dfm-search/searcherror.h b/include/dfm-search/dfm-search/searcherror.h index 828fef0a..4a4433d8 100644 --- a/include/dfm-search/dfm-search/searcherror.h +++ b/include/dfm-search/dfm-search/searcherror.h @@ -57,6 +57,16 @@ enum class ContentSearchErrorCode { ContentIndexException // An exception occurred with the content index }; +// Enumeration for OCR text search specific error codes +enum class OcrTextSearchErrorCode { + KeywordTooShort = 3000, // The search keyword is too short + WildcardNotSupported = 3001, // Wildcard search is not supported for OCR text search + + // Errors related to OCR text indexing + OcrTextIndexNotFound = 3200, // The OCR text index could not be found + OcrTextIndexException // An exception occurred with the OCR text index +}; + // Base class for search error categories class SearchErrorCategory : public std::error_category { @@ -82,10 +92,19 @@ class ContentSearchErrorCategory : public SearchErrorCategory std::string message(int ev) const override; // Returns a message corresponding to the content search error code }; +// Class for OCR text search error categories +class OcrTextSearchErrorCategory : public SearchErrorCategory +{ +public: + const char *name() const noexcept override { return "ocrtext_search_error"; } // Returns the name of the OCR text search error category + std::string message(int ev) const override; // Returns a message corresponding to the OCR text search error code +}; + // Functions to get singleton instances of error categories const SearchErrorCategory &search_category(); // Returns the singleton instance of the search error category const FileNameSearchErrorCategory &filename_search_category(); // Returns the singleton instance of the file name search error category const ContentSearchErrorCategory &content_search_category(); // Returns the singleton instance of the content search error category +const OcrTextSearchErrorCategory &ocrtext_search_category(); // Returns the singleton instance of the OCR text search error category // Function to create an error code from a SearchErrorCode inline std::error_code make_error_code(SearchErrorCode ec) @@ -105,6 +124,12 @@ inline std::error_code make_error_code(ContentSearchErrorCode ec) return std::error_code((int)ec, content_search_category()); } +// Function to create an error code from a OcrTextSearchErrorCode +inline std::error_code make_error_code(OcrTextSearchErrorCode ec) +{ + return std::error_code((int)ec, ocrtext_search_category()); +} + // Class to wrap search error conditions class SearchError { @@ -117,6 +142,8 @@ class SearchError : m_code(make_error_code(code)) { } // Constructor initializes with a FileNameSearchErrorCode SearchError(ContentSearchErrorCode code) : m_code(make_error_code(code)) { } // Constructor initializes with a ContentSearchErrorCode + SearchError(OcrTextSearchErrorCode code) + : m_code(make_error_code(code)) { } // Constructor initializes with a OcrTextSearchErrorCode bool isError() const { return m_code.value() != static_cast<int>(SearchErrorCode::Success); } // Checks if there is an error const std::error_code &code() const { return m_code; } // Returns the error code diff --git a/src/dfm-search/dfm-search-client/main.cpp b/src/dfm-search/dfm-search-client/main.cpp index ff3104ff..eb5643f4 100644 --- a/src/dfm-search/dfm-search-client/main.cpp +++ b/src/dfm-search/dfm-search-client/main.cpp @@ -22,6 +22,7 @@ #include <dfm-search/searchoptions.h> #include <dfm-search/filenamesearchapi.h> #include <dfm-search/contentsearchapi.h> +#include <dfm-search/ocrtextsearchapi.h> #include "../dfm-search-lib/utils/filenameblacklistmatcher.h" #include <iostream> @@ -69,20 +70,20 @@ void printUsage() { std::cout << "Usage: dfm6-search-client [options] <keyword> <search_path>" << std::endl; std::cout << "Options:" << std::endl; - std::cout << " --type=<filename|content> Search type (default: filename)" << std::endl; - std::cout << " --method=<indexed|realtime> Search method (default: indexed)" << std::endl; + std::cout << " --type=<filename|content|ocr> Search type (default: filename)" << std::endl; + std::cout << " --method=<indexed|realtime> Search method (default: indexed)" << std::endl; std::cout << " --query=<simple|boolean|wildcard> Query type (default: simple)" << std::endl; - std::cout << " --wildcard Enable wildcard search with * and ? patterns" << std::endl; - std::cout << " --case-sensitive Enable case sensitivity" << std::endl; - std::cout << " --include-hidden Include hidden files" << std::endl; - std::cout << " --pinyin Enable pinyin search (for filename search)" << std::endl; - std::cout << " --pinyin-acronym Enable pinyin acronym search (for filename search)" << std::endl; - std::cout << " --file-types=<types> Filter by file types, comma separated" << std::endl; - std::cout << " --file-extensions=<exts> Filter by file extensions, comma separated" << std::endl; - std::cout << " --max-results=<number> Maximum number of results" << std::endl; - std::cout << " --max-preview=<length> Max content preview length (for content search)" << std::endl; - std::cout << " --json, -j Output results in JSON format" << std::endl; - std::cout << " --help Display this help" << std::endl; + std::cout << " --wildcard Enable wildcard search with * and ? patterns" << std::endl; + std::cout << " --case-sensitive Enable case sensitivity" << std::endl; + std::cout << " --include-hidden Include hidden files" << std::endl; + std::cout << " --pinyin Enable pinyin search (for filename search)" << std::endl; + std::cout << " --pinyin-acronym Enable pinyin acronym search (for filename search)" << std::endl; + std::cout << " --file-types=<types> Filter by file types, comma separated" << std::endl; + std::cout << " --file-extensions=<exts> Filter by file extensions, comma separated" << std::endl; + std::cout << " --max-results=<number> Maximum number of results" << std::endl; + std::cout << " --max-preview=<length> Max content preview length (for content search)" << std::endl; + std::cout << " --json, -j Output results in JSON format" << std::endl; + std::cout << " --help Display this help" << std::endl; } void printSearchResult(const SearchResult &result, SearchType searchType) @@ -103,6 +104,9 @@ void printSearchResult(const SearchResult &result, SearchType searchType) } else if (searchType == SearchType::Content) { ContentResultAPI contentResult(const_cast<SearchResult &>(result)); std::cout << " Content match: " << contentResult.highlightedContent().toStdString() << std::endl; + } else if (searchType == SearchType::Ocr) { + OcrTextResultAPI ocrResult(const_cast<SearchResult &>(result)); + std::cout << " OCR text match" << std::endl; } std::cout << std::endl; @@ -124,6 +128,11 @@ QJsonValue resultToJson(const SearchResult &result, SearchType searchType) ContentResultAPI contentResult(const_cast<SearchResult &>(result)); obj["contentMatch"] = contentResult.highlightedContent(); return obj; + } else if (searchType == SearchType::Ocr) { + // OCR 搜索:返回路径 + QJsonObject obj; + obj["path"] = result.path(); + return obj; } return result.path(); } @@ -142,10 +151,25 @@ void printJsonSearchStart(const QString &keyword, const QString &searchPath, QJsonObject startObj; startObj["type"] = "search_started"; + QString searchTypeStr; + switch (searchType) { + case SearchType::FileName: + searchTypeStr = "filename"; + break; + case SearchType::Content: + searchTypeStr = "content"; + break; + case SearchType::Ocr: + searchTypeStr = "ocr"; + break; + default: + searchTypeStr = "unknown"; + } + QJsonObject searchInfo; searchInfo["keyword"] = keyword; searchInfo["searchPath"] = searchPath; - searchInfo["searchType"] = (searchType == SearchType::FileName ? "filename" : "content"); + searchInfo["searchType"] = searchTypeStr; searchInfo["searchMethod"] = (searchMethod == SearchMethod::Indexed ? "indexed" : "realtime"); searchInfo["caseSensitive"] = options.caseSensitive(); searchInfo["includeHidden"] = options.includeHidden(); @@ -205,7 +229,22 @@ void printJsonComplete(const JsonOutputContext &ctx) QJsonObject searchInfo; searchInfo["keyword"] = ctx.keyword; searchInfo["searchPath"] = ctx.searchPath; - searchInfo["searchType"] = (ctx.searchType == SearchType::FileName ? "filename" : "content"); + + QString searchTypeStr; + switch (ctx.searchType) { + case SearchType::FileName: + searchTypeStr = "filename"; + break; + case SearchType::Content: + searchTypeStr = "content"; + break; + case SearchType::Ocr: + searchTypeStr = "ocr"; + break; + default: + searchTypeStr = "unknown"; + } + searchInfo["searchType"] = searchTypeStr; searchInfo["searchMethod"] = (ctx.searchMethod == SearchMethod::Indexed ? "indexed" : "realtime"); searchInfo["caseSensitive"] = ctx.options.caseSensitive(); searchInfo["includeHidden"] = ctx.options.includeHidden(); @@ -302,8 +341,10 @@ int main(int argc, char *argv[]) QString typeStr = parser.value(typeOption); if (typeStr == "content") { searchType = SearchType::Content; + } else if (typeStr == "ocr") { + searchType = SearchType::Ocr; } else if (typeStr != "filename") { - std::cerr << "Error: Invalid search type. Use 'filename' or 'content'" << std::endl; + std::cerr << "Error: Invalid search type. Use 'filename', 'content', or 'ocr'" << std::endl; return 1; } @@ -397,6 +438,9 @@ int main(int argc, char *argv[]) contentOptions.setMaxPreviewLength(previewLength); } } + } else if (searchType == SearchType::Ocr) { + OcrTextOptionsAPI ocrTextOptions(options); + ocrTextOptions.setFilenameOcrContentMixedAndSearchEnabled(true); } engine->setSearchOptions(options); @@ -549,7 +593,13 @@ int main(int argc, char *argv[]) // Start search std::cout << "Searching for: " << keyword.toStdString() << std::endl; std::cout << "In path: " << searchPath.toStdString() << std::endl; - std::cout << "Search type: " << (searchType == SearchType::FileName ? "Filename" : "Content") << std::endl; + + QString typeStr = "Filename"; + if (searchType == SearchType::Content) + typeStr = "Content"; + else if (searchType == SearchType::Ocr) + typeStr = "Ocr"; + std::cout << "Search type: " << typeStr.toStdString() << std::endl; std::cout << "Search method: " << (searchMethod == SearchMethod::Indexed ? "Indexed" : "Realtime") << std::endl; // Print file extensions if set diff --git a/src/dfm-search/dfm-search-lib/contentsearch/contentstrategies/indexedstrategy.cpp b/src/dfm-search/dfm-search-lib/contentsearch/contentstrategies/indexedstrategy.cpp index ff5a72ea..45ac9d5e 100644 --- a/src/dfm-search/dfm-search-lib/contentsearch/contentstrategies/indexedstrategy.cpp +++ b/src/dfm-search/dfm-search-lib/contentsearch/contentstrategies/indexedstrategy.cpp @@ -16,13 +16,14 @@ #include <lucene++/QueryWrapperFilter.h> #include <lucene++/WildcardQuery.h> +#include <dfm-search/field_names.h> + #include "3rdparty/fulltext/chineseanalyzer.h" #include "utils/cancellablecollector.h" #include "utils/contenthighlighter.h" #include "utils/lucenequeryutils.h" #include "utils/searchutility.h" #include "utils/lucene_cancellation_compat.h" -#include "utils/lucene_field_names.h" using namespace Lucene; diff --git a/src/dfm-search/dfm-search-lib/core/searchengine.cpp b/src/dfm-search/dfm-search-lib/core/searchengine.cpp index e5ca9a78..cb2e01a7 100644 --- a/src/dfm-search/dfm-search-lib/core/searchengine.cpp +++ b/src/dfm-search/dfm-search-lib/core/searchengine.cpp @@ -9,6 +9,7 @@ #include "contentsearch/contentsearchengine.h" #include "filenamesearch/filenamesearchengine.h" +#include "ocrtextsearch/ocrtextsearchengine.h" DFM_SEARCH_BEGIN_NS @@ -57,6 +58,9 @@ void SearchEngine::setSearchType(SearchType type) case SearchType::Content: d_ptr = std::make_unique<ContentSearchEngine>(); break; + case SearchType::Ocr: + d_ptr = std::make_unique<OcrTextSearchEngine>(); + break; default: qWarning("Unsupported search type: %d", static_cast<int>(type)); return; diff --git a/src/dfm-search/dfm-search-lib/core/searcherror.cpp b/src/dfm-search/dfm-search-lib/core/searcherror.cpp index 61423909..aa2780a7 100644 --- a/src/dfm-search/dfm-search-lib/core/searcherror.cpp +++ b/src/dfm-search/dfm-search-lib/core/searcherror.cpp @@ -75,6 +75,22 @@ std::string ContentSearchErrorCategory::message(int ev) const } } +std::string OcrTextSearchErrorCategory::message(int ev) const +{ + switch (static_cast<OcrTextSearchErrorCode>(ev)) { + case OcrTextSearchErrorCode::KeywordTooShort: + return "Keyword too short: The search keyword is too short to perform an OCR text search. Please provide a longer keyword."; + case OcrTextSearchErrorCode::WildcardNotSupported: + return "Wildcard not supported: Wildcard search is not supported for OCR text search. Please use simple or boolean query instead."; + case OcrTextSearchErrorCode::OcrTextIndexNotFound: + return "OCR text index not found: The OCR text index could not be found. Please ensure the index is created."; + case OcrTextSearchErrorCode::OcrTextIndexException: + return "OCR text index exception: An error occurred while accessing the OCR text index. Please check the index integrity."; + default: + return "Unknown OCR text search error: An unknown error occurred related to OCR text search. Please contact support."; + } +} + // ... 实现其他错误分类的消息方法 ... // 获取错误分类单例 @@ -96,6 +112,12 @@ const ContentSearchErrorCategory &content_search_category() return c; } +const OcrTextSearchErrorCategory &ocrtext_search_category() +{ + static OcrTextSearchErrorCategory c; + return c; +} + // ... 实现其他错误分类单例 ... QString SearchError::message() const diff --git a/src/dfm-search/dfm-search-lib/core/searchfactory.cpp b/src/dfm-search/dfm-search-lib/core/searchfactory.cpp index f73c9b3e..4d092ccf 100644 --- a/src/dfm-search/dfm-search-lib/core/searchfactory.cpp +++ b/src/dfm-search/dfm-search-lib/core/searchfactory.cpp @@ -17,8 +17,11 @@ SearchEngine *SearchFactory::createEngine(SearchType type, QObject *parent) case SearchType::Content: engine = new SearchEngine(type, parent); break; + case SearchType::Ocr: + engine = new SearchEngine(type, parent); + break; case SearchType::Custom: - // TODO: 由应用程序基于provider自行创建 + // TODO: Created by application based on provider break; } diff --git a/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.cpp b/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.cpp index 7cd6e5b3..d16b6836 100644 --- a/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.cpp +++ b/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.cpp @@ -2,10 +2,6 @@ // // SPDX-License-Identifier: GPL-3.0-or-later #include "indexedstrategy.h" -#include "utils/cancellablecollector.h" -#include "utils/searchutility.h" -#include "utils/lucenequeryutils.h" -#include "utils/lucene_field_names.h" #include <unistd.h> #include <sys/types.h> @@ -16,7 +12,12 @@ #include <QDebug> #include <QElapsedTimer> +#include <dfm-search/field_names.h> + #include "3rdparty/fulltext/chineseanalyzer.h" +#include "utils/cancellablecollector.h" +#include "utils/searchutility.h" +#include "utils/lucenequeryutils.h" DFM_SEARCH_BEGIN_NS diff --git a/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextsearchapi.cpp b/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextsearchapi.cpp new file mode 100644 index 00000000..bd7d7bef --- /dev/null +++ b/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextsearchapi.cpp @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later +#include <dfm-search/ocrtextsearchapi.h> + +DFM_SEARCH_BEGIN_NS + +OcrTextOptionsAPI::OcrTextOptionsAPI(SearchOptions &options) + : m_options(options) +{ + // init default + if (!m_options.hasCustomOption("filenameOcrContentMixedAndSearchEnabled")) + setFilenameOcrContentMixedAndSearchEnabled(false); +} + +void OcrTextOptionsAPI::setFilenameOcrContentMixedAndSearchEnabled(bool enabled) +{ + m_options.setCustomOption("filenameOcrContentMixedAndSearchEnabled", enabled); +} + +bool OcrTextOptionsAPI::isFilenameOcrContentMixedAndSearchEnabled() const +{ + return m_options.customOption("filenameOcrContentMixedAndSearchEnabled").toBool(); +} + +OcrTextResultAPI::OcrTextResultAPI(SearchResult &result) + : m_result(result) +{ +} + +QString OcrTextResultAPI::ocrContent() const +{ + return m_result.customAttribute("ocrContent").toString(); +} + +void OcrTextResultAPI::setOcrContent(const QString &content) +{ + m_result.setCustomAttribute("ocrContent", content); +} + +DFM_SEARCH_END_NS diff --git a/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextsearchengine.cpp b/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextsearchengine.cpp new file mode 100644 index 00000000..ed9172bb --- /dev/null +++ b/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextsearchengine.cpp @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ocrtextsearchengine.h" + +#include "ocrtextstrategies/indexedstrategy.h" + +DFM_SEARCH_BEGIN_NS +DCORE_USE_NAMESPACE + +OcrTextSearchEngine::OcrTextSearchEngine(QObject *parent) + : GenericSearchEngine(parent) +{ +} + +OcrTextSearchEngine::~OcrTextSearchEngine() = default; + +void OcrTextSearchEngine::setupStrategyFactory() +{ + // Set up OCR text search strategy factory + auto factory = std::make_unique<OcrTextSearchStrategyFactory>(); + m_worker->setStrategyFactory(std::move(factory)); +} + +SearchError OcrTextSearchEngine::validateSearchConditions() +{ + // First execute base class validation + auto result = GenericSearchEngine::validateSearchConditions(); + if (result.isError()) { + return result; + } + + // OCR text search specific validation + if (m_options.method() != SearchMethod::Indexed) { + return SearchError(SearchErrorCode::InvalidSerchMethod); + } + + // Check for unsupported Wildcard query type + if (m_currentQuery.type() == SearchQuery::Type::Wildcard) { + return SearchError(OcrTextSearchErrorCode::WildcardNotSupported); + } + + if (m_currentQuery.type() == SearchQuery::Type::Simple + && m_currentQuery.keyword().toUtf8().size() < Global::kMinContentSearchKeywordLength) { + return SearchError(OcrTextSearchErrorCode::KeywordTooShort); + } + + return result; +} + +std::unique_ptr<BaseSearchStrategy> OcrTextSearchStrategyFactory::createStrategy( + SearchType searchType, const SearchOptions &options) +{ + // Ensure correct search type + if (searchType != SearchType::Ocr) { + return nullptr; + } + + // Create corresponding strategy based on search method + if (options.method() == SearchMethod::Indexed) { + return std::make_unique<OcrTextIndexedStrategy>(options); + } + + return nullptr; +} + +DFM_SEARCH_END_NS diff --git a/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextsearchengine.h b/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextsearchengine.h new file mode 100644 index 00000000..4d6d6f59 --- /dev/null +++ b/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextsearchengine.h @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later +#ifndef OCRTEXT_SEARCH_ENGINE_H +#define OCRTEXT_SEARCH_ENGINE_H + +#include "core/genericsearchengine.h" + +DFM_SEARCH_BEGIN_NS + +/** + * @brief OCR text search engine + * + * Implements search functionality for text extracted from images using OCR. + * This engine uses Lucene indexes similar to content search, but with + * simplified features (no highlighting support). + */ +class OcrTextSearchEngine : public GenericSearchEngine +{ + Q_OBJECT + +public: + explicit OcrTextSearchEngine(QObject *parent = nullptr); + ~OcrTextSearchEngine() override; + + // Implement search type + SearchType searchType() const override { return SearchType::Ocr; } + +protected: + // Setup strategy factory + void setupStrategyFactory() override; + + // Override validation to add specific checks + SearchError validateSearchConditions() override; +}; + +/** + * @brief OCR text search strategy factory + */ +class OcrTextSearchStrategyFactory : public SearchStrategyFactory +{ +public: + std::unique_ptr<BaseSearchStrategy> createStrategy( + SearchType searchType, const SearchOptions &options) override; +}; + +DFM_SEARCH_END_NS + +#endif // OCRTEXT_SEARCH_ENGINE_H diff --git a/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextstrategies/basestrategy.h b/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextstrategies/basestrategy.h new file mode 100644 index 00000000..0774e059 --- /dev/null +++ b/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextstrategies/basestrategy.h @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later +#ifndef OCRTEXT_BASE_STRATEGY_H +#define OCRTEXT_BASE_STRATEGY_H + +#include "core/searchstrategy/basesearchstrategy.h" +#include <dfm-search/ocrtextsearchapi.h> + +DFM_SEARCH_BEGIN_NS + +/** + * @brief OCR text search strategy base class + */ +class OcrTextBaseStrategy : public BaseSearchStrategy +{ + Q_OBJECT + +public: + explicit OcrTextBaseStrategy(const SearchOptions &options, QObject *parent = nullptr) + : BaseSearchStrategy(options, parent) { } + + SearchType searchType() const override { return SearchType::Ocr; } +}; + +DFM_SEARCH_END_NS + +#endif // OCRTEXT_BASE_STRATEGY_H diff --git a/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextstrategies/indexedstrategy.cpp b/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextstrategies/indexedstrategy.cpp new file mode 100644 index 00000000..a5a80e6a --- /dev/null +++ b/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextstrategies/indexedstrategy.cpp @@ -0,0 +1,407 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later +#include "indexedstrategy.h" + +#include <QDir> +#include <QFileInfo> +#include <QThread> +#include <QElapsedTimer> + +#include <lucene++/LuceneHeaders.h> +#include <lucene++/QueryParser.h> +#include <lucene++/BooleanQuery.h> +#include <lucene++/QueryWrapperFilter.h> +#include <lucene++/WildcardQuery.h> + +#include <dfm-search/field_names.h> + +#include "3rdparty/fulltext/chineseanalyzer.h" +#include "utils/cancellablecollector.h" +#include "utils/lucenequeryutils.h" +#include "utils/searchutility.h" +#include "utils/lucene_cancellation_compat.h" + +using namespace Lucene; + +DFM_SEARCH_BEGIN_NS + +OcrTextIndexedStrategy::OcrTextIndexedStrategy(const SearchOptions &options, QObject *parent) + : OcrTextBaseStrategy(options, parent) +{ + initializeIndexing(); +} + +OcrTextIndexedStrategy::~OcrTextIndexedStrategy() = default; + +void OcrTextIndexedStrategy::initializeIndexing() +{ + // Get OCR text index directory + m_indexDir = Global::ocrTextIndexDirectory(); + + // Check if index directory exists + if (!QDir(m_indexDir).exists()) { + qWarning() << "OCR text index directory does not exist:" << m_indexDir; + } +} + +void OcrTextIndexedStrategy::search(const SearchQuery &query) +{ + m_cancelled.store(false); + m_results.clear(); + + try { + // Perform OCR text index search + performOcrTextSearch(query); + } catch (const std::exception &e) { + qWarning() << "OCR Text Index Search Exception:" << e.what(); + emit errorOccurred(SearchError(OcrTextSearchErrorCode::OcrTextIndexException)); + } +} + +Lucene::QueryPtr OcrTextIndexedStrategy::buildLuceneQuery(const SearchQuery &query, const Lucene::AnalyzerPtr &analyzer, const QString &searchPath) +{ + try { + m_keywords.clear(); + OcrTextOptionsAPI optAPI(m_options); + bool mixedAndEnabled = optAPI.isFilenameOcrContentMixedAndSearchEnabled(); + + Lucene::QueryParserPtr ocrContentsParser = newLucene<Lucene::QueryParser>( + Lucene::LuceneVersion::LUCENE_CURRENT, + LuceneFieldNames::OcrText::kOcrContents, + analyzer); + + Lucene::QueryPtr mainQuery; + if (query.type() == SearchQuery::Type::Simple) { + mainQuery = buildSimpleOcrContentsQuery(query, ocrContentsParser); + } else if (query.type() == SearchQuery::Type::Boolean) { + if (query.subQueries().isEmpty()) { + // For an empty boolean query, match nothing. + mainQuery = newLucene<Lucene::BooleanQuery>(); + } else { + // Determine which logic path to take for boolean queries + if (mixedAndEnabled && query.booleanOperator() == SearchQuery::BooleanOperator::AND) { + // New "advanced" AND logic for ocr_contents/filename + mainQuery = buildAdvancedAndQuery(query, ocrContentsParser, analyzer); + } else { + // "Standard" ocr_contents-only logic + mainQuery = buildStandardBooleanOcrContentsQuery(query, ocrContentsParser); + } + } + } else { + qWarning() << "Unknown SearchQuery type encountered."; + mainQuery = newLucene<Lucene::BooleanQuery>(); // Should not happen + } + + // Add path prefix query optimization + if (mainQuery && SearchUtility::isOcrTextIndexAncestorPathsSupported() + && SearchUtility::shouldUsePathPrefixQuery(searchPath)) { + QueryPtr pathPrefixQuery = LuceneQueryUtils::buildPathPrefixQuery(searchPath, + QString::fromWCharArray(LuceneFieldNames::OcrText::kAncestorPaths)); + if (pathPrefixQuery) { + BooleanQueryPtr finalQuery = newLucene<BooleanQuery>(); + finalQuery->add(mainQuery, BooleanClause::MUST); + finalQuery->add(pathPrefixQuery, BooleanClause::MUST); + qInfo() << "Using path prefix query for OCR text search optimization:" << searchPath; + return finalQuery; + } + } + + return mainQuery; + + } catch (const Lucene::LuceneException &e) { + qWarning() << "Error building Lucene query:" << QString::fromStdWString(e.getError()); + return nullptr; + } catch (const std::exception &e) { + qWarning() << "Standard exception building Lucene query:" << e.what(); + return nullptr; + } +} + +QueryPtr OcrTextIndexedStrategy::buildAdvancedAndQuery(const SearchQuery &query, const Lucene::QueryParserPtr &ocrContentsParser, const Lucene::AnalyzerPtr &analyzer) +{ + // This method implements the "mixed" AND logic similar to content search. + // It requires its own filenameParser. + Lucene::QueryParserPtr filenameParser = newLucene<Lucene::QueryParser>( + Lucene::LuceneVersion::LUCENE_CURRENT, + LuceneFieldNames::OcrText::kFilename, + analyzer); + + Lucene::BooleanQueryPtr overallQuery = newLucene<Lucene::BooleanQuery>(); + Lucene::BooleanQueryPtr mainAndClausesQuery = newLucene<Lucene::BooleanQuery>(); + Lucene::BooleanQueryPtr allOcrContentsQuery = newLucene<Lucene::BooleanQuery>(); + Lucene::BooleanQueryPtr allFilenamesQuery = newLucene<Lucene::BooleanQuery>(); + bool hasValidKeywords = false; + + for (const auto &subQuery : query.subQueries()) { + m_keywords.append(subQuery.keyword()); + if (subQuery.keyword().isEmpty()) { + continue; // Skip empty keywords + } + hasValidKeywords = true; + + // Use LuceneQueryUtils to process special characters + Lucene::String processedKeyword = LuceneQueryUtils::processQueryString(subQuery.keyword(), false); + Lucene::QueryPtr ocrContentsTermQuery = ocrContentsParser->parse(processedKeyword); + Lucene::QueryPtr filenameTermQuery = filenameParser->parse(processedKeyword); + + // Build (ocr_contents:keyword OR filename:keyword) + Lucene::BooleanQueryPtr combinedTermQuery = newLucene<Lucene::BooleanQuery>(); + combinedTermQuery->add(ocrContentsTermQuery, Lucene::BooleanClause::SHOULD); + combinedTermQuery->add(filenameTermQuery, Lucene::BooleanClause::SHOULD); + + mainAndClausesQuery->add(combinedTermQuery, Lucene::BooleanClause::MUST); + allOcrContentsQuery->add(ocrContentsTermQuery, Lucene::BooleanClause::MUST); + allFilenamesQuery->add(filenameTermQuery, Lucene::BooleanClause::MUST); + } + + if (!hasValidKeywords) { // All subQuery keywords were empty + qWarning() << "No valid keywords found in advanced AND query"; + return newLucene<Lucene::BooleanQuery>(); // Matches nothing + } + + // Exclude pure filename-only matches + // Final query: ( (ocr:k1 OR f:k1) AND ... ) AND NOT (f:k1 AND f:k2 ... AND NOT (ocr:k1 AND ocr:k2 ...)) + Lucene::BooleanQueryPtr pureFilenameQuery = newLucene<Lucene::BooleanQuery>(); + pureFilenameQuery->add(allFilenamesQuery, Lucene::BooleanClause::MUST); + pureFilenameQuery->add(allOcrContentsQuery, Lucene::BooleanClause::MUST_NOT); + + overallQuery->add(mainAndClausesQuery, Lucene::BooleanClause::MUST); + overallQuery->add(pureFilenameQuery, Lucene::BooleanClause::MUST_NOT); + + return overallQuery; +} + +QueryPtr OcrTextIndexedStrategy::buildStandardBooleanOcrContentsQuery(const SearchQuery &query, const Lucene::QueryParserPtr &ocrContentsParser) +{ + // This method implements the "original" boolean logic, searching only "ocr_contents". + Lucene::BooleanQueryPtr booleanQuery = newLucene<Lucene::BooleanQuery>(); + + for (const auto &subQuery : query.subQueries()) { + m_keywords.append(subQuery.keyword()); + if (subQuery.keyword().isEmpty()) { + continue; // Skip empty keywords + } + + // Use LuceneQueryUtils to process special characters + Lucene::QueryPtr termQuery = ocrContentsParser->parse(LuceneQueryUtils::processQueryString(subQuery.keyword(), false)); + booleanQuery->add(termQuery, + query.booleanOperator() == SearchQuery::BooleanOperator::AND ? Lucene::BooleanClause::MUST : Lucene::BooleanClause::SHOULD); + } + + return booleanQuery; +} + +QueryPtr OcrTextIndexedStrategy::buildSimpleOcrContentsQuery(const SearchQuery &query, const Lucene::QueryParserPtr &ocrContentsParser) +{ + m_keywords.append(query.keyword()); + if (query.keyword().isEmpty()) { + return newLucene<Lucene::BooleanQuery>(); // Match nothing for empty keyword + } + // Use LuceneQueryUtils to process special characters + return ocrContentsParser->parse(LuceneQueryUtils::processQueryString(query.keyword(), false)); +} + +void OcrTextIndexedStrategy::processSearchResults(const Lucene::IndexSearcherPtr &searcher, + const Lucene::Collection<Lucene::ScoreDocPtr> &scoreDocs) +{ + // Measure the time taken to process search results + QElapsedTimer resultTimer; + resultTimer.start(); + + QString searchPath = m_options.searchPath(); + const QStringList &searchExcludedPaths = m_options.searchExcludedPaths(); + auto docsSize = scoreDocs.size(); + + for (int32_t i = 0; i < docsSize; ++i) { + if (m_cancelled.load()) { + qInfo() << "OCR text search cancelled"; + break; + } + + try { + Lucene::ScoreDocPtr scoreDoc = scoreDocs[i]; + if (!scoreDoc) { + qWarning() << "Null ScoreDoc encountered at index" << i; + continue; + } + + // Defensive check: verify document ID is valid + if (scoreDoc->doc < 0) { + qWarning() << "Invalid document ID:" << scoreDoc->doc; + continue; + } + + // Safely retrieve document (could throw if index is corrupted) + Lucene::DocumentPtr doc; + try { + doc = searcher->doc(scoreDoc->doc); + if (!doc) { + qWarning() << "Failed to retrieve document at index:" << scoreDoc->doc; + continue; + } + } catch (const Lucene::LuceneException &e) { + qWarning() << "Exception while retrieving document:" << QString::fromStdWString(e.getError()); + continue; + } catch (const std::exception &e) { + qWarning() << "Standard exception while retrieving document:" << e.what(); + continue; + } + + // Safely get path + Lucene::String pathField; + try { + pathField = doc->get(LuceneFieldNames::OcrText::kPath); + if (pathField.empty()) { + qWarning() << "Document missing path field at index:" << scoreDoc->doc; + continue; + } + } catch (const std::exception &e) { + qWarning() << "Exception retrieving path field:" << e.what(); + continue; + } + + QString path = QString::fromStdWString(pathField); + + if (!path.startsWith(searchPath)) { + continue; + } + + if (std::any_of(searchExcludedPaths.cbegin(), searchExcludedPaths.cend(), + [&path](const auto &excluded) { return path.startsWith(excluded); })) { + continue; + } + + // Safely check hidden status + if (Q_LIKELY(!m_options.includeHidden())) { + try { + Lucene::String hiddenField = doc->get(LuceneFieldNames::OcrText::kIsHidden); + if (!hiddenField.empty() && QString::fromStdWString(hiddenField).toLower() == "y") { + continue; + } + } catch (const std::exception &e) { + qWarning() << "Exception retrieving is_hidden field:" << e.what(); + // Default to visible if field can't be read + } + } + + // Create search result + SearchResult result(path); + + // Add to result collection + m_results.append(result); + + // Real-time result emission + if (Q_UNLIKELY(m_options.resultFoundEnabled())) + emit resultFound(result); + + } catch (const Lucene::LuceneException &e) { + qWarning() << "Error processing result:" << QString::fromStdWString(e.getError()); + continue; + } catch (const std::exception &e) { + qWarning() << "Standard exception:" << e.what(); + continue; + } catch (...) { + qWarning() << "Unknown exception during result processing"; + continue; + } + } + + qInfo() << "OCR text result processing time:" << resultTimer.elapsed() << "ms"; + emit searchFinished(m_results); +} + +void OcrTextIndexedStrategy::performOcrTextSearch(const SearchQuery &query) +{ + // RAII guard: automatically manage cancellation flag lifecycle + SearchCancellationGuard guard(&m_cancelled); + + try { + // Get index directory + FSDirectoryPtr directory = FSDirectory::open(m_indexDir.toStdWString()); + if (!directory) { + qWarning() << "Failed to open OCR text index directory:" << m_indexDir; + emit errorOccurred(SearchError(OcrTextSearchErrorCode::OcrTextIndexNotFound)); + return; + } + + // Get index reader + IndexReaderPtr reader = IndexReader::open(directory, true); + if (!reader || reader->numDocs() == 0) { + qWarning() << "OCR text index is empty or cannot be opened"; + emit errorOccurred(SearchError(OcrTextSearchErrorCode::OcrTextIndexNotFound)); + return; + } + + // Create searcher + IndexSearcherPtr searcher = newLucene<IndexSearcher>(reader); + + // Create analyzer (reuse ChineseAnalyzer for OCR text) + AnalyzerPtr analyzer = newLucene<ChineseAnalyzer>(); + + // Build query + m_currentQuery = buildLuceneQuery(query, analyzer, m_options.searchPath()); + if (!m_currentQuery) { + qWarning() << "Failed to build Lucene query for OCR text search"; + emit errorOccurred(SearchError(OcrTextSearchErrorCode::OcrTextIndexException)); + return; + } + + // Execute search + QElapsedTimer searchTimer; + searchTimer.start(); + + int32_t maxResults = m_options.maxResults() > 0 ? m_options.maxResults() : reader->numDocs(); + + // Use custom CancellableCollector for interruptible search + Collection<ScoreDocPtr> scoreDocs; + try { + // Create cancellable collector + boost::shared_ptr<CancellableCollector> collector = newLucene<CancellableCollector>(&m_cancelled, maxResults); + + // Execute search with custom collector + qInfo() << "OCR text search execution start:" << query.keyword(); + searcher->search(m_currentQuery, collector); + // Get collected documents + scoreDocs = collector->getScoreDocs(); + + qInfo() << "OCR text search execution time:" << searchTimer.elapsed() << "ms" + << "Total hits:" << collector->getTotalHits() + << "Collected:" << scoreDocs.size() + << "Keyword:" << query.keyword() + << "Cancelled" << m_cancelled.load(); + } catch (const SearchCancelledException &e) { + qInfo() << "OCR text search cancelled during execution"; + emit searchFinished(m_results); + return; + } catch (const RuntimeException &e) { +#if LUCENE_HAS_SEARCH_CANCELLATION + // Check if this is a cancellation exception thrown in ExactPhraseScorer::phraseFreq() + QString errorMsg = QString::fromStdWString(e.getError()); + if (errorMsg.contains("cancelled", Qt::CaseInsensitive)) { + qInfo() << "OCR text search cancelled in phraseFreq():" << errorMsg; + emit searchFinished(m_results); + return; + } +#endif + // Other runtime exceptions, rethrow + throw; + } + + // Process search results + processSearchResults(searcher, scoreDocs); + } catch (const LuceneException &e) { + qWarning() << "Lucene search exception:" << QString::fromStdWString(e.getError()); + emit errorOccurred(SearchError(OcrTextSearchErrorCode::OcrTextIndexException)); + } catch (const std::exception &e) { + qWarning() << "Standard exception:" << e.what(); + emit errorOccurred(SearchError(OcrTextSearchErrorCode::OcrTextIndexException)); + } +} + +void OcrTextIndexedStrategy::cancel() +{ + m_cancelled.store(true); +} + +DFM_SEARCH_END_NS diff --git a/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextstrategies/indexedstrategy.h b/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextstrategies/indexedstrategy.h new file mode 100644 index 00000000..b09aead1 --- /dev/null +++ b/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextstrategies/indexedstrategy.h @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later +#ifndef OCRTEXT_INDEXED_STRATEGY_H +#define OCRTEXT_INDEXED_STRATEGY_H + +#include "basestrategy.h" + +#include <lucene++/LuceneHeaders.h> +#include <lucene++/QueryParser.h> +#include <lucene++/BooleanQuery.h> +#include <lucene++/QueryWrapperFilter.h> +#include <lucene++/WildcardQuery.h> + +DFM_SEARCH_BEGIN_NS + +/** + * @brief OCR text index search strategy + * + * This strategy searches for text extracted from images using OCR technology. + * It uses a Lucene index similar to content search, but with simplified logic + * (no highlighting support). + */ +class OcrTextIndexedStrategy : public OcrTextBaseStrategy +{ + Q_OBJECT + +public: + explicit OcrTextIndexedStrategy(const SearchOptions &options, QObject *parent = nullptr); + ~OcrTextIndexedStrategy() override; + + void search(const SearchQuery &query) override; + void cancel() override; + +private: + // Initialize index directory + void initializeIndexing(); + + // Perform OCR text search + void performOcrTextSearch(const SearchQuery &query); + + // Build Lucene query + Lucene::QueryPtr buildLuceneQuery(const SearchQuery &query, const Lucene::AnalyzerPtr &analyzer, const QString &searchPath); + + // Helper for simple queries + Lucene::QueryPtr buildSimpleOcrContentsQuery( + const SearchQuery &query, + const Lucene::QueryParserPtr &ocrContentsParser); + + // Helper for "standard" boolean logic + Lucene::QueryPtr buildStandardBooleanOcrContentsQuery( + const SearchQuery &query, + const Lucene::QueryParserPtr &ocrContentsParser); + + // Helper for "advanced" mixed AND logic (searches "ocr_contents" and "filename") + Lucene::QueryPtr buildAdvancedAndQuery( + const SearchQuery &query, + const Lucene::QueryParserPtr &ocrContentsParser, + const Lucene::AnalyzerPtr &analyzer); + + // Process search results + void processSearchResults(const Lucene::IndexSearcherPtr &searcher, + const Lucene::Collection<Lucene::ScoreDocPtr> &scoreDocs); + + QString m_indexDir; + Lucene::QueryPtr m_currentQuery; + QStringList m_keywords; +}; + +DFM_SEARCH_END_NS + +#endif // OCRTEXT_INDEXED_STRATEGY_H diff --git a/src/dfm-search/dfm-search-lib/utils/searchutility.cpp b/src/dfm-search/dfm-search-lib/utils/searchutility.cpp index 7984c37e..93aa37a0 100644 --- a/src/dfm-search/dfm-search-lib/utils/searchutility.cpp +++ b/src/dfm-search/dfm-search-lib/utils/searchutility.cpp @@ -28,6 +28,7 @@ namespace Global { namespace IndexVersionThresholds { constexpr int FILENAME_ANCESTOR_PATHS = 3; constexpr int CONTENT_ANCESTOR_PATHS = 1; +constexpr int OCRTEXT_ANCESTOR_PATHS = 1; } /** @@ -627,6 +628,57 @@ QString contentIndexDirectory() return indexPath; } +QString ocrTextIndexDirectory() +{ + QString dataDir = + QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation); + QDir deepinDir(dataDir); + QString indexPath = deepinDir.filePath("deepin/dde-file-manager/ocrtext-index"); + return indexPath; +} + +bool isPathInOcrTextIndexDirectory(const QString &path) +{ + if (!isOcrTextIndexAvailable()) + return false; + + const QStringList &dirs = defaultIndexedDirectory(); + return std::any_of(dirs.cbegin(), dirs.cend(), + [&path](const QString &dir) { return path.startsWith(dir); }); +} + +bool isOcrTextIndexAvailable() +{ + const QString &dir = ocrTextIndexDirectory(); + if (!IndexReader::indexExists(FSDirectory::open(dir.toStdWString()))) + return false; + + const QString &statusFile = dir + "/index_status.json"; + + // 1. 尝试打开文件 + QFile file(statusFile); + if (!file.open(QIODevice::ReadOnly)) { + return false; // 文件无法打开 + } + + // 2. 读取并解析 JSON + QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + file.close(); + + if (doc.isNull() || !doc.isObject()) { + return false; // JSON 格式无效 + } + + // 3. 检查 lastUpdateTime 字段 + QJsonObject obj = doc.object(); + if (!obj.contains("lastUpdateTime")) { + return false; // 字段不存在 + } + + const QString lastUpdateTime = obj["lastUpdateTime"].toString(); + return !lastUpdateTime.isEmpty(); // 字段值非空则为有效 +} + bool isPathInFileNameIndexDirectory(const QString &path) { if (!isFileNameIndexDirectoryAvailable()) @@ -740,6 +792,11 @@ int contentIndexVersion() return readIndexVersion(contentIndexDirectory(), "index_status.json"); } +int ocrTextIndexVersion() +{ + return readIndexVersion(ocrTextIndexDirectory(), "index_status.json"); +} + } // namespace Global namespace SearchUtility { @@ -754,6 +811,11 @@ bool isContentIndexAncestorPathsSupported() return Global::contentIndexVersion() > Global::IndexVersionThresholds::CONTENT_ANCESTOR_PATHS; } +bool isOcrTextIndexAncestorPathsSupported() +{ + return Global::ocrTextIndexVersion() > Global::IndexVersionThresholds::OCRTEXT_ANCESTOR_PATHS; +} + QStringList extractBooleanKeywords(const SearchQuery &query) { QStringList keywords; diff --git a/src/dfm-search/dfm-search-lib/utils/searchutility.h b/src/dfm-search/dfm-search-lib/utils/searchutility.h index 2c0407c0..1fa74fd8 100644 --- a/src/dfm-search/dfm-search-lib/utils/searchutility.h +++ b/src/dfm-search/dfm-search-lib/utils/searchutility.h @@ -54,6 +54,13 @@ bool isFilenameIndexAncestorPathsSupported(); */ bool isContentIndexAncestorPathsSupported(); +/** + * @brief Check if the OCR text index supports the ancestor_paths field. + * This function checks the OCR text index version and returns true if the version supports ancestor_paths. + * @return true if the OCR text index supports ancestor_paths, false otherwise. + */ +bool isOcrTextIndexAncestorPathsSupported(); + } // namespace SearchUtility DFM_SEARCH_END_NS From f1fa57570bfce80ba81085d9906e6b721bb9cc60 Mon Sep 17 00:00:00 2001 From: Zhang Sheng <zhangsheng@uniontech.com> Date: Wed, 8 Apr 2026 10:55:06 +0800 Subject: [PATCH 07/21] chore(task): archive 04-08-add-ocr-search --- .../2026-04/04-08-add-ocr-search/check.jsonl | 2 + .../2026-04/04-08-add-ocr-search/debug.jsonl | 1 + .../04-08-add-ocr-search/implement.jsonl | 5 + .../2026-04/04-08-add-ocr-search/prd.md | 110 ++++++++++++++++++ .../2026-04/04-08-add-ocr-search/task.json | 44 +++++++ 5 files changed, 162 insertions(+) create mode 100644 .trellis/tasks/archive/2026-04/04-08-add-ocr-search/check.jsonl create mode 100644 .trellis/tasks/archive/2026-04/04-08-add-ocr-search/debug.jsonl create mode 100644 .trellis/tasks/archive/2026-04/04-08-add-ocr-search/implement.jsonl create mode 100644 .trellis/tasks/archive/2026-04/04-08-add-ocr-search/prd.md create mode 100644 .trellis/tasks/archive/2026-04/04-08-add-ocr-search/task.json diff --git a/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/check.jsonl b/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/check.jsonl new file mode 100644 index 00000000..26c2ed68 --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/check.jsonl @@ -0,0 +1,2 @@ +{"file": ".claude/commands/trellis/finish-work.md", "reason": "Finish work checklist"} +{"file": ".claude/commands/trellis/check-backend.md", "reason": "Backend check spec"} diff --git a/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/debug.jsonl b/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/debug.jsonl new file mode 100644 index 00000000..8d2740ff --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/debug.jsonl @@ -0,0 +1 @@ +{"file": ".claude/commands/trellis/check-backend.md", "reason": "Backend check spec"} diff --git a/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/implement.jsonl b/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/implement.jsonl new file mode 100644 index 00000000..c0abad0a --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/implement.jsonl @@ -0,0 +1,5 @@ +{"file": ".trellis/workflow.md", "reason": "Project workflow and conventions"} +{"file": ".trellis/spec/backend/index.md", "reason": "Backend development guide"} +{"file": ".trellis/spec/dfm-search/error-handling.md", "reason": "错误处理规范"} +{"file": ".trellis/spec/dfm-search/naming-conventions.md", "reason": "命名规范"} +{"file": ".trellis/spec/shared/cpp-conventions.md", "reason": "C++编码约定"} diff --git a/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/prd.md b/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/prd.md new file mode 100644 index 00000000..ce05a60f --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/prd.md @@ -0,0 +1,110 @@ +# Add OCR Search Type to dfm-search + +## Goal + +在 dfm-search 库中新增 OCR 搜索类型,支持对图片中识别出的文字进行全文搜索。OCR 搜索作为 Content 搜索的简化版,只支持索引策略,不支持高亮返回。 + +## Requirements + +### 1. 枚举和常量定义 + +- [ ] 在 `SearchType` 枚举中新增 `Ocr` 类型 +- [ ] 在 `lucene_field_names.h` 中新增 `LuceneFieldNames::OcrText` 命名空间 +- [ ] 在 `searcherror.h` 中新增 `OcrSearchErrorCode` 枚举 + +### 2. OCR 搜索模块结构 + +参考 `contentsearch` 目录结构,创建 `ocrtextsearch` 模块: + +``` +src/dfm-search/dfm-search-lib/ocrtextsearch/ +├── ocrtextsearchengine.h/.cpp # OCR 搜索引擎 +├── ocrtextsearchapi.cpp # API 实现 +└── ocrtextstrategies/ + ├── basestrategy.h # 策略基类 + └── indexedstrategy.h/.cpp # 索引策略实现 + +include/dfm-search/dfm-search/ +└── ocrtextsearchapi.h # 公共 API 头文件 +``` + +### 3. 功能实现 + +#### OcrTextIndexedStrategy + +- 参考 `ContentIndexedStrategy` 实现 +- 使用已定义的 OCR 索引路径(`Global::ocrTextIndexDirectory()`) +- 支持 `isFilenameContentMixedAndSearchEnabled` 混合搜索 +- 复用现有的 Lucene 查询工具和分析器 + +#### OcrTextSearchEngine + +- 继承 `GenericSearchEngine` +- 仅支持索引策略 +- 注册到 `SearchFactory` + +#### OcrTextOptionsAPI + +- 参考 `ContentOptionsAPI` 实现 +- 支持混合搜索选项 + +### 4. Lucene 字段定义 + +新增 OCR 字段(与 Content 结构一致): + +```cpp +namespace OcrText { + constexpr const wchar_t kOcrContents[] = L"ocr_contents"; + constexpr const wchar_t kFilename[] = L"filename"; + constexpr const wchar_t kPath[] = L"path"; + constexpr const wchar_t kIsHidden[] = L"is_hidden"; + constexpr const wchar_t kAncestorPaths[] = L"ancestor_paths"; +} +``` + +### 5. 错误码定义 + +新增 OCR 搜索错误码范围:3000-3999 + +```cpp +enum OcrSearchErrorCode { + OcrIndexNotAvailable = 3000, + OcrIndexPathError = 3001, + OcrQueryError = 3002, + // ... +}; +``` + +## Acceptance Criteria + +- [ ] `SearchType::Ocr` 枚举值已添加 +- [ ] OCR 搜索模块目录结构完整 +- [ ] `OcrTextIndexedStrategy` 可执行搜索 +- [ ] 支持文件名+OCR内容混合搜索 +- [ ] 通过工厂创建 OCR 搜索引擎 +- [ ] 编译无错误 +- [ ] 代码风格符合项目规范 + +## Technical Notes + +### OCR 与 Content 的差异 + +| 项目 | Content | OCR | +|------|---------|-----| +| 索引目录 | fulltext-index | ocrtext-index | +| 内容字段 | `contents` | `ocr_contents` | +| 文件类型 | 文档 | 图片 | +| 高亮 | 支持 | 不需要 | + +### 已有接口(可复用) + +- `Global::ocrTextIndexDirectory()` - OCR 索引目录 +- `Global::isOcrTextIndexAvailable()` - 索引可用性检查 +- `Global::isPathInOcrTextIndexDirectory()` - 路径检查 +- `Global::ocrTextIndexVersion()` - 索引版本 + +### 设计原则 + +1. **DRY**: 复用 Content 搜索的查询构建逻辑 +2. **KISS**: OCR 作为简化版,不需要高亮等复杂特性 +3. **YAGNI**: 仅实现索引策略,不预留实时策略扩展 diff --git a/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/task.json b/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/task.json new file mode 100644 index 00000000..40337be0 --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/task.json @@ -0,0 +1,44 @@ +{ + "id": "add-ocr-search", + "name": "add-ocr-search", + "title": "Add OCR search type to dfm-search", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "priority": "P2", + "creator": "Zhang Sheng", + "assignee": "Zhang Sheng", + "createdAt": "2026-04-08", + "completedAt": "2026-04-08", + "branch": null, + "base_branch": "feat_ocr", + "worktree_path": null, + "current_phase": 0, + "next_action": [ + { + "phase": 1, + "action": "implement" + }, + { + "phase": 2, + "action": "check" + }, + { + "phase": 3, + "action": "finish" + }, + { + "phase": 4, + "action": "create-pr" + } + ], + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file From a1528440e5be2686b50ae7ddb4a8de7449bcf8f6 Mon Sep 17 00:00:00 2001 From: Zhang Sheng <zhangsheng@uniontech.com> Date: Wed, 8 Apr 2026 11:09:38 +0800 Subject: [PATCH 08/21] chore: update gitignore as title Log: --- .agents/skills/before-backend-dev/SKILL.md | 18 - .agents/skills/before-frontend-dev/SKILL.md | 18 - .agents/skills/brainstorm/SKILL.md | 492 ------ .agents/skills/break-loop/SKILL.md | 130 -- .agents/skills/check-backend/SKILL.md | 18 - .agents/skills/check-cross-layer/SKILL.md | 158 -- .agents/skills/check-frontend/SKILL.md | 18 - .agents/skills/create-command/SKILL.md | 101 -- .agents/skills/finish-work/SKILL.md | 148 -- .agents/skills/integrate-skill/SKILL.md | 221 --- .agents/skills/onboard/SKILL.md | 363 ----- .agents/skills/record-session/SKILL.md | 66 - .agents/skills/start/SKILL.md | 346 ----- .agents/skills/update-spec/SKILL.md | 335 ---- .claude/agents/check.md | 122 -- .claude/agents/debug.md | 106 -- .claude/agents/dispatch.md | 214 --- .claude/agents/implement.md | 96 -- .claude/agents/plan.md | 396 ----- .claude/agents/research.md | 120 -- .../commands/trellis/before-backend-dev.md | 13 - .../commands/trellis/before-frontend-dev.md | 13 - .claude/commands/trellis/brainstorm.md | 487 ------ .claude/commands/trellis/break-loop.md | 125 -- .claude/commands/trellis/check-backend.md | 13 - .claude/commands/trellis/check-cross-layer.md | 153 -- .claude/commands/trellis/check-frontend.md | 13 - .claude/commands/trellis/create-command.md | 154 -- .claude/commands/trellis/finish-work.md | 153 -- .claude/commands/trellis/integrate-skill.md | 219 --- .claude/commands/trellis/onboard.md | 358 ----- .claude/commands/trellis/parallel.md | 193 --- .claude/commands/trellis/record-session.md | 61 - .claude/commands/trellis/start.md | 389 ----- .claude/commands/trellis/update-spec.md | 354 ----- .claude/hooks/inject-subagent-context.py | 788 ---------- .claude/hooks/ralph-loop.py | 388 ----- .claude/hooks/session-start.py | 205 --- .claude/settings.json | 71 - .gitignore | 7 + .trellis/.gitignore | 29 - .trellis/.template-hashes.json | 68 - .trellis/.version | 1 - .trellis/config.yaml | 33 - .trellis/scripts/__init__.py | 5 - .trellis/scripts/add_session.py | 423 ----- .trellis/scripts/common/__init__.py | 82 - .trellis/scripts/common/cli_adapter.py | 628 -------- .trellis/scripts/common/config.py | 72 - .trellis/scripts/common/developer.py | 190 --- .trellis/scripts/common/git_context.py | 641 -------- .trellis/scripts/common/paths.py | 347 ----- .trellis/scripts/common/phase.py | 253 --- .trellis/scripts/common/registry.py | 366 ----- .trellis/scripts/common/task_queue.py | 259 ---- .trellis/scripts/common/task_utils.py | 178 --- .trellis/scripts/common/worktree.py | 305 ---- .trellis/scripts/create_bootstrap.py | 293 ---- .trellis/scripts/get_context.py | 16 - .trellis/scripts/get_developer.py | 26 - .trellis/scripts/init_developer.py | 51 - .trellis/scripts/multi_agent/__init__.py | 5 - .trellis/scripts/multi_agent/cleanup.py | 403 ----- .trellis/scripts/multi_agent/create_pr.py | 329 ---- .trellis/scripts/multi_agent/plan.py | 236 --- .trellis/scripts/multi_agent/start.py | 465 ------ .trellis/scripts/multi_agent/status.py | 817 ---------- .trellis/scripts/task.py | 1370 ----------------- .trellis/spec/README.md | 111 -- .../spec/backend/architecture-decisions.md | 195 --- .trellis/spec/backend/architecture-design.md | 293 ---- .trellis/spec/backend/bug-fix-standards.md | 223 --- .trellis/spec/backend/cmake-conventions.md | 289 ---- .trellis/spec/backend/index.md | 89 -- .trellis/spec/backend/plugin-architecture.md | 293 ---- .trellis/spec/backend/qt-dtk-guide.md | 507 ------ .../backend/reference/dbus-service-usage.md | 313 ---- .../backend/reference/dtk-widgets-guide.md | 202 --- .../backend/reference/gvfs-gio-integration.md | 295 ---- .../backend/reference/polkit-auth-workflow.md | 304 ---- .trellis/spec/backend/test-standards.md | 379 ----- .../spec/big-question/dbus-async-vs-sync.md | 261 ---- .../big-question/gthread-ui-thread-safety.md | 232 --- .../big-question/gvfs-mount-path-issues.md | 223 --- .trellis/spec/big-question/index.md | 49 - .../qt-memory-management-pitfalls.md | 204 --- .trellis/spec/dfm-burn/error-handling.md | 203 --- .trellis/spec/dfm-burn/index.md | 40 - .trellis/spec/dfm-burn/naming-conventions.md | 171 -- .trellis/spec/dfm-io/error-handling.md | 228 --- .trellis/spec/dfm-io/index.md | 41 - .trellis/spec/dfm-io/naming-conventions.md | 198 --- .trellis/spec/dfm-mount/error-handling.md | 259 ---- .trellis/spec/dfm-mount/index.md | 44 - .trellis/spec/dfm-mount/naming-conventions.md | 192 --- .trellis/spec/dfm-search/error-handling.md | 271 ---- .trellis/spec/dfm-search/index.md | 42 - .../spec/dfm-search/naming-conventions.md | 247 --- .trellis/spec/guides/index.md | 73 - .trellis/spec/guides/root-cause-analysis.md | 186 --- .trellis/spec/review/code-review-standards.md | 373 ----- .trellis/spec/review/index.md | 112 -- .../reference/architecture-review-guide.md | 472 ------ .trellis/spec/review/reference/c.md | 285 ---- .../reference/code-review-best-practices.md | 136 -- .../review/reference/common-bugs-checklist.md | 1227 --------------- .trellis/spec/review/reference/cpp.md | 385 ----- .../reference/performance-review-guide.md | 752 --------- .trellis/spec/review/reference/qt.md | 186 --- .../review/reference/security-review-guide.md | 265 ---- .trellis/spec/shared/cpp-conventions.md | 605 -------- .trellis/spec/shared/dbus-conventions.md | 437 ------ .trellis/spec/shared/deepin-terminology.md | 215 --- .trellis/spec/shared/git-conventions.md | 272 ---- .trellis/spec/shared/index.md | 59 - .trellis/spec/shared/internationalization.md | 327 ---- .../2026-03/00-bootstrap-guidelines/prd.md | 127 -- .../2026-03/00-bootstrap-guidelines/task.json | 35 - .../2026-04/04-08-add-ocr-search/check.jsonl | 2 - .../2026-04/04-08-add-ocr-search/debug.jsonl | 1 - .../04-08-add-ocr-search/implement.jsonl | 5 - .../2026-04/04-08-add-ocr-search/prd.md | 110 -- .../2026-04/04-08-add-ocr-search/task.json | 44 - .trellis/workflow.md | 416 ----- .trellis/workspace/Zhang Sheng/index.md | 41 - .trellis/workspace/Zhang Sheng/journal-1.md | 108 -- .trellis/workspace/index.md | 123 -- .trellis/worktree.yaml | 47 - AGENTS.md | 18 - 129 files changed, 7 insertions(+), 29369 deletions(-) delete mode 100644 .agents/skills/before-backend-dev/SKILL.md delete mode 100644 .agents/skills/before-frontend-dev/SKILL.md delete mode 100644 .agents/skills/brainstorm/SKILL.md delete mode 100644 .agents/skills/break-loop/SKILL.md delete mode 100644 .agents/skills/check-backend/SKILL.md delete mode 100644 .agents/skills/check-cross-layer/SKILL.md delete mode 100644 .agents/skills/check-frontend/SKILL.md delete mode 100644 .agents/skills/create-command/SKILL.md delete mode 100644 .agents/skills/finish-work/SKILL.md delete mode 100644 .agents/skills/integrate-skill/SKILL.md delete mode 100644 .agents/skills/onboard/SKILL.md delete mode 100644 .agents/skills/record-session/SKILL.md delete mode 100644 .agents/skills/start/SKILL.md delete mode 100644 .agents/skills/update-spec/SKILL.md delete mode 100644 .claude/agents/check.md delete mode 100644 .claude/agents/debug.md delete mode 100644 .claude/agents/dispatch.md delete mode 100644 .claude/agents/implement.md delete mode 100644 .claude/agents/plan.md delete mode 100644 .claude/agents/research.md delete mode 100644 .claude/commands/trellis/before-backend-dev.md delete mode 100644 .claude/commands/trellis/before-frontend-dev.md delete mode 100644 .claude/commands/trellis/brainstorm.md delete mode 100644 .claude/commands/trellis/break-loop.md delete mode 100644 .claude/commands/trellis/check-backend.md delete mode 100644 .claude/commands/trellis/check-cross-layer.md delete mode 100644 .claude/commands/trellis/check-frontend.md delete mode 100644 .claude/commands/trellis/create-command.md delete mode 100644 .claude/commands/trellis/finish-work.md delete mode 100644 .claude/commands/trellis/integrate-skill.md delete mode 100644 .claude/commands/trellis/onboard.md delete mode 100644 .claude/commands/trellis/parallel.md delete mode 100644 .claude/commands/trellis/record-session.md delete mode 100644 .claude/commands/trellis/start.md delete mode 100644 .claude/commands/trellis/update-spec.md delete mode 100644 .claude/hooks/inject-subagent-context.py delete mode 100644 .claude/hooks/ralph-loop.py delete mode 100644 .claude/hooks/session-start.py delete mode 100644 .claude/settings.json delete mode 100644 .trellis/.gitignore delete mode 100644 .trellis/.template-hashes.json delete mode 100644 .trellis/.version delete mode 100644 .trellis/config.yaml delete mode 100755 .trellis/scripts/__init__.py delete mode 100755 .trellis/scripts/add_session.py delete mode 100755 .trellis/scripts/common/__init__.py delete mode 100755 .trellis/scripts/common/cli_adapter.py delete mode 100755 .trellis/scripts/common/config.py delete mode 100755 .trellis/scripts/common/developer.py delete mode 100755 .trellis/scripts/common/git_context.py delete mode 100755 .trellis/scripts/common/paths.py delete mode 100755 .trellis/scripts/common/phase.py delete mode 100755 .trellis/scripts/common/registry.py delete mode 100755 .trellis/scripts/common/task_queue.py delete mode 100755 .trellis/scripts/common/task_utils.py delete mode 100755 .trellis/scripts/common/worktree.py delete mode 100755 .trellis/scripts/create_bootstrap.py delete mode 100755 .trellis/scripts/get_context.py delete mode 100755 .trellis/scripts/get_developer.py delete mode 100755 .trellis/scripts/init_developer.py delete mode 100755 .trellis/scripts/multi_agent/__init__.py delete mode 100755 .trellis/scripts/multi_agent/cleanup.py delete mode 100755 .trellis/scripts/multi_agent/create_pr.py delete mode 100755 .trellis/scripts/multi_agent/plan.py delete mode 100755 .trellis/scripts/multi_agent/start.py delete mode 100755 .trellis/scripts/multi_agent/status.py delete mode 100755 .trellis/scripts/task.py delete mode 100644 .trellis/spec/README.md delete mode 100644 .trellis/spec/backend/architecture-decisions.md delete mode 100644 .trellis/spec/backend/architecture-design.md delete mode 100644 .trellis/spec/backend/bug-fix-standards.md delete mode 100644 .trellis/spec/backend/cmake-conventions.md delete mode 100644 .trellis/spec/backend/index.md delete mode 100644 .trellis/spec/backend/plugin-architecture.md delete mode 100644 .trellis/spec/backend/qt-dtk-guide.md delete mode 100644 .trellis/spec/backend/reference/dbus-service-usage.md delete mode 100644 .trellis/spec/backend/reference/dtk-widgets-guide.md delete mode 100644 .trellis/spec/backend/reference/gvfs-gio-integration.md delete mode 100644 .trellis/spec/backend/reference/polkit-auth-workflow.md delete mode 100644 .trellis/spec/backend/test-standards.md delete mode 100644 .trellis/spec/big-question/dbus-async-vs-sync.md delete mode 100644 .trellis/spec/big-question/gthread-ui-thread-safety.md delete mode 100644 .trellis/spec/big-question/gvfs-mount-path-issues.md delete mode 100644 .trellis/spec/big-question/index.md delete mode 100644 .trellis/spec/big-question/qt-memory-management-pitfalls.md delete mode 100644 .trellis/spec/dfm-burn/error-handling.md delete mode 100644 .trellis/spec/dfm-burn/index.md delete mode 100644 .trellis/spec/dfm-burn/naming-conventions.md delete mode 100644 .trellis/spec/dfm-io/error-handling.md delete mode 100644 .trellis/spec/dfm-io/index.md delete mode 100644 .trellis/spec/dfm-io/naming-conventions.md delete mode 100644 .trellis/spec/dfm-mount/error-handling.md delete mode 100644 .trellis/spec/dfm-mount/index.md delete mode 100644 .trellis/spec/dfm-mount/naming-conventions.md delete mode 100644 .trellis/spec/dfm-search/error-handling.md delete mode 100644 .trellis/spec/dfm-search/index.md delete mode 100644 .trellis/spec/dfm-search/naming-conventions.md delete mode 100644 .trellis/spec/guides/index.md delete mode 100644 .trellis/spec/guides/root-cause-analysis.md delete mode 100644 .trellis/spec/review/code-review-standards.md delete mode 100644 .trellis/spec/review/index.md delete mode 100644 .trellis/spec/review/reference/architecture-review-guide.md delete mode 100644 .trellis/spec/review/reference/c.md delete mode 100644 .trellis/spec/review/reference/code-review-best-practices.md delete mode 100644 .trellis/spec/review/reference/common-bugs-checklist.md delete mode 100644 .trellis/spec/review/reference/cpp.md delete mode 100644 .trellis/spec/review/reference/performance-review-guide.md delete mode 100644 .trellis/spec/review/reference/qt.md delete mode 100644 .trellis/spec/review/reference/security-review-guide.md delete mode 100644 .trellis/spec/shared/cpp-conventions.md delete mode 100644 .trellis/spec/shared/dbus-conventions.md delete mode 100644 .trellis/spec/shared/deepin-terminology.md delete mode 100644 .trellis/spec/shared/git-conventions.md delete mode 100644 .trellis/spec/shared/index.md delete mode 100644 .trellis/spec/shared/internationalization.md delete mode 100644 .trellis/tasks/archive/2026-03/00-bootstrap-guidelines/prd.md delete mode 100644 .trellis/tasks/archive/2026-03/00-bootstrap-guidelines/task.json delete mode 100644 .trellis/tasks/archive/2026-04/04-08-add-ocr-search/check.jsonl delete mode 100644 .trellis/tasks/archive/2026-04/04-08-add-ocr-search/debug.jsonl delete mode 100644 .trellis/tasks/archive/2026-04/04-08-add-ocr-search/implement.jsonl delete mode 100644 .trellis/tasks/archive/2026-04/04-08-add-ocr-search/prd.md delete mode 100644 .trellis/tasks/archive/2026-04/04-08-add-ocr-search/task.json delete mode 100644 .trellis/workflow.md delete mode 100644 .trellis/workspace/Zhang Sheng/index.md delete mode 100644 .trellis/workspace/Zhang Sheng/journal-1.md delete mode 100644 .trellis/workspace/index.md delete mode 100644 .trellis/worktree.yaml delete mode 100644 AGENTS.md diff --git a/.agents/skills/before-backend-dev/SKILL.md b/.agents/skills/before-backend-dev/SKILL.md deleted file mode 100644 index 0615694c..00000000 --- a/.agents/skills/before-backend-dev/SKILL.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -name: before-backend-dev -description: "Read the backend development guidelines before starting your development task." ---- - -Read the backend development guidelines before starting your development task. - -Execute these steps: -1. Read `.trellis/spec/backend/index.md` to understand available guidelines -2. Based on your task, read the relevant guideline files: - - Database work → `.trellis/spec/backend/database-guidelines.md` - - Error handling → `.trellis/spec/backend/error-handling.md` - - Logging → `.trellis/spec/backend/logging-guidelines.md` - - Type questions → `.trellis/spec/backend/type-safety.md` -3. Understand the coding standards and patterns you need to follow -4. Then proceed with your development plan - -This step is **mandatory** before writing any backend code. diff --git a/.agents/skills/before-frontend-dev/SKILL.md b/.agents/skills/before-frontend-dev/SKILL.md deleted file mode 100644 index b048b8db..00000000 --- a/.agents/skills/before-frontend-dev/SKILL.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -name: before-frontend-dev -description: "Read the frontend development guidelines before starting your development task." ---- - -Read the frontend development guidelines before starting your development task. - -Execute these steps: -1. Read `.trellis/spec/frontend/index.md` to understand available guidelines -2. Based on your task, read the relevant guideline files: - - Component work → `.trellis/spec/frontend/component-guidelines.md` - - Hook work → `.trellis/spec/frontend/hook-guidelines.md` - - State management → `.trellis/spec/frontend/state-management.md` - - Type questions → `.trellis/spec/frontend/type-safety.md` -3. Understand the coding standards and patterns you need to follow -4. Then proceed with your development plan - -This step is **mandatory** before writing any frontend code. diff --git a/.agents/skills/brainstorm/SKILL.md b/.agents/skills/brainstorm/SKILL.md deleted file mode 100644 index e26005dc..00000000 --- a/.agents/skills/brainstorm/SKILL.md +++ /dev/null @@ -1,492 +0,0 @@ ---- -name: brainstorm -description: "Brainstorm - Requirements Discovery (AI Coding Enhanced)" ---- - -# Brainstorm - Requirements Discovery (AI Coding Enhanced) - -Guide AI through collaborative requirements discovery **before implementation**, optimized for AI coding workflows: - -* **Task-first** (capture ideas immediately) -* **Action-before-asking** (reduce low-value questions) -* **Research-first** for technical choices (avoid asking users to invent options) -* **Diverge → Converge** (expand thinking, then lock MVP) - ---- - -## When to Use - -Triggered from `$start` when the user describes a development task, especially when: - -* requirements are unclear or evolving -* there are multiple valid implementation paths -* trade-offs matter (UX, reliability, maintainability, cost, performance) -* the user might not know the best options up front - ---- - -## Core Principles (Non-negotiable) - -1. **Task-first (capture early)** - Always ensure a task exists at the start so the user's ideas are recorded immediately. - -2. **Action before asking** - If you can derive the answer from repo code, docs, configs, conventions, or quick research — do that first. - -3. **One question per message** - Never overwhelm the user with a list of questions. Ask one, update PRD, repeat. - -4. **Prefer concrete options** - For preference/decision questions, present 2–3 feasible, specific approaches with trade-offs. - -5. **Research-first for technical choices** - If the decision depends on industry conventions / similar tools / established patterns, do research first, then propose options. - -6. **Diverge → Converge** - After initial understanding, proactively consider future evolution, related scenarios, and failure/edge cases — then converge to an MVP with explicit out-of-scope. - -7. **No meta questions** - Do not ask "should I search?" or "can you paste the code so I can continue?" - If you need information: search/inspect. If blocked: ask the minimal blocking question. - ---- - -## Step 0: Ensure Task Exists (ALWAYS) - -Before any Q&A, ensure a task exists. If none exists, create one immediately. - -* Use a **temporary working title** derived from the user's message. -* It's OK if the title is imperfect — refine later in PRD. - -```bash -TASK_DIR=$(python3 ./.trellis/scripts/task.py create "brainstorm: <short goal>" --slug <auto>) -``` - -Create/seed `prd.md` immediately with what you know: - -```markdown -# brainstorm: <short goal> - -## Goal - -<one paragraph: what + why> - -## What I already know - -* <facts from user message> -* <facts discovered from repo/docs> - -## Assumptions (temporary) - -* <assumptions to validate> - -## Open Questions - -* <ONLY Blocking / Preference questions; keep list short> - -## Requirements (evolving) - -* <start with what is known> - -## Acceptance Criteria (evolving) - -* [ ] <testable criterion> - -## Definition of Done (team quality bar) - -* Tests added/updated (unit/integration where appropriate) -* Lint / typecheck / CI green -* Docs/notes updated if behavior changes -* Rollout/rollback considered if risky - -## Out of Scope (explicit) - -* <what we will not do in this task> - -## Technical Notes - -* <files inspected, constraints, links, references> -* <research notes summary if applicable> -``` - ---- - -## Step 1: Auto-Context (DO THIS BEFORE ASKING QUESTIONS) - -Before asking questions like "what does the code look like?", gather context yourself: - -### Repo inspection checklist - -* Identify likely modules/files impacted -* Locate existing patterns (similar features, conventions, error handling style) -* Check configs, scripts, existing command definitions -* Note any constraints (runtime, dependency policy, build tooling) - -### Documentation checklist - -* Look for existing PRDs/specs/templates -* Look for command usage examples, README, ADRs if any - -Write findings into PRD: - -* Add to `What I already know` -* Add constraints/links to `Technical Notes` - ---- - -## Step 2: Classify Complexity (still useful, not gating task creation) - -| Complexity | Criteria | Action | -| ------------ | ------------------------------------------------------ | ------------------------------------------- | -| **Trivial** | Single-line fix, typo, obvious change | Skip brainstorm, implement directly | -| **Simple** | Clear goal, 1–2 files, scope well-defined | Ask 1 confirm question, then implement | -| **Moderate** | Multiple files, some ambiguity | Light brainstorm (2–3 high-value questions) | -| **Complex** | Vague goal, architectural choices, multiple approaches | Full brainstorm | - -> Note: Task already exists from Step 0. Classification only affects depth of brainstorming. - ---- - -## Step 3: Question Gate (Ask ONLY high-value questions) - -Before asking ANY question, run the following gate: - -### Gate A — Can I derive this without the user? - -If answer is available via: - -* repo inspection (code/config) -* docs/specs/conventions -* quick market/OSS research - -→ **Do not ask.** Fetch it, summarize, update PRD. - -### Gate B — Is this a meta/lazy question? - -Examples: - -* "Should I search?" -* "Can you paste the code so I can proceed?" -* "What does the code look like?" (when repo is available) - -→ **Do not ask.** Take action. - -### Gate C — What type of question is it? - -* **Blocking**: cannot proceed without user input -* **Preference**: multiple valid choices, depends on product/UX/risk preference -* **Derivable**: should be answered by inspection/research - -→ Only ask **Blocking** or **Preference**. - ---- - -## Step 4: Research-first Mode (Mandatory for technical choices) - -### Trigger conditions (any → research-first) - -* The task involves selecting an approach, library, protocol, framework, template system, plugin mechanism, or CLI UX convention -* The user asks for "best practice", "how others do it", "recommendation" -* The user can't reasonably enumerate options - -### Research steps - -1. Identify 2–4 comparable tools/patterns -2. Summarize common conventions and why they exist -3. Map conventions onto our repo constraints -4. Produce **2–3 feasible approaches** for our project - -### Research output format (PRD) - -Add a section in PRD (either within Technical Notes or as its own): - -```markdown -## Research Notes - -### What similar tools do - -* ... -* ... - -### Constraints from our repo/project - -* ... - -### Feasible approaches here - -**Approach A: <name>** (Recommended) - -* How it works: -* Pros: -* Cons: - -**Approach B: <name>** - -* How it works: -* Pros: -* Cons: - -**Approach C: <name>** (optional) - -* ... -``` - -Then ask **one** preference question: - -* "Which approach do you prefer: A / B / C (or other)?" - ---- - -## Step 5: Expansion Sweep (DIVERGE) — Required after initial understanding - -After you can summarize the goal, proactively broaden thinking before converging. - -### Expansion categories (keep to 1–2 bullets each) - -1. **Future evolution** - - * What might this feature become in 1–3 months? - * What extension points are worth preserving now? - -2. **Related scenarios** - - * What adjacent commands/flows should remain consistent with this? - * Are there parity expectations (create vs update, import vs export, etc.)? - -3. **Failure & edge cases** - - * Conflicts, offline/network failure, retries, idempotency, compatibility, rollback - * Input validation, security boundaries, permission checks - -### Expansion message template (to user) - -```markdown -I understand you want to implement: <current goal>. - -Before diving into design, let me quickly diverge to consider three categories (to avoid rework later): - -1. Future evolution: <1–2 bullets> -2. Related scenarios: <1–2 bullets> -3. Failure/edge cases: <1–2 bullets> - -For this MVP, which would you like to include (or none)? - -1. Current requirement only (minimal viable) -2. Add <X> (reserve for future extension) -3. Add <Y> (improve robustness/consistency) -4. Other: describe your preference -``` - -Then update PRD: - -* What's in MVP → `Requirements` -* What's excluded → `Out of Scope` - ---- - -## Step 6: Q&A Loop (CONVERGE) - -### Rules - -* One question per message -* Prefer multiple-choice when possible -* After each user answer: - - * Update PRD immediately - * Move answered items from `Open Questions` → `Requirements` - * Update `Acceptance Criteria` with testable checkboxes - * Clarify `Out of Scope` - -### Question priority (recommended) - -1. **MVP scope boundary** (what is included/excluded) -2. **Preference decisions** (after presenting concrete options) -3. **Failure/edge behavior** (only for MVP-critical paths) -4. **Success metrics & Acceptance Criteria** (what proves it works) - -### Preferred question format (multiple choice) - -```markdown -For <topic>, which approach do you prefer? - -1. **Option A** — <what it means + trade-off> -2. **Option B** — <what it means + trade-off> -3. **Option C** — <what it means + trade-off> -4. **Other** — describe your preference -``` - ---- - -## Step 7: Propose Approaches + Record Decisions (Complex tasks) - -After requirements are clear enough, propose 2–3 approaches (if not already done via research-first): - -```markdown -Based on current information, here are 2–3 feasible approaches: - -**Approach A: <name>** (Recommended) - -* How: -* Pros: -* Cons: - -**Approach B: <name>** - -* How: -* Pros: -* Cons: - -Which direction do you prefer? -``` - -Record the outcome in PRD as an ADR-lite section: - -```markdown -## Decision (ADR-lite) - -**Context**: Why this decision was needed -**Decision**: Which approach was chosen -**Consequences**: Trade-offs, risks, potential future improvements -``` - ---- - -## Step 8: Final Confirmation + Implementation Plan - -When open questions are resolved, confirm complete requirements with a structured summary: - -### Final confirmation format - -```markdown -Here's my understanding of the complete requirements: - -**Goal**: <one sentence> - -**Requirements**: - -* ... -* ... - -**Acceptance Criteria**: - -* [ ] ... -* [ ] ... - -**Definition of Done**: - -* ... - -**Out of Scope**: - -* ... - -**Technical Approach**: -<brief summary + key decisions> - -**Implementation Plan (small PRs)**: - -* PR1: <scaffolding + tests + minimal plumbing> -* PR2: <core behavior> -* PR3: <edge cases + docs + cleanup> - -Does this look correct? If yes, I'll proceed with implementation. -``` - -### Subtask Decomposition (Complex Tasks) - -For complex tasks with multiple independent work items, create subtasks: - -```bash -# Create child tasks -CHILD1=$(python3 ./.trellis/scripts/task.py create "Child task 1" --slug child1 --parent "$TASK_DIR") -CHILD2=$(python3 ./.trellis/scripts/task.py create "Child task 2" --slug child2 --parent "$TASK_DIR") - -# Or link existing tasks -python3 ./.trellis/scripts/task.py add-subtask "$TASK_DIR" "$CHILD_DIR" -``` - ---- - -## PRD Target Structure (final) - -`prd.md` should converge to: - -```markdown -# <Task Title> - -## Goal - -<why + what> - -## Requirements - -* ... - -## Acceptance Criteria - -* [ ] ... - -## Definition of Done - -* ... - -## Technical Approach - -<key design + decisions> - -## Decision (ADR-lite) - -Context / Decision / Consequences - -## Out of Scope - -* ... - -## Technical Notes - -<constraints, references, files, research notes> -``` - ---- - -## Anti-Patterns (Hard Avoid) - -* Asking user for code/context that can be derived from repo -* Asking user to choose an approach before presenting concrete options -* Meta questions about whether to research -* Staying narrowly on the initial request without considering evolution/edges -* Letting brainstorming drift without updating PRD - ---- - -## Integration with Start Workflow - -After brainstorm completes (Step 8 confirmation approved), the flow continues to the Task Workflow's **Phase 2: Prepare for Implementation**: - -```text -Brainstorm - Step 0: Create task directory + seed PRD - Step 1–7: Discover requirements, research, converge - Step 8: Final confirmation → user approves - ↓ -Task Workflow Phase 2 (Prepare for Implementation) - Code-Spec Depth Check (if applicable) - → Research codebase (based on confirmed PRD) - → Configure code-spec context (jsonl files) - → Activate task - ↓ -Task Workflow Phase 3 (Execute) - Implement → Check → Complete -``` - -The task directory and PRD already exist from brainstorm, so Phase 1 of the Task Workflow is skipped entirely. - ---- - -## Related Commands - -| Command | When to Use | -|---------|-------------| -| `$start` | Entry point that triggers brainstorm | -| `$finish-work` | After implementation is complete | -| `$update-spec` | If new patterns emerge during work | diff --git a/.agents/skills/break-loop/SKILL.md b/.agents/skills/break-loop/SKILL.md deleted file mode 100644 index 0f5f4e1c..00000000 --- a/.agents/skills/break-loop/SKILL.md +++ /dev/null @@ -1,130 +0,0 @@ ---- -name: break-loop -description: "Break the Loop - Deep Bug Analysis" ---- - -# Break the Loop - Deep Bug Analysis - -When debug is complete, use this skill for deep analysis to break the "fix bug -> forget -> repeat" cycle. - ---- - -## Analysis Framework - -Analyze the bug you just fixed from these 5 dimensions: - -### 1. Root Cause Category - -Which category does this bug belong to? - -| Category | Characteristics | Example | -|----------|-----------------|---------| -| **A. Missing Spec** | No documentation on how to do it | New feature without checklist | -| **B. Cross-Layer Contract** | Interface between layers unclear | API returns different format than expected | -| **C. Change Propagation Failure** | Changed one place, missed others | Changed function signature, missed call sites | -| **D. Test Coverage Gap** | Unit test passes, integration fails | Works alone, breaks when combined | -| **E. Implicit Assumption** | Code relies on undocumented assumption | Timestamp seconds vs milliseconds | - -### 2. Why Fixes Failed (if applicable) - -If you tried multiple fixes before succeeding, analyze each failure: - -- **Surface Fix**: Fixed symptom, not root cause -- **Incomplete Scope**: Found root cause, didn't cover all cases -- **Tool Limitation**: grep missed it, type check wasn't strict -- **Mental Model**: Kept looking in same layer, didn't think cross-layer - -### 3. Prevention Mechanisms - -What mechanisms would prevent this from happening again? - -| Type | Description | Example | -|------|-------------|---------| -| **Documentation** | Write it down so people know | Update thinking guide | -| **Architecture** | Make the error impossible structurally | Type-safe wrappers | -| **Compile-time** | TypeScript strict, no any | Signature change causes compile error | -| **Runtime** | Monitoring, alerts, scans | Detect orphan entities | -| **Test Coverage** | E2E tests, integration tests | Verify full flow | -| **Code Review** | Checklist, PR template | "Did you check X?" | - -### 4. Systematic Expansion - -What broader problems does this bug reveal? - -- **Similar Issues**: Where else might this problem exist? -- **Design Flaw**: Is there a fundamental architecture issue? -- **Process Flaw**: Is there a development process improvement? -- **Knowledge Gap**: Is the team missing some understanding? - -### 5. Knowledge Capture - -Solidify insights into the system: - -- [ ] Update `.trellis/spec/guides/` thinking guides -- [ ] Update `.trellis/spec/backend/` or `frontend/` docs -- [ ] Create issue record (if applicable) -- [ ] Create feature ticket for root fix -- [ ] Update check skills if needed - ---- - -## Output Format - -Please output analysis in this format: - -```markdown -## Bug Analysis: [Short Description] - -### 1. Root Cause Category -- **Category**: [A/B/C/D/E] - [Category Name] -- **Specific Cause**: [Detailed description] - -### 2. Why Fixes Failed (if applicable) -1. [First attempt]: [Why it failed] -2. [Second attempt]: [Why it failed] -... - -### 3. Prevention Mechanisms -| Priority | Mechanism | Specific Action | Status | -|----------|-----------|-----------------|--------| -| P0 | ... | ... | TODO/DONE | - -### 4. Systematic Expansion -- **Similar Issues**: [List places with similar problems] -- **Design Improvement**: [Architecture-level suggestions] -- **Process Improvement**: [Development process suggestions] - -### 5. Knowledge Capture -- [ ] [Documents to update / tickets to create] -``` - ---- - -## Core Philosophy - -> **The value of debugging is not in fixing the bug, but in making this class of bugs never happen again.** - -Three levels of insight: -1. **Tactical**: How to fix THIS bug -2. **Strategic**: How to prevent THIS CLASS of bugs -3. **Philosophical**: How to expand thinking patterns - -30 minutes of analysis saves 30 hours of future debugging. - ---- - -## After Analysis: Immediate Actions - -**IMPORTANT**: After completing the analysis above, you MUST immediately: - -1. **Update spec/guides** - Don't just list TODOs, actually update the relevant files: - - If it's a cross-platform issue → update `cross-platform-thinking-guide.md` - - If it's a cross-layer issue → update `cross-layer-thinking-guide.md` - - If it's a code reuse issue → update `code-reuse-thinking-guide.md` - - If it's domain-specific → update `backend/*.md` or `frontend/*.md` - -2. **Sync templates** - After updating `.trellis/spec/`, sync to `src/templates/markdown/spec/` - -3. **Commit the spec updates** - This is the primary output, not just the analysis text - -> **The analysis is worthless if it stays in chat. The value is in the updated specs.** diff --git a/.agents/skills/check-backend/SKILL.md b/.agents/skills/check-backend/SKILL.md deleted file mode 100644 index dce49bc8..00000000 --- a/.agents/skills/check-backend/SKILL.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -name: check-backend -description: "Check if the code you just wrote follows the backend development guidelines." ---- - -Check if the code you just wrote follows the backend development guidelines. - -Execute these steps: -1. Run `git status` to see modified files -2. Read `.trellis/spec/backend/index.md` to understand which guidelines apply -3. Based on what you changed, read the relevant guideline files: - - Database changes → `.trellis/spec/backend/database-guidelines.md` - - Error handling → `.trellis/spec/backend/error-handling.md` - - Logging changes → `.trellis/spec/backend/logging-guidelines.md` - - Type changes → `.trellis/spec/backend/type-safety.md` - - Any changes → `.trellis/spec/backend/quality-guidelines.md` -4. Review your code against the guidelines -5. Report any violations and fix them if found diff --git a/.agents/skills/check-cross-layer/SKILL.md b/.agents/skills/check-cross-layer/SKILL.md deleted file mode 100644 index 3a3d9777..00000000 --- a/.agents/skills/check-cross-layer/SKILL.md +++ /dev/null @@ -1,158 +0,0 @@ ---- -name: check-cross-layer -description: "Cross-Layer Check" ---- - -# Cross-Layer Check - -Check if your changes considered all dimensions. Most bugs come from "didn't think of it", not lack of technical skill. - -> **Note**: This is a **post-implementation** safety net. Ideally, read the [Pre-Implementation Checklist](.trellis/spec/guides/pre-implementation-checklist.md) **before** writing code. - ---- - -## Related Documents - -| Document | Purpose | Timing | -|----------|---------|--------| -| [Pre-Implementation Checklist](.trellis/spec/guides/pre-implementation-checklist.md) | Questions before coding | **Before** writing code | -| [Code Reuse Thinking Guide](.trellis/spec/guides/code-reuse-thinking-guide.md) | Pattern recognition | During implementation | -| **`$check-cross-layer`** (this skill) | Verification check | **After** implementation | - ---- - -## Execution Steps - -### 1. Identify Change Scope - -```bash -git status -git diff --name-only -``` - -### 2. Select Applicable Check Dimensions - -Based on your change type, execute relevant checks below: - ---- - -## Dimension A: Cross-Layer Data Flow (Required when 3+ layers) - -**Trigger**: Changes involve 3 or more layers - -| Layer | Common Locations | -|-------|------------------| -| API/Routes | `routes/`, `api/`, `handlers/`, `controllers/` | -| Service/Business Logic | `services/`, `lib/`, `core/`, `domain/` | -| Database/Storage | `db/`, `models/`, `repositories/`, `schema/` | -| UI/Presentation | `components/`, `views/`, `templates/`, `pages/` | -| Utility | `utils/`, `helpers/`, `common/` | - -**Checklist**: -- [ ] Read flow: Database -> Service -> API -> UI -- [ ] Write flow: UI -> API -> Service -> Database -- [ ] Types/schemas correctly passed between layers? -- [ ] Errors properly propagated to caller? -- [ ] Loading/pending states handled at each layer? - -**Detailed Guide**: `.trellis/spec/guides/cross-layer-thinking-guide.md` - ---- - -## Dimension B: Code Reuse (Required when modifying constants/config) - -**Trigger**: -- Modifying UI constants (label, icon, color) -- Modifying any hardcoded value -- Seeing similar code in multiple places -- Creating a new utility/helper function -- Just finished batch modifications across files - -**Checklist**: -- [ ] Search first: How many places define this value? - ```bash - # Search in source files (adjust extensions for your project) - grep -r "value-to-change" src/ - ``` -- [ ] If 2+ places define same value -> Should extract to shared constant -- [ ] After modification, all usage sites updated? -- [ ] If creating utility: Does similar utility already exist? - -**Detailed Guide**: `.trellis/spec/guides/code-reuse-thinking-guide.md` - ---- - -## Dimension B2: New Utility Functions - -**Trigger**: About to create a new utility/helper function - -**Checklist**: -- [ ] Search for existing similar utilities first - ```bash - grep -r "functionNamePattern" src/ - ``` -- [ ] If similar exists, can you extend it instead? -- [ ] If creating new, is it in the right location (shared vs domain-specific)? - ---- - -## Dimension B3: After Batch Modifications - -**Trigger**: Just modified similar patterns in multiple files - -**Checklist**: -- [ ] Did you check ALL files with similar patterns? - ```bash - grep -r "patternYouChanged" src/ - ``` -- [ ] Any files missed that should also be updated? -- [ ] Should this pattern be abstracted to prevent future duplication? - ---- - -## Dimension C: Import/Dependency Paths (Required when creating new files) - -**Trigger**: Creating new source files - -**Checklist**: -- [ ] Using correct import paths (relative vs absolute)? -- [ ] No circular dependencies? -- [ ] Consistent with project's module organization? - ---- - -## Dimension D: Same-Layer Consistency - -**Trigger**: -- Modifying display logic or formatting -- Same domain concept used in multiple places - -**Checklist**: -- [ ] Search for other places using same concept - ```bash - grep -r "ConceptName" src/ - ``` -- [ ] Are these usages consistent? -- [ ] Should they share configuration/constants? - ---- - -## Common Issues Quick Reference - -| Issue | Root Cause | Prevention | -|-------|------------|------------| -| Changed one place, missed others | Didn't search impact scope | `grep` before changing | -| Data lost at some layer | Didn't check data flow | Trace data source to destination | -| Type/schema mismatch | Cross-layer types inconsistent | Use shared type definitions | -| UI/output inconsistent | Same concept in multiple places | Extract shared constants | -| Similar utility exists | Didn't search first | Search before creating | -| Batch fix incomplete | Didn't verify all occurrences | grep after fixing | - ---- - -## Output - -Report: -1. Which dimensions your changes involve -2. Check results for each dimension -3. Issues found and fix suggestions diff --git a/.agents/skills/check-frontend/SKILL.md b/.agents/skills/check-frontend/SKILL.md deleted file mode 100644 index cdef3cb9..00000000 --- a/.agents/skills/check-frontend/SKILL.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -name: check-frontend -description: "Check if the code you just wrote follows the frontend development guidelines." ---- - -Check if the code you just wrote follows the frontend development guidelines. - -Execute these steps: -1. Run `git status` to see modified files -2. Read `.trellis/spec/frontend/index.md` to understand which guidelines apply -3. Based on what you changed, read the relevant guideline files: - - Component changes → `.trellis/spec/frontend/component-guidelines.md` - - Hook changes → `.trellis/spec/frontend/hook-guidelines.md` - - State changes → `.trellis/spec/frontend/state-management.md` - - Type changes → `.trellis/spec/frontend/type-safety.md` - - Any changes → `.trellis/spec/frontend/quality-guidelines.md` -4. Review your code against the guidelines -5. Report any violations and fix them if found diff --git a/.agents/skills/create-command/SKILL.md b/.agents/skills/create-command/SKILL.md deleted file mode 100644 index eed6dafb..00000000 --- a/.agents/skills/create-command/SKILL.md +++ /dev/null @@ -1,101 +0,0 @@ ---- -name: create-command -description: "Create New Skill" ---- - -# Create New Skill - -Create a new Codex skill in `.agents/skills/<skill-name>/SKILL.md` based on user requirements. - -## Usage - -```bash -$create-command <skill-name> <description> -``` - -**Example**: -```bash -$create-command review-pr Check PR code changes against project guidelines -``` - -## Execution Steps - -### 1. Parse Input - -Extract from user input: -- **Skill name**: Use kebab-case (e.g., `review-pr`) -- **Description**: What the skill should accomplish - -### 2. Analyze Requirements - -Determine skill type based on description: -- **Initialization**: Read docs, establish context -- **Pre-development**: Read guidelines, check dependencies -- **Code check**: Validate code quality and guideline compliance -- **Recording**: Record progress, questions, structure changes -- **Generation**: Generate docs or code templates - -### 3. Generate Skill Content - -Minimum `SKILL.md` structure: - -```markdown ---- -name: <skill-name> -description: "<description>" ---- - -# <Skill Title> - -<Instructions for when and how to use this skill> -``` - -### 4. Create Files - -Create: -- `.agents/skills/<skill-name>/SKILL.md` - -### 5. Confirm Creation - -Output result: - -```text -[OK] Created Skill: <skill-name> - -File path: -- .agents/skills/<skill-name>/SKILL.md - -Usage: -- Trigger directly with $<skill-name> -- Or open /skills and select it - -Description: -<description> -``` - -## Skill Content Guidelines - -### [OK] Good skill content - -1. **Clear and concise**: Immediately understandable -2. **Executable**: AI can follow steps directly -3. **Well-scoped**: Clear boundaries of what to do and not do -4. **Has output**: Specifies expected output format (if needed) - -### [X] Avoid - -1. **Too vague**: e.g., "optimize code" -2. **Too complex**: Single skill should not exceed 100 lines -3. **Duplicate functionality**: Check if similar skill exists first - -## Naming Conventions - -| Skill Type | Prefix | Example | -|------------|--------|---------| -| Session Start | `start` | `start` | -| Pre-development | `before-` | `before-frontend-dev` | -| Check | `check-` | `check-frontend` | -| Record | `record-` | `record-session` | -| Generate | `generate-` | `generate-api-doc` | -| Update | `update-` | `update-changelog` | -| Other | Verb-first | `review-code`, `sync-data` | diff --git a/.agents/skills/finish-work/SKILL.md b/.agents/skills/finish-work/SKILL.md deleted file mode 100644 index 75ec3688..00000000 --- a/.agents/skills/finish-work/SKILL.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -name: finish-work -description: "Finish Work - Pre-Commit Checklist" ---- - -# Finish Work - Pre-Commit Checklist - -Before submitting or committing, use this checklist to ensure work completeness. - -**Timing**: After code is written and tested, before commit - ---- - -## Checklist - -### 1. Code Quality - -```bash -# Must pass -pnpm lint -pnpm type-check -pnpm test -``` - -- [ ] `pnpm lint` passes with 0 errors? -- [ ] `pnpm type-check` passes with no type errors? -- [ ] Tests pass? -- [ ] No `console.log` statements (use logger)? -- [ ] No non-null assertions (the `x!` operator)? -- [ ] No `any` types? - -### 2. Code-Spec Sync - -**Code-Spec Docs**: -- [ ] Does `.trellis/spec/backend/` need updates? - - New patterns, new modules, new conventions -- [ ] Does `.trellis/spec/frontend/` need updates? - - New components, new hooks, new patterns -- [ ] Does `.trellis/spec/guides/` need updates? - - New cross-layer flows, lessons from bugs - -**Key Question**: -> "If I fixed a bug or discovered something non-obvious, should I document it so future me (or others) won't hit the same issue?" - -If YES -> Update the relevant code-spec doc. - -### 2.5. Code-Spec Hard Block (Infra/Cross-Layer) - -If this change touches infra or cross-layer contracts, this is a blocking checklist: - -- [ ] Spec content is executable (real signatures/contracts), not principle-only text -- [ ] Includes file path + command/API name + payload field names -- [ ] Includes validation and error matrix -- [ ] Includes Good/Base/Bad cases -- [ ] Includes required tests and assertion points - -**Block Rule**: -If infra/cross-layer changed but the related spec is still abstract, do NOT finish. Run `$update-spec` manually first. - -### 3. API Changes - -If you modified API endpoints: - -- [ ] Input schema updated? -- [ ] Output schema updated? -- [ ] API documentation updated? -- [ ] Client code updated to match? - -### 4. Database Changes - -If you modified database schema: - -- [ ] Migration file created? -- [ ] Schema file updated? -- [ ] Related queries updated? -- [ ] Seed data updated (if applicable)? - -### 5. Cross-Layer Verification - -If the change spans multiple layers: - -- [ ] Data flows correctly through all layers? -- [ ] Error handling works at each boundary? -- [ ] Types are consistent across layers? -- [ ] Loading states handled? - -### 6. Manual Testing - -- [ ] Feature works in browser/app? -- [ ] Edge cases tested? -- [ ] Error states tested? -- [ ] Works after page refresh? - ---- - -## Quick Check Flow - -```bash -# 1. Code checks -pnpm lint && pnpm type-check - -# 2. View changes -git status -git diff --name-only - -# 3. Based on changed files, check relevant items above -``` - ---- - -## Common Oversights - -| Oversight | Consequence | Check | -|-----------|-------------|-------| -| Code-spec docs not updated | Others don't know the change | Check .trellis/spec/ | -| Spec text is abstract only | Easy regressions in infra/cross-layer changes | Require signature/contract/matrix/cases/tests | -| Migration not created | Schema out of sync | Check db/migrations/ | -| Types not synced | Runtime errors | Check shared types | -| Tests not updated | False confidence | Run full test suite | -| Console.log left in | Noisy production logs | Search for console.log | - ---- - -## Relationship to Other Commands - -``` -Development Flow: - Write code -> Test -> $finish-work -> git commit -> $record-session - | | - Ensure completeness Record progress - -Debug Flow: - Hit bug -> Fix -> $break-loop -> Knowledge capture - | - Deep analysis -``` - -- `$finish-work` - Check work completeness (this skill) -- `$record-session` - Record session and commits -- `$break-loop` - Deep analysis after debugging - ---- - -## Core Principle - -> **Delivery includes not just code, but also documentation, verification, and knowledge capture.** - -Complete work = Code + Docs + Tests + Verification diff --git a/.agents/skills/integrate-skill/SKILL.md b/.agents/skills/integrate-skill/SKILL.md deleted file mode 100644 index 41107884..00000000 --- a/.agents/skills/integrate-skill/SKILL.md +++ /dev/null @@ -1,221 +0,0 @@ ---- -name: integrate-skill -description: "Integrate Skill into Project Guidelines" ---- - -# Integrate Skill into Project Guidelines - -Adapt and integrate a reusable skill into your project's development guidelines (not directly into project code). - -## Usage - -``` -$integrate-skill <skill-name> -``` - -**Examples**: -``` -$integrate-skill frontend-design -$integrate-skill mcp-builder -``` - -## Core Principle - -> [!] **Important**: The goal of skill integration is to update **development guidelines**, not to generate project code directly. -> -> - Guidelines content -> Write to `.trellis/spec/{target}/doc.md` -> - Code examples -> Place in `.trellis/spec/{target}/examples/skills/<skill-name>/` -> - Example files -> Use `.template` suffix (e.g., `component.tsx.template`) to avoid IDE errors -> -> Where `{target}` is `frontend` or `backend`, determined by skill type. - -## Execution Steps - -### 1. Read Skill Content - -Locate and read the skill instructions: -- `.agents/skills/<skill-name>/SKILL.md` in the repository -- Skill list in `AGENTS.md` (when available in current context) - -If the skill cannot be found, ask the user for the source path or repository. - -### 2. Determine Integration Target - -Based on skill type, determine which guidelines to update: - -| Skill Category | Integration Target | -|----------------|-------------------| -| UI/Frontend (`frontend-design`, `web-artifacts-builder`) | `.trellis/spec/frontend/` | -| Backend/API (`mcp-builder`) | `.trellis/spec/backend/` | -| Documentation (`doc-coauthoring`, `docx`, `pdf`) | `.trellis/` or create dedicated guidelines | -| Testing (`webapp-testing`) | `.trellis/spec/frontend/` (E2E) | - -### 3. Analyze Skill Content - -Extract from the skill: -- **Core concepts**: How the skill works and key concepts -- **Best practices**: Recommended approaches -- **Code patterns**: Reusable code templates -- **Caveats**: Common issues and solutions - -### 4. Execute Integration - -#### 4.1 Update Guidelines Document - -Add a new section to the corresponding `doc.md`: - -```markdown -@@@section:skill-<skill-name> -## # <Skill Name> Integration Guide - -### Overview -[Core functionality and use cases of the skill] - -### Project Adaptation -[How to use this skill in the current project] - -### Usage Steps -1. [Step 1] -2. [Step 2] - -### Caveats -- [Project-specific constraints] -- [Differences from default behavior] - -### Reference Examples -See `examples/skills/<skill-name>/` - -@@@/section:skill-<skill-name> -``` - -#### 4.2 Create Examples Directory (if code examples exist) - -```bash -# Directory structure ({target} = frontend or backend) -.trellis/spec/{target}/ -|-- doc.md # Add skill-related section -|-- index.md # Update index -+-- examples/ - +-- skills/ - +-- <skill-name>/ - |-- README.md # Example documentation - |-- example-1.ts.template # Code example (use .template suffix) - +-- example-2.tsx.template -``` - -**File naming conventions**: -- Code files: `<name>.<ext>.template` (e.g., `component.tsx.template`) -- Config files: `<name>.config.template` (e.g., `tailwind.config.template`) -- Documentation: `README.md` (normal suffix) - -#### 4.3 Update Index File - -Add to the Quick Navigation table in `index.md`: - -```markdown -| <Skill-related task> | <Section name> | `skill-<skill-name>` | -``` - -### 5. Generate Integration Report - ---- - -## Skill Integration Report: `<skill-name>` - -### # Overview -- **Skill description**: [Functionality description] -- **Integration target**: `.trellis/spec/{target}/` - -### # Tech Stack Compatibility - -| Skill Requirement | Project Status | Compatibility | -|-------------------|----------------|---------------| -| [Tech 1] | [Project tech] | [OK]/[!]/[X] | - -### # Integration Locations - -| Type | Path | -|------|------| -| Guidelines doc | `.trellis/spec/{target}/doc.md` (section: `skill-<name>`) | -| Code examples | `.trellis/spec/{target}/examples/skills/<name>/` | -| Index update | `.trellis/spec/{target}/index.md` | - -> `{target}` = `frontend` or `backend` - -### # Dependencies (if needed) - -```bash -# Install required dependencies (adjust for your package manager) -npm install <package> -# or -pnpm add <package> -# or -yarn add <package> -``` - -### [OK] Completed Changes - -- [ ] Added `@@@section:skill-<name>` section to `doc.md` -- [ ] Added index entry to `index.md` -- [ ] Created example files in `examples/skills/<name>/` -- [ ] Example files use `.template` suffix - -### # Related Guidelines - -- [Existing related section IDs] - ---- - -## 6. Optional: Create Usage Skill - -If this skill is frequently used, create a shortcut skill: - -```bash -$create-command use-<skill-name> Use <skill-name> skill following project guidelines -``` - -## Common Skill Integration Reference - -| Skill | Integration Target | Examples Directory | -|-------|-------------------|-------------------| -| `frontend-design` | `frontend` | `examples/skills/frontend-design/` | -| `mcp-builder` | `backend` | `examples/skills/mcp-builder/` | -| `webapp-testing` | `frontend` | `examples/skills/webapp-testing/` | -| `doc-coauthoring` | `.trellis/` | N/A (documentation workflow only) | - -## Example: Integrating `mcp-builder` Skill - -### Directory Structure - -``` -.trellis/spec/backend/ -|-- doc.md # Add MCP section -|-- index.md # Add index entry -+-- examples/ - +-- skills/ - +-- mcp-builder/ - |-- README.md - |-- server.ts.template - |-- tools.ts.template - +-- types.ts.template -``` - -### New Section in doc.md - -```markdown -@@@section:skill-mcp-builder -## # MCP Server Development Guide - -### Overview -Create LLM-callable tool services using MCP (Model Context Protocol). - -### Project Adaptation -- Place services in a dedicated directory -- Follow existing TypeScript and type definition conventions -- Use project's logging system - -### Reference Examples -See `examples/skills/mcp-builder/` - -@@@/section:skill-mcp-builder -``` diff --git a/.agents/skills/onboard/SKILL.md b/.agents/skills/onboard/SKILL.md deleted file mode 100644 index 36212719..00000000 --- a/.agents/skills/onboard/SKILL.md +++ /dev/null @@ -1,363 +0,0 @@ ---- -name: onboard -description: "PART 3: Customize Your Development Guidelines" ---- - -You are a senior developer onboarding a new team member to this project's AI-assisted workflow system. - -YOUR ROLE: Be a mentor and teacher. Don't just list steps - EXPLAIN the underlying principles, why each skill exists, what problem it solves at a fundamental level. - -## CRITICAL INSTRUCTION - YOU MUST COMPLETE ALL SECTIONS - -This onboarding has THREE equally important parts: - -**PART 1: Core Concepts** (Sections: CORE PHILOSOPHY, SYSTEM STRUCTURE, SKILL DEEP DIVE) -- Explain WHY this workflow exists -- Explain WHAT each skill does and WHY - -**PART 2: Real-World Examples** (Section: REAL-WORLD WORKFLOW EXAMPLES) -- Walk through ALL 5 examples in detail -- For EACH step in EACH example, explain: - - PRINCIPLE: Why this step exists - - WHAT HAPPENS: What the skill actually does - - IF SKIPPED: What goes wrong without it - -**PART 3: Customize Your Development Guidelines** (Section: CUSTOMIZE YOUR DEVELOPMENT GUIDELINES) -- Check if project guidelines are still empty templates -- If empty, guide the developer to fill them with project-specific content -- Explain the customization workflow - -DO NOT skip any part. All three parts are essential: -- Part 1 teaches the concepts -- Part 2 shows how concepts work in practice -- Part 3 ensures the project has proper guidelines for AI to follow - -After completing ALL THREE parts, ask the developer about their first task. - ---- - -## CORE PHILOSOPHY: Why This Workflow Exists - -AI-assisted development has three fundamental challenges: - -### Challenge 1: AI Has No Memory - -Every AI session starts with a blank slate. Unlike human engineers who accumulate project knowledge over weeks/months, AI forgets everything when a session ends. - -**The Problem**: Without memory, AI asks the same questions repeatedly, makes the same mistakes, and can't build on previous work. - -**The Solution**: The `.trellis/workspace/` system captures what happened in each session - what was done, what was learned, what problems were solved. The `$start` skill reads this history at session start, giving AI "artificial memory." - -### Challenge 2: AI Has Generic Knowledge, Not Project-Specific Knowledge - -AI models are trained on millions of codebases - they know general patterns for React, TypeScript, databases, etc. But they don't know YOUR project's conventions. - -**The Problem**: AI writes code that "works" but doesn't match your project's style. It uses patterns that conflict with existing code. It makes decisions that violate unwritten team rules. - -**The Solution**: The `.trellis/spec/` directory contains project-specific guidelines. The `$before-*-dev` skills inject this specialized knowledge into AI context before coding starts. - -### Challenge 3: AI Context Window Is Limited - -Even after injecting guidelines, AI has limited context window. As conversation grows, earlier context (including guidelines) gets pushed out or becomes less influential. - -**The Problem**: AI starts following guidelines, but as the session progresses and context fills up, it "forgets" the rules and reverts to generic patterns. - -**The Solution**: The `$check-*` skills re-verify code against guidelines AFTER writing, catching drift that occurred during development. The `$finish-work` skill does a final holistic review. - ---- - -## SYSTEM STRUCTURE - -``` -.trellis/ -|-- .developer # Your identity (gitignored) -|-- workflow.md # Complete workflow documentation -|-- workspace/ # "AI Memory" - session history -| |-- index.md # All developers' progress -| +-- {developer}/ # Per-developer directory -| |-- index.md # Personal progress index -| +-- journal-N.md # Session records (max 2000 lines) -|-- tasks/ # Task tracking (unified) -| +-- {MM}-{DD}-{slug}/ # Task directory -| |-- task.json # Task metadata -| +-- prd.md # Requirements doc -|-- spec/ # "AI Training Data" - project knowledge -| |-- frontend/ # Frontend conventions -| |-- backend/ # Backend conventions -| +-- guides/ # Thinking patterns -+-- scripts/ # Automation tools -``` - -### Understanding spec/ subdirectories - -**frontend/** - Single-layer frontend knowledge: -- Component patterns (how to write components in THIS project) -- State management rules (Redux? Zustand? Context?) -- Styling conventions (CSS modules? Tailwind? Styled-components?) -- Hook patterns (custom hooks, data fetching) - -**backend/** - Single-layer backend knowledge: -- API design patterns (REST? GraphQL? tRPC?) -- Database conventions (query patterns, migrations) -- Error handling standards -- Logging and monitoring rules - -**guides/** - Cross-layer thinking guides: -- Code reuse thinking guide -- Cross-layer thinking guide -- Pre-implementation checklists - ---- - -## SKILL DEEP DIVE - -### $start - Restore AI Memory - -**WHY IT EXISTS**: -When a human engineer joins a project, they spend days/weeks learning: What is this project? What's been built? What's in progress? What's the current state? - -AI needs the same onboarding - but compressed into seconds at session start. - -**WHAT IT ACTUALLY DOES**: -1. Reads developer identity (who am I in this project?) -2. Checks git status (what branch? uncommitted changes?) -3. Reads recent session history from `workspace/` (what happened before?) -4. Identifies active features (what's in progress?) -5. Understands current project state before making any changes - -**WHY THIS MATTERS**: -- Without $start: AI is blind. It might work on wrong branch, conflict with others' work, or redo already-completed work. -- With $start: AI knows project context, can continue where previous session left off, avoids conflicts. - ---- - -### $before-frontend-dev and $before-backend-dev - Inject Specialized Knowledge - -**WHY IT EXISTS**: -AI models have "pre-trained knowledge" - general patterns from millions of codebases. But YOUR project has specific conventions that differ from generic patterns. - -**WHAT IT ACTUALLY DOES**: -1. Reads `.trellis/spec/frontend/` or `.trellis/spec/backend/` -2. Loads project-specific patterns into AI's working context: - - Component naming conventions - - State management patterns - - Database query patterns - - Error handling standards - -**WHY THIS MATTERS**: -- Without before-*-dev: AI writes generic code that doesn't match project style. -- With before-*-dev: AI writes code that looks like the rest of the codebase. - ---- - -### $check-frontend and $check-backend - Combat Context Drift - -**WHY IT EXISTS**: -AI context window has limited capacity. As conversation progresses, guidelines injected at session start become less influential. This causes "context drift." - -**WHAT IT ACTUALLY DOES**: -1. Re-reads the guidelines that were injected earlier -2. Compares written code against those guidelines -3. Runs type checker and linter -4. Identifies violations and suggests fixes - -**WHY THIS MATTERS**: -- Without check-*: Context drift goes unnoticed, code quality degrades. -- With check-*: Drift is caught and corrected before commit. - ---- - -### $check-cross-layer - Multi-Dimension Verification - -**WHY IT EXISTS**: -Most bugs don't come from lack of technical skill - they come from "didn't think of it": -- Changed a constant in one place, missed 5 other places -- Modified database schema, forgot to update the API layer -- Created a utility function, but similar one already exists - -**WHAT IT ACTUALLY DOES**: -1. Identifies which dimensions your change involves -2. For each dimension, runs targeted checks: - - Cross-layer data flow - - Code reuse analysis - - Import path validation - - Consistency checks - ---- - -### $finish-work - Holistic Pre-Commit Review - -**WHY IT EXISTS**: -The `$check-*` skills focus on code quality within a single layer. But real changes often have cross-cutting concerns. - -**WHAT IT ACTUALLY DOES**: -1. Reviews all changes holistically -2. Checks cross-layer consistency -3. Identifies broader impacts -4. Checks if new patterns should be documented - ---- - -### $record-session - Persist Memory for Future - -**WHY IT EXISTS**: -All the context AI built during this session will be lost when session ends. The next session's `$start` needs this information. - -**WHAT IT ACTUALLY DOES**: -1. Records session summary to `workspace/{developer}/journal-N.md` -2. Captures what was done, learned, and what's remaining -3. Updates index files for quick lookup - ---- - -## REAL-WORLD WORKFLOW EXAMPLES - -### Example 1: Bug Fix Session - -**[1/8] $start** - AI needs project context before touching code -**[2/8] python3 ./.trellis/scripts/task.py create "Fix bug" --slug fix-bug** - Track work for future reference -**[3/8] $before-frontend-dev** - Inject project-specific frontend knowledge -**[4/8] Investigate and fix the bug** - Actual development work -**[5/8] $check-frontend** - Re-verify code against guidelines -**[6/8] $finish-work** - Holistic cross-layer review -**[7/8] Human tests and commits** - Human validates before code enters repo -**[8/8] $record-session** - Persist memory for future sessions - -### Example 2: Planning Session (No Code) - -**[1/4] $start** - Context needed even for non-coding work -**[2/4] python3 ./.trellis/scripts/task.py create "Planning task" --slug planning-task** - Planning is valuable work -**[3/4] Review docs, create subtask list** - Actual planning work -**[4/4] $record-session (with --summary)** - Planning decisions must be recorded - -### Example 3: Code Review Fixes - -**[1/6] $start** - Resume context from previous session -**[2/6] $before-backend-dev** - Re-inject guidelines before fixes -**[3/6] Fix each CR issue** - Address feedback with guidelines in context -**[4/6] $check-backend** - Verify fixes didn't introduce new issues -**[5/6] $finish-work** - Document lessons from CR -**[6/6] Human commits, then $record-session** - Preserve CR lessons - -### Example 4: Large Refactoring - -**[1/5] $start** - Clear baseline before major changes -**[2/5] Plan phases** - Break into verifiable chunks -**[3/5] Execute phase by phase with $check-* after each** - Incremental verification -**[4/5] $finish-work** - Check if new patterns should be documented -**[5/5] Record with multiple commit hashes** - Link all commits to one feature - -### Example 5: Debug Session - -**[1/6] $start** - See if this bug was investigated before -**[2/6] $before-backend-dev** - Guidelines might document known gotchas -**[3/6] Investigation** - Actual debugging work -**[4/6] $check-backend** - Verify debug changes don't break other things -**[5/6] $finish-work** - Debug findings might need documentation -**[6/6] Human commits, then $record-session** - Debug knowledge is valuable - ---- - -## KEY RULES TO EMPHASIZE - -1. **AI NEVER commits** - Human tests and approves. AI prepares, human validates. -2. **Guidelines before code** - `$before-*-dev` skills inject project knowledge. -3. **Check after code** - `$check-*` skills catch context drift. -4. **Record everything** - $record-session persists memory. - ---- - -# PART 3: Customize Your Development Guidelines - -After explaining Part 1 and Part 2, check if the project's development guidelines need customization. - -## Step 1: Check Current Guidelines Status - -Check if `.trellis/spec/` contains empty templates or customized guidelines: - -```bash -# Check if files are still empty templates (look for placeholder text) -grep -l "To be filled by the team" .trellis/spec/backend/*.md 2>/dev/null | wc -l -grep -l "To be filled by the team" .trellis/spec/frontend/*.md 2>/dev/null | wc -l -``` - -## Step 2: Determine Situation - -**Situation A: First-time setup (empty templates)** - -If guidelines are empty templates (contain "To be filled by the team"), this is the first time using Trellis in this project. - -Explain to the developer: - -"I see that the development guidelines in `.trellis/spec/` are still empty templates. This is normal for a new Trellis setup! - -The templates contain placeholder text that needs to be replaced with YOUR project's actual conventions. Without this, `$before-*-dev` skills won't provide useful guidance. - -**Your first task should be to fill in these guidelines:** - -1. Look at your existing codebase -2. Identify the patterns and conventions already in use -3. Document them in the guideline files - -For example, for `.trellis/spec/backend/database-guidelines.md`: -- What ORM/query library does your project use? -- How are migrations managed? -- What naming conventions for tables/columns? - -Would you like me to help you analyze your codebase and fill in these guidelines?" - -**Situation B: Guidelines already customized** - -If guidelines have real content (no "To be filled" placeholders), this is an existing setup. - -Explain to the developer: - -"Great! Your team has already customized the development guidelines. You can start using `$before-*-dev` skills right away. - -I recommend reading through `.trellis/spec/` to familiarize yourself with the team's coding standards." - -## Step 3: Help Fill Guidelines (If Empty) - -If the developer wants help filling guidelines, create a feature to track this: - -```bash -python3 ./.trellis/scripts/task.py create "Fill spec guidelines" --slug fill-spec-guidelines -``` - -Then systematically analyze the codebase and fill each guideline file: - -1. **Analyze the codebase** - Look at existing code patterns -2. **Document conventions** - Write what you observe, not ideals -3. **Include examples** - Reference actual files in the project -4. **List forbidden patterns** - Document anti-patterns the team avoids - -Work through one file at a time: -- `backend/directory-structure.md` -- `backend/database-guidelines.md` -- `backend/error-handling.md` -- `backend/quality-guidelines.md` -- `backend/logging-guidelines.md` -- `frontend/directory-structure.md` -- `frontend/component-guidelines.md` -- `frontend/hook-guidelines.md` -- `frontend/state-management.md` -- `frontend/quality-guidelines.md` -- `frontend/type-safety.md` - ---- - -## Completing the Onboard Session - -After covering all three parts, summarize: - -"You're now onboarded to the Trellis workflow system! Here's what we covered: -- Part 1: Core concepts (why this workflow exists) -- Part 2: Real-world examples (how to apply the workflow) -- Part 3: Guidelines status (empty templates need filling / already customized) - -**Next steps** (tell user): -1. Run `$record-session` to record this onboard session -2. [If guidelines empty] Start filling in `.trellis/spec/` guidelines -3. [If guidelines ready] Start your first development task - -What would you like to do first?" diff --git a/.agents/skills/record-session/SKILL.md b/.agents/skills/record-session/SKILL.md deleted file mode 100644 index 34dbbda7..00000000 --- a/.agents/skills/record-session/SKILL.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -name: record-session -description: "Record work progress after human has tested and committed code" ---- - -[!] **Prerequisite**: This skill should only be used AFTER the human has tested and committed the code. - -**Do NOT run `git commit` directly** — the scripts below handle their own commits for `.trellis/` metadata. You only need to read git history (`git log`, `git status`, `git diff`) and run the Python scripts. - ---- - -## Record Work Progress - -### Step 1: Get Context & Check Tasks - -```bash -python3 ./.trellis/scripts/get_context.py --mode record -``` - -[!] Archive tasks whose work is **actually done** — judge by work status, not the `status` field in task.json: -- Code committed? → Archive it (don't wait for PR) -- All acceptance criteria met? → Archive it -- Don't skip archiving just because `status` still says `planning` or `in_progress` - -```bash -python3 ./.trellis/scripts/task.py archive <task-name> -``` - -### Step 2: One-Click Add Session - -```bash -# Method 1: Simple parameters -python3 ./.trellis/scripts/add_session.py \ - --title "Session Title" \ - --commit "hash1,hash2" \ - --summary "Brief summary of what was done" - -# Method 2: Pass detailed content via stdin -cat << 'EOF' | python3 ./.trellis/scripts/add_session.py --title "Title" --commit "hash" -| Feature | Description | -|---------|-------------| -| New API | Added user authentication endpoint | -| Frontend | Updated login form | - -**Updated Files**: -- `packages/api/modules/auth/router.ts` -- `apps/web/modules/auth/components/login-form.tsx` -EOF -``` - -**Auto-completes**: -- [OK] Appends session to journal-N.md -- [OK] Auto-detects line count, creates new file if >2000 lines -- [OK] Updates index.md (Total Sessions +1, Last Active, line stats, history) -- [OK] Auto-commits .trellis/workspace and .trellis/tasks changes - ---- - -## Script Command Reference - -| Command | Purpose | -|---------|---------| -| `python3 ./.trellis/scripts/get_context.py --mode record` | Get context for record-session | -| `python3 ./.trellis/scripts/add_session.py --title "..." --commit "..."` | **One-click add session (recommended)** | -| `python3 ./.trellis/scripts/task.py archive <name>` | Archive completed task (auto-commits) | -| `python3 ./.trellis/scripts/task.py list` | List active tasks | diff --git a/.agents/skills/start/SKILL.md b/.agents/skills/start/SKILL.md deleted file mode 100644 index 0adfc87b..00000000 --- a/.agents/skills/start/SKILL.md +++ /dev/null @@ -1,346 +0,0 @@ ---- -name: start -description: "Start Session" ---- - -# Start Session - -Initialize your AI development session and begin working on tasks. - ---- - -## Operation Types - -| Marker | Meaning | Executor | -|--------|---------|----------| -| `[AI]` | Bash scripts or tool calls executed by AI | You (AI) | -| `[USER]` | Skills executed by user | User | - ---- - -## Initialization `[AI]` - -### Step 1: Understand Development Workflow - -First, read the workflow guide to understand the development process: - -```bash -cat .trellis/workflow.md -``` - -**Follow the instructions in workflow.md** - it contains: -- Core principles (Read Before Write, Follow Standards, etc.) -- File system structure -- Development process -- Best practices - -### Step 2: Get Current Context - -```bash -python3 ./.trellis/scripts/get_context.py -``` - -This shows: developer identity, git status, current task (if any), active tasks. - -### Step 3: Read Guidelines Index - -```bash -cat .trellis/spec/frontend/index.md # Frontend guidelines -cat .trellis/spec/backend/index.md # Backend guidelines -cat .trellis/spec/guides/index.md # Thinking guides -``` - -> **Important**: The index files are navigation — they list the actual guideline files (e.g., `error-handling.md`, `conventions.md`, `mock-strategies.md`). -> At this step, just read the indexes to understand what's available. -> When you start actual development, you MUST go back and read the specific guideline files relevant to your task, as listed in the index's Pre-Development Checklist. - -### Step 4: Report and Ask - -Report what you learned and ask: "What would you like to work on?" - ---- - -## Task Classification - -When user describes a task, classify it: - -| Type | Criteria | Workflow | -|------|----------|----------| -| **Question** | User asks about code, architecture, or how something works | Answer directly | -| **Trivial Fix** | Typo fix, comment update, single-line change, < 5 minutes | Direct Edit | -| **Simple Task** | Clear goal, 1-2 files, well-defined scope | Quick confirm → Task Workflow | -| **Complex Task** | Vague goal, multiple files, architectural decisions | **Brainstorm → Task Workflow** | - -### Decision Rule - -> **If in doubt, use Brainstorm + Task Workflow.** -> -> Task Workflow ensures code-specs are injected to the right context, resulting in higher quality code. -> The overhead is minimal, but the benefit is significant. - -> **Subtask Decomposition**: If brainstorm reveals multiple independent work items, -> consider creating subtasks using `--parent` flag or `add-subtask` command. -> See the brainstorm skill's Step 8 for details. - ---- - -## Question / Trivial Fix - -For questions or trivial fixes, work directly: - -1. Answer question or make the fix -2. If code was changed, remind user to run `$finish-work` - ---- - -## Simple Task - -For simple, well-defined tasks: - -1. Quick confirm: "I understand you want to [goal]. Shall I proceed?" -2. If no, clarify and confirm again -3. **If yes: execute ALL steps below without stopping. Do NOT ask for additional confirmation between steps.** - - Create task directory (Phase 1 Path B, Step 2) - - Write PRD (Step 3) - - Research codebase (Phase 2, Step 5) - - Configure context (Step 6) - - Activate task (Step 7) - - Implement (Phase 3, Step 8) - - Check quality (Step 9) - - Complete (Step 10) - ---- - -## Complex Task - Brainstorm First - -For complex or vague tasks, **automatically start the brainstorm process** — do NOT skip directly to implementation. - -See `$brainstorm` for the full process. Summary: - -1. **Acknowledge and classify** - State your understanding -2. **Create task directory** - Track evolving requirements in `prd.md` -3. **Ask questions one at a time** - Update PRD after each answer -4. **Propose approaches** - For architectural decisions -5. **Confirm final requirements** - Get explicit approval -6. **Proceed to Task Workflow** - With clear requirements in PRD - ---- - -## Task Workflow (Development Tasks) - -**Why this workflow?** -- Run a dedicated research pass before coding -- Configure specs in jsonl context files -- Implement using injected context -- Verify with a separate check pass -- Result: Code that follows project conventions automatically - -### Overview: Two Entry Points - -``` -From Brainstorm (Complex Task): - PRD confirmed → Research → Configure Context → Activate → Implement → Check → Complete - -From Simple Task: - Confirm → Create Task → Write PRD → Research → Configure Context → Activate → Implement → Check → Complete -``` - -**Key principle: Research happens AFTER requirements are clear (PRD exists).** - ---- - -### Phase 1: Establish Requirements - -#### Path A: From Brainstorm (skip to Phase 2) - -PRD and task directory already exist from brainstorm. Skip directly to Phase 2. - -#### Path B: From Simple Task - -**Step 1: Confirm Understanding** `[AI]` - -Quick confirm: -- What is the goal? -- What type of development? (frontend / backend / fullstack) -- Any specific requirements or constraints? - -If unclear, ask clarifying questions. - -**Step 2: Create Task Directory** `[AI]` - -```bash -TASK_DIR=$(python3 ./.trellis/scripts/task.py create "<title>" --slug <name>) -``` - -**Step 3: Write PRD** `[AI]` - -Create `prd.md` in the task directory with: - -```markdown -# <Task Title> - -## Goal -<What we're trying to achieve> - -## Requirements -- <Requirement 1> -- <Requirement 2> - -## Acceptance Criteria -- [ ] <Criterion 1> -- [ ] <Criterion 2> - -## Technical Notes -<Any technical decisions or constraints> -``` - ---- - -### Phase 2: Prepare for Implementation (shared) - -> Both paths converge here. PRD and task directory must exist before proceeding. - -**Step 4: Code-Spec Depth Check** `[AI]` - -If the task touches infra or cross-layer contracts, do not start implementation until code-spec depth is defined. - -Trigger this requirement when the change includes any of: -- New or changed command/API signatures -- Database schema or migration changes -- Infra integrations (storage, queue, cache, secrets, env contracts) -- Cross-layer payload transformations - -Must-have before proceeding: -- [ ] Target code-spec files to update are identified -- [ ] Concrete contract is defined (signature, fields, env keys) -- [ ] Validation and error matrix is defined -- [ ] At least one Good/Base/Bad case is defined - -**Step 5: Research the Codebase** `[AI]` - -Based on the confirmed PRD, run a focused research pass and produce: - -1. Relevant spec files in `.trellis/spec/` -2. Existing code patterns to follow (2-3 examples) -3. Files that will likely need modification - -Use this output format: - -```markdown -## Relevant Specs -- <path>: <why it's relevant> - -## Code Patterns Found -- <pattern>: <example file path> - -## Files to Modify -- <path>: <what change> -``` - -**Step 6: Configure Context** `[AI]` - -Initialize default context: - -```bash -python3 ./.trellis/scripts/task.py init-context "$TASK_DIR" <type> -# type: backend | frontend | fullstack -``` - -Add specs found in your research pass: - -```bash -# For each relevant spec and code pattern: -python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" implement "<path>" "<reason>" -python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" check "<path>" "<reason>" -``` - -**Step 7: Activate Task** `[AI]` - -```bash -python3 ./.trellis/scripts/task.py start "$TASK_DIR" -``` - -This sets `.current-task` so hooks can inject context. - ---- - -### Phase 3: Execute (shared) - -**Step 8: Implement** `[AI]` - -Implement the task described in `prd.md`. - -- Follow all specs injected into implement context -- Keep changes scoped to requirements -- Run lint and typecheck before finishing - -**Step 9: Check Quality** `[AI]` - -Run a quality pass against check context: - -- Review all code changes against the specs -- Fix issues directly -- Ensure lint and typecheck pass - -**Step 10: Complete** `[AI]` - -1. Verify lint and typecheck pass -2. Report what was implemented -3. Remind user to: - - Test the changes - - Commit when ready - - Run `$record-session` to record this session - ---- - -## Continuing Existing Task - -If `get_context.py` shows a current task: - -1. Read the task's `prd.md` to understand the goal -2. Check `task.json` for current status and phase -3. Ask user: "Continue working on <task-name>?" - -If yes, resume from the appropriate step (usually Step 7 or 8). - ---- - -## Skills Reference - -### User Skills `[USER]` - -| Skill | When to Use | -|---------|-------------| -| `$start` | Begin a session (this skill) | -| `$finish-work` | Before committing changes | -| `$record-session` | After completing a task | - -### AI Scripts `[AI]` - -| Script | Purpose | -|--------|---------| -| `python3 ./.trellis/scripts/get_context.py` | Get session context | -| `python3 ./.trellis/scripts/task.py create` | Create task directory | -| `python3 ./.trellis/scripts/task.py init-context` | Initialize jsonl files | -| `python3 ./.trellis/scripts/task.py add-context` | Add spec to jsonl | -| `python3 ./.trellis/scripts/task.py start` | Set current task | -| `python3 ./.trellis/scripts/task.py finish` | Clear current task | -| `python3 ./.trellis/scripts/task.py archive` | Archive completed task | - -### Workflow Phases `[AI]` - -| Phase | Purpose | Context Source | -|-------|---------|----------------| -| research | Analyze codebase | direct repo inspection | -| implement | Write code | `implement.jsonl` | -| check | Review & fix | `check.jsonl` | -| debug | Fix specific issues | `debug.jsonl` | - ---- - -## Key Principle - -> **Code-spec context is injected, not remembered.** -> -> The Task Workflow ensures agents receive relevant code-spec context automatically. -> This is more reliable than hoping the AI "remembers" conventions. diff --git a/.agents/skills/update-spec/SKILL.md b/.agents/skills/update-spec/SKILL.md deleted file mode 100644 index 435327b2..00000000 --- a/.agents/skills/update-spec/SKILL.md +++ /dev/null @@ -1,335 +0,0 @@ ---- -name: update-spec -description: "Update Code-Spec - Capture Executable Contracts" ---- - -# Update Code-Spec - Capture Executable Contracts - -When you learn something valuable (from debugging, implementing, or discussion), use this skill to update the relevant code-spec documents. - -**Timing**: After completing a task, fixing a bug, or discovering a new pattern - ---- - -## Code-Spec First Rule (CRITICAL) - -In this project, "spec" for implementation work means **code-spec**: -- Executable contracts (not principle-only text) -- Concrete signatures, payload fields, env keys, and boundary behavior -- Testable validation/error behavior - -If the change touches infra or cross-layer contracts, code-spec depth is mandatory. - -Required sections for infra/cross-layer specs: -1. Scope / Trigger -2. Signatures (command/API/DB) -3. Contracts (request/response/env) -4. Validation & Error Matrix -5. Good/Base/Bad Cases -6. Tests Required (with assertion points) -7. Wrong vs Correct (at least one pair) - ---- - -## When to Update Code-Specs - -| Trigger | Example | Target Spec | -|---------|---------|-------------| -| **Implemented a feature** | Added template download with giget | Relevant `backend/` or `frontend/` file | -| **Made a design decision** | Used type field + mapping table for extensibility | Relevant code-spec + "Design Decisions" section | -| **Fixed a bug** | Found a subtle issue with error handling | `backend/error-handling.md` | -| **Discovered a pattern** | Found a better way to structure code | Relevant `backend/` or `frontend/` file | -| **Hit a gotcha** | Learned that X must be done before Y | Relevant code-spec + "Common Mistakes" section | -| **Established a convention** | Team agreed on naming pattern | `quality-guidelines.md` | -| **New thinking trigger** | "Don't forget to check X before doing Y" | `guides/*.md` (as a checklist item, not detailed rules) | - -**Key Insight**: Code-spec updates are NOT just for problems. Every feature implementation contains design decisions and contracts that future AI/developers need to execute safely. - ---- - -## Spec Structure Overview - -``` -.trellis/spec/ -├── backend/ # Backend coding standards -│ ├── index.md # Overview and links -│ └── *.md # Topic-specific guidelines -├── frontend/ # Frontend coding standards -│ ├── index.md # Overview and links -│ └── *.md # Topic-specific guidelines -└── guides/ # Thinking checklists (NOT coding specs!) - ├── index.md # Guide index - └── *.md # Topic-specific guides -``` - -### CRITICAL: Code-Spec vs Guide - Know the Difference - -| Type | Location | Purpose | Content Style | -|------|----------|---------|---------------| -| **Code-Spec** | `backend/*.md`, `frontend/*.md` | Tell AI "how to implement safely" | Signatures, contracts, matrices, cases, test points | -| **Guide** | `guides/*.md` | Help AI "what to think about" | Checklists, questions, pointers to specs | - -**Decision Rule**: Ask yourself: - -- "This is **how to write** the code" → Put in `backend/` or `frontend/` -- "This is **what to consider** before writing" → Put in `guides/` - -**Example**: - -| Learning | Wrong Location | Correct Location | -|----------|----------------|------------------| -| "Use `reconfigure()` not `TextIOWrapper` for Windows stdout" | ❌ `guides/cross-platform-thinking-guide.md` | ✅ `backend/script-conventions.md` | -| "Remember to check encoding when writing cross-platform code" | ❌ `backend/script-conventions.md` | ✅ `guides/cross-platform-thinking-guide.md` | - -**Guides should be short checklists that point to specs**, not duplicate the detailed rules. - ---- - -## Update Process - -### Step 1: Identify What You Learned - -Answer these questions: - -1. **What did you learn?** (Be specific) -2. **Why is it important?** (What problem does it prevent?) -3. **Where does it belong?** (Which spec file?) - -### Step 2: Classify the Update Type - -| Type | Description | Action | -|------|-------------|--------| -| **Design Decision** | Why we chose approach X over Y | Add to "Design Decisions" section | -| **Project Convention** | How we do X in this project | Add to relevant section with examples | -| **New Pattern** | A reusable approach discovered | Add to "Patterns" section | -| **Forbidden Pattern** | Something that causes problems | Add to "Anti-patterns" or "Don't" section | -| **Common Mistake** | Easy-to-make error | Add to "Common Mistakes" section | -| **Convention** | Agreed-upon standard | Add to relevant section | -| **Gotcha** | Non-obvious behavior | Add warning callout | - -### Step 3: Read the Target Code-Spec - -Before editing, read the current code-spec to: -- Understand existing structure -- Avoid duplicating content -- Find the right section for your update - -```bash -cat .trellis/spec/<category>/<file>.md -``` - -### Step 4: Make the Update - -Follow these principles: - -1. **Be Specific**: Include concrete examples, not just abstract rules -2. **Explain Why**: State the problem this prevents -3. **Show Contracts**: Add signatures, payload fields, and error behavior -4. **Show Code**: Add code snippets for key patterns -5. **Keep it Short**: One concept per section - -### Step 5: Update the Index (if needed) - -If you added a new section or the code-spec status changed, update the category's `index.md`. - ---- - -## Update Templates - -### Mandatory Template for Infra/Cross-Layer Work - -```markdown -## Scenario: <name> - -### 1. Scope / Trigger -- Trigger: <why this requires code-spec depth> - -### 2. Signatures -### 3. Contracts -### 4. Validation & Error Matrix -### 5. Good/Base/Bad Cases -### 6. Tests Required -### 7. Wrong vs Correct -#### Wrong -... -#### Correct -... -``` - -### Adding a Design Decision - -```markdown -### Design Decision: [Decision Name] - -**Context**: What problem were we solving? - -**Options Considered**: -1. Option A - brief description -2. Option B - brief description - -**Decision**: We chose Option X because... - -**Example**: -\`\`\`typescript -// How it's implemented -code example -\`\`\` - -**Extensibility**: How to extend this in the future... -``` - -### Adding a Project Convention - -```markdown -### Convention: [Convention Name] - -**What**: Brief description of the convention. - -**Why**: Why we do it this way in this project. - -**Example**: -\`\`\`typescript -// How to follow this convention -code example -\`\`\` - -**Related**: Links to related conventions or specs. -``` - -### Adding a New Pattern - -```markdown -### Pattern Name - -**Problem**: What problem does this solve? - -**Solution**: Brief description of the approach. - -**Example**: -\`\`\` -// Good -code example - -// Bad -code example -\`\`\` - -**Why**: Explanation of why this works better. -``` - -### Adding a Forbidden Pattern - -```markdown -### Don't: Pattern Name - -**Problem**: -\`\`\` -// Don't do this -bad code example -\`\`\` - -**Why it's bad**: Explanation of the issue. - -**Instead**: -\`\`\` -// Do this instead -good code example -\`\`\` -``` - -### Adding a Common Mistake - -```markdown -### Common Mistake: Description - -**Symptom**: What goes wrong - -**Cause**: Why this happens - -**Fix**: How to correct it - -**Prevention**: How to avoid it in the future -``` - -### Adding a Gotcha - -```markdown -> **Warning**: Brief description of the non-obvious behavior. -> -> Details about when this happens and how to handle it. -``` - ---- - -## Interactive Mode - -If you're unsure what to update, answer these prompts: - -1. **What did you just finish?** - - [ ] Fixed a bug - - [ ] Implemented a feature - - [ ] Refactored code - - [ ] Had a discussion about approach - -2. **What did you learn or decide?** - - Design decision (why X over Y) - - Project convention (how we do X) - - Non-obvious behavior (gotcha) - - Better approach (pattern) - -3. **Would future AI/developers need to know this?** - - To understand how the code works → Yes, update spec - - To maintain or extend the feature → Yes, update spec - - To avoid repeating mistakes → Yes, update spec - - Purely one-off implementation detail → Maybe skip - -4. **Which area does it relate to?** - - [ ] Backend code - - [ ] Frontend code - - [ ] Cross-layer data flow - - [ ] Code organization/reuse - - [ ] Quality/testing - ---- - -## Quality Checklist - -Before finishing your code-spec update: - -- [ ] Is the content specific and actionable? -- [ ] Did you include a code example? -- [ ] Did you explain WHY, not just WHAT? -- [ ] Did you include executable signatures/contracts? -- [ ] Did you include validation and error matrix? -- [ ] Did you include Good/Base/Bad cases? -- [ ] Did you include required tests with assertion points? -- [ ] Is it in the right code-spec file? -- [ ] Does it duplicate existing content? -- [ ] Would a new team member understand it? - ---- - -## Relationship to Other Commands - -``` -Development Flow: - Learn something → $update-spec → Knowledge captured - ↑ ↓ - $break-loop ←──────────────────── Future sessions benefit - (deep bug analysis) -``` - -- `$break-loop` - Analyzes bugs deeply, often reveals spec updates needed -- `$update-spec` - Actually makes the updates (this skill) -- `$finish-work` - Reminds you to check if specs need updates - ---- - -## Core Philosophy - -> **Code-specs are living documents. Every debugging session, every "aha moment" is an opportunity to make the implementation contract clearer.** - -The goal is **institutional memory**: -- What one person learns, everyone benefits from -- What AI learns in one session, persists to future sessions -- Mistakes become documented guardrails diff --git a/.claude/agents/check.md b/.claude/agents/check.md deleted file mode 100644 index 071aec4e..00000000 --- a/.claude/agents/check.md +++ /dev/null @@ -1,122 +0,0 @@ ---- -name: check -description: | - Code quality check expert. Reviews code changes against specs and self-fixes issues. -tools: Read, Write, Edit, Bash, Glob, Grep, mcp__exa__web_search_exa, mcp__exa__get_code_context_exa -model: opus ---- -# Check Agent - -You are the Check Agent in the Trellis workflow. - -## Context - -Before checking, read: -- `.trellis/spec/` - Development guidelines -- Pre-commit checklist for quality standards - -## Core Responsibilities - -1. **Get code changes** - Use git diff to get uncommitted code -2. **Check against specs** - Verify code follows guidelines -3. **Self-fix** - Fix issues yourself, not just report them -4. **Run verification** - typecheck and lint - -## Important - -**Fix issues yourself**, don't just report them. - -You have write and edit tools, you can modify code directly. - ---- - -## Workflow - -### Step 1: Get Changes - -```bash -git diff --name-only # List changed files -git diff # View specific changes -``` - -### Step 2: Check Against Specs - -Read relevant specs in `.trellis/spec/` to check code: - -- Does it follow directory structure conventions -- Does it follow naming conventions -- Does it follow code patterns -- Are there missing types -- Are there potential bugs - -### Step 3: Self-Fix - -After finding issues: - -1. Fix the issue directly (use edit tool) -2. Record what was fixed -3. Continue checking other issues - -### Step 4: Run Verification - -Run project's lint and typecheck commands to verify changes. - -If failed, fix issues and re-run. - ---- - -## Completion Markers (Ralph Loop) - -**CRITICAL**: You are in a loop controlled by the Ralph Loop system. -The loop will NOT stop until you output ALL required completion markers. - -Completion markers are generated from `check.jsonl` in the task directory. -Each entry's `reason` field becomes a marker: `{REASON}_FINISH` - -For example, if check.jsonl contains: -```json -{"file": "...", "reason": "TypeCheck"} -{"file": "...", "reason": "Lint"} -{"file": "...", "reason": "CodeReview"} -``` - -You MUST output these markers when each check passes: -- `TYPECHECK_FINISH` - After typecheck passes -- `LINT_FINISH` - After lint passes -- `CODEREVIEW_FINISH` - After code review passes - -If check.jsonl doesn't exist or has no reasons, output: `ALL_CHECKS_FINISH` - -**The loop will block you from stopping until all markers are present in your output.** - ---- - -## Report Format - -```markdown -## Self-Check Complete - -### Files Checked - -- src/components/Feature.tsx -- src/hooks/useFeature.ts - -### Issues Found and Fixed - -1. `<file>:<line>` - <what was fixed> -2. `<file>:<line>` - <what was fixed> - -### Issues Not Fixed - -(If there are issues that cannot be self-fixed, list them here with reasons) - -### Verification Results - -- TypeCheck: Passed TYPECHECK_FINISH -- Lint: Passed LINT_FINISH - -### Summary - -Checked X files, found Y issues, all fixed. -ALL_CHECKS_FINISH -``` diff --git a/.claude/agents/debug.md b/.claude/agents/debug.md deleted file mode 100644 index 0108d99f..00000000 --- a/.claude/agents/debug.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -name: debug -description: | - Issue fixing expert. Understands issues, fixes against specs, and verifies fixes. Precise fixes only. -tools: Read, Write, Edit, Bash, Glob, Grep, mcp__exa__web_search_exa, mcp__exa__get_code_context_exa -model: opus ---- -# Debug Agent - -You are the Debug Agent in the Trellis workflow. - -## Context - -Before debugging, read: -- `.trellis/spec/` - Development guidelines -- Error messages or issue descriptions provided - -## Core Responsibilities - -1. **Understand issues** - Analyze error messages or reported issues -2. **Fix against specs** - Fix issues following dev specs -3. **Verify fixes** - Run typecheck to ensure no new issues -4. **Report results** - Report fix status - ---- - -## Workflow - -### Step 1: Understand Issues - -Parse the issue, categorize by priority: - -- `[P1]` - Must fix (blocking) -- `[P2]` - Should fix (important) -- `[P3]` - Optional fix (nice to have) - -### Step 2: Research if Needed - -If you need additional info: - -```bash -# Check knowledge base -ls .trellis/big-question/ -``` - -### Step 3: Fix One by One - -For each issue: - -1. Locate the exact position -2. Fix following specs -3. Run typecheck to verify - -### Step 4: Verify - -Run project's lint and typecheck commands to verify fixes. - -If fix introduces new issues: - -1. Revert the fix -2. Use a more complete solution -3. Re-verify - ---- - -## Report Format - -```markdown -## Fix Report - -### Issues Fixed - -1. `[P1]` `<file>:<line>` - <what was fixed> -2. `[P2]` `<file>:<line>` - <what was fixed> - -### Issues Not Fixed - -- `<file>:<line>` - <reason why not fixed> - -### Verification - -- TypeCheck: Pass -- Lint: Pass - -### Summary - -Fixed X/Y issues. Z issues require discussion. -``` - ---- - -## Guidelines - -### DO - -- Precise fixes for reported issues -- Follow specs -- Verify each fix - -### DON'T - -- Don't refactor surrounding code -- Don't add new features -- Don't modify unrelated files -- Don't use non-null assertion (`x!` operator) -- Don't execute git commit diff --git a/.claude/agents/dispatch.md b/.claude/agents/dispatch.md deleted file mode 100644 index 827c3920..00000000 --- a/.claude/agents/dispatch.md +++ /dev/null @@ -1,214 +0,0 @@ ---- -name: dispatch -description: | - Multi-Agent Pipeline main dispatcher. Pure dispatcher. Only responsible for calling subagents and scripts in phase order. -tools: Read, Bash, mcp__exa__web_search_exa, mcp__exa__get_code_context_exa -model: opus ---- -# Dispatch Agent - -You are the Dispatch Agent in the Multi-Agent Pipeline (pure dispatcher). - -## Working Directory Convention - -Current Task is specified by `.trellis/.current-task` file, content is the relative path to task directory. - -Task directory path format: `.trellis/tasks/{MM}-{DD}-{name}/` - -This directory contains all context files for the current task: - -- `task.json` - Task configuration -- `prd.md` - Requirements document -- `info.md` - Technical design (optional) -- `implement.jsonl` - Implement context -- `check.jsonl` - Check context -- `debug.jsonl` - Debug context - -## Core Principles - -1. **You are a pure dispatcher** - Only responsible for calling subagents and scripts in order -2. **You don't read specs/requirements** - Hook will auto-inject all context to subagents -3. **You don't need resume** - Hook injects complete context on each subagent call -4. **You only need simple commands** - Tell subagent "start working" is enough - ---- - -## Startup Flow - -### Step 1: Determine Current Task Directory - -Read `.trellis/.current-task` to get current task directory path: - -```bash -TASK_DIR=$(cat .trellis/.current-task) -# e.g.: .trellis/tasks/02-03-my-feature -``` - -### Step 2: Read Task Configuration - -```bash -cat ${TASK_DIR}/task.json -``` - -Get the `next_action` array, which defines the list of phases to execute. - -### Step 3: Execute in Phase Order - -Execute each step in `phase` order. - -> **Note**: You do NOT need to manually update `current_phase`. The Hook automatically updates it when you call Task with a subagent. - ---- - -## Phase Handling - -> Hook will auto-inject all specs, requirements, and technical design to subagent context. -> Dispatch only needs to issue simple call commands. - -### action: "implement" - -``` -Task( - subagent_type: "implement", - prompt: "Implement the feature described in prd.md in the task directory", - model: "opus", - run_in_background: true -) -``` - -Hook will auto-inject: - -- All spec files from implement.jsonl -- Requirements document (prd.md) -- Technical design (info.md) - -Implement receives complete context and autonomously: read → understand → implement. - -### action: "check" - -``` -Task( - subagent_type: "check", - prompt: "Check code changes, fix issues yourself", - model: "opus", - run_in_background: true -) -``` - -Hook will auto-inject: - -- finish-work.md -- check-cross-layer.md -- check-backend.md -- check-frontend.md -- All spec files from check.jsonl - -### action: "debug" - -``` -Task( - subagent_type: "debug", - prompt: "Fix the issues described in the task context", - model: "opus", - run_in_background: true -) -``` - -Hook will auto-inject: - -- All spec files from debug.jsonl -- Error context if available - -### action: "finish" - -``` -Task( - subagent_type: "check", - prompt: "[finish] Execute final completion check before PR", - model: "opus", - run_in_background: true -) -``` - -**Important**: The `[finish]` marker in prompt triggers different context injection: -- finish-work.md checklist -- update-spec.md (spec update process and templates) -- prd.md for verifying requirements are met - -The finish agent actively updates spec docs when it detects new patterns or contracts in the changes. This is different from regular "check" which has full specs for self-fix loop. - -### action: "create-pr" - -This action creates a Pull Request from the feature branch. Run it via Bash: - -```bash -python3 ./.trellis/scripts/multi_agent/create_pr.py -``` - -This will: -1. Stage and commit all changes (excluding workspace) -2. Push to origin -3. Create a Draft PR using `gh pr create` -4. Update task.json with status="review", pr_url, and current_phase - -**Note**: This is the only action that performs git commit, as it's the final step after all implementation and checks are complete. - ---- - -## Calling Subagents - -### Basic Pattern - -``` -task_id = Task( - subagent_type: "implement", // or "check", "debug" - prompt: "Simple task description", - model: "opus", - run_in_background: true -) - -// Poll for completion -for i in 1..N: - result = TaskOutput(task_id, block=true, timeout=300000) - if result.status == "completed": - break -``` - -### Timeout Settings - -| Phase | Max Time | Poll Count | -|-------|----------|------------| -| implement | 30 min | 6 times | -| check | 15 min | 3 times | -| debug | 20 min | 4 times | - ---- - -## Error Handling - -### Timeout - -If a subagent times out, notify the user and ask for guidance: - -``` -"Subagent {phase} timed out after {time}. Options: -1. Retry the same phase -2. Skip to next phase -3. Abort the pipeline" -``` - -### Subagent Failure - -If a subagent reports failure, read the output and decide: - -- If recoverable: call debug agent to fix -- If not recoverable: notify user and ask for guidance - ---- - -## Key Constraints - -1. **Do not read spec/requirement files directly** - Let Hook inject to subagents -2. **Only commit via create-pr action** - Use `multi_agent/create_pr.py` at the end of pipeline -3. **All subagents should use opus model for complex tasks** -4. **Keep dispatch logic simple** - Complex logic belongs in subagents diff --git a/.claude/agents/implement.md b/.claude/agents/implement.md deleted file mode 100644 index 60eaa5d0..00000000 --- a/.claude/agents/implement.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -name: implement -description: | - Code implementation expert. Understands specs and requirements, then implements features. No git commit allowed. -tools: Read, Write, Edit, Bash, Glob, Grep, mcp__exa__web_search_exa, mcp__exa__get_code_context_exa -model: opus ---- -# Implement Agent - -You are the Implement Agent in the Trellis workflow. - -## Context - -Before implementing, read: -- `.trellis/workflow.md` - Project workflow -- `.trellis/spec/` - Development guidelines -- Task `prd.md` - Requirements document -- Task `info.md` - Technical design (if exists) - -## Core Responsibilities - -1. **Understand specs** - Read relevant spec files in `.trellis/spec/` -2. **Understand requirements** - Read prd.md and info.md -3. **Implement features** - Write code following specs and design -4. **Self-check** - Ensure code quality -5. **Report results** - Report completion status - -## Forbidden Operations - -**Do NOT execute these git commands:** - -- `git commit` -- `git push` -- `git merge` - ---- - -## Workflow - -### 1. Understand Specs - -Read relevant specs based on task type: - -- Backend: `.trellis/spec/backend/` -- Frontend: `.trellis/spec/frontend/` -- Guides: `.trellis/spec/guides/` - -### 2. Understand Requirements - -Read the task's prd.md and info.md: - -- What are the core requirements -- Key points of technical design -- Which files to modify/create - -### 3. Implement Features - -- Write code following specs and technical design -- Follow existing code patterns -- Only do what's required, no over-engineering - -### 4. Verify - -Run project's lint and typecheck commands to verify changes. - ---- - -## Report Format - -```markdown -## Implementation Complete - -### Files Modified - -- `src/components/Feature.tsx` - New component -- `src/hooks/useFeature.ts` - New hook - -### Implementation Summary - -1. Created Feature component... -2. Added useFeature hook... - -### Verification Results - -- Lint: Passed -- TypeCheck: Passed -``` - ---- - -## Code Standards - -- Follow existing code patterns -- Don't add unnecessary abstractions -- Only do what's required, no over-engineering -- Keep code readable diff --git a/.claude/agents/plan.md b/.claude/agents/plan.md deleted file mode 100644 index 5c0d0be9..00000000 --- a/.claude/agents/plan.md +++ /dev/null @@ -1,396 +0,0 @@ ---- -name: plan -description: | - Multi-Agent Pipeline planner. Analyzes requirements and produces a fully configured task directory ready for dispatch. -tools: Read, Bash, Glob, Grep, Task -model: opus ---- -# Plan Agent - -You are the Plan Agent in the Multi-Agent Pipeline. - -**Your job**: Evaluate requirements and, if valid, transform them into a fully configured task directory. - -**You have the power to reject** - If a requirement is unclear, incomplete, unreasonable, or potentially harmful, you MUST refuse to proceed and clean up. - ---- - -## Step 0: Evaluate Requirement (CRITICAL) - -Before doing ANY work, evaluate the requirement: - -``` -PLAN_REQUIREMENT = <the requirement from environment> -``` - -### Reject If: - -1. **Unclear or Vague** - - "Make it better" / "Fix the bugs" / "Improve performance" - - No specific outcome defined - - Cannot determine what "done" looks like - -2. **Incomplete Information** - - Missing critical details to implement - - References unknown systems or files - - Depends on decisions not yet made - -3. **Out of Scope for This Project** - - Requirement doesn't match the project's purpose - - Requires changes to external systems - - Not technically feasible with current architecture - -4. **Potentially Harmful** - - Security vulnerabilities (intentional backdoors, data exfiltration) - - Destructive operations without clear justification - - Circumventing access controls - -5. **Too Large / Should Be Split** - - Multiple unrelated features bundled together - - Would require touching too many systems - - Cannot be completed in a reasonable scope - -### If Rejecting: - -1. **Update task.json status to "rejected"**: - ```bash - jq '.status = "rejected"' "$PLAN_TASK_DIR/task.json" > "$PLAN_TASK_DIR/task.json.tmp" \ - && mv "$PLAN_TASK_DIR/task.json.tmp" "$PLAN_TASK_DIR/task.json" - ``` - -2. **Write rejection reason to a file** (so user can see it): - ```bash - cat > "$PLAN_TASK_DIR/REJECTED.md" << 'EOF' - # Plan Rejected - - ## Reason - <category from above> - - ## Details - <specific explanation of why this requirement cannot proceed> - - ## Suggestions - - <what the user should clarify or change> - - <how to make the requirement actionable> - - ## To Retry - - 1. Delete this directory: - rm -rf $PLAN_TASK_DIR - - 2. Run with revised requirement: - python3 ./.trellis/scripts/multi_agent/plan.py --name "<name>" --type "<type>" --requirement "<revised requirement>" - EOF - ``` - -3. **Print summary to stdout** (will be captured in .plan-log): - ``` - === PLAN REJECTED === - - Reason: <category> - Details: <brief explanation> - - See: $PLAN_TASK_DIR/REJECTED.md - ``` - -4. **Exit immediately** - Do not proceed to Step 1. - -**The task directory is kept** with: -- `task.json` (status: "rejected") -- `REJECTED.md` (full explanation) -- `.plan-log` (execution log) - -This allows the user to review why it was rejected. - -### If Accepting: - -Continue to Step 1. The requirement is: -- Clear and specific -- Has a defined outcome -- Is technically feasible -- Is appropriately scoped - ---- - -## Input - -You receive input via environment variables (set by plan.py): - -```bash -PLAN_TASK_NAME # Task name (e.g., "user-auth") -PLAN_DEV_TYPE # Development type: backend | frontend | fullstack -PLAN_REQUIREMENT # Requirement description from user -PLAN_TASK_DIR # Pre-created task directory path -``` - -Read them at startup: - -```bash -echo "Task: $PLAN_TASK_NAME" -echo "Type: $PLAN_DEV_TYPE" -echo "Requirement: $PLAN_REQUIREMENT" -echo "Directory: $PLAN_TASK_DIR" -``` - -## Output (if accepted) - -A complete task directory containing: - -``` -${PLAN_TASK_DIR}/ -├── task.json # Updated with branch, scope, dev_type -├── prd.md # Requirements document -├── implement.jsonl # Implement phase context -├── check.jsonl # Check phase context -└── debug.jsonl # Debug phase context -``` - ---- - -## Workflow (After Acceptance) - -### Step 1: Initialize Context Files - -```bash -python3 ./.trellis/scripts/task.py init-context "$PLAN_TASK_DIR" "$PLAN_DEV_TYPE" -``` - -This creates base jsonl files with standard specs for the dev type. - -### Step 2: Analyze Codebase with Research Agent - -Call research agent to find relevant specs and code patterns: - -``` -Task( - subagent_type: "research", - prompt: "Analyze what specs and code patterns are needed for this task. - -Task: ${PLAN_REQUIREMENT} -Dev Type: ${PLAN_DEV_TYPE} - -Instructions: -1. Search .trellis/spec/ for relevant spec files -2. Search the codebase for related modules and patterns -3. Identify files that should be added to jsonl context - -Output format (use exactly this format): - -## implement.jsonl -- path: <relative file path>, reason: <why needed> -- path: <relative file path>, reason: <why needed> - -## check.jsonl -- path: <relative file path>, reason: <why needed> - -## debug.jsonl -- path: <relative file path>, reason: <why needed> - -## Suggested Scope -<single word for commit scope, e.g., auth, api, ui> - -## Technical Notes -<any important technical considerations for prd.md>", - model: "opus" -) -``` - -### Step 3: Add Context Entries - -Parse research agent output and add entries to jsonl files: - -```bash -# For each entry in implement.jsonl section: -python3 ./.trellis/scripts/task.py add-context "$PLAN_TASK_DIR" implement "<path>" "<reason>" - -# For each entry in check.jsonl section: -python3 ./.trellis/scripts/task.py add-context "$PLAN_TASK_DIR" check "<path>" "<reason>" - -# For each entry in debug.jsonl section: -python3 ./.trellis/scripts/task.py add-context "$PLAN_TASK_DIR" debug "<path>" "<reason>" -``` - -### Step 4: Write prd.md - -Create the requirements document: - -```bash -cat > "$PLAN_TASK_DIR/prd.md" << 'EOF' -# Task: ${PLAN_TASK_NAME} - -## Overview -[Brief description of what this feature does] - -## Requirements -- [Requirement 1] -- [Requirement 2] -- ... - -## Acceptance Criteria -- [ ] [Criterion 1] -- [ ] [Criterion 2] -- ... - -## Technical Notes -[Any technical considerations from research agent] - -## Out of Scope -- [What this feature does NOT include] -EOF -``` - -**Guidelines for prd.md**: -- Be specific and actionable -- Include acceptance criteria that can be verified -- Add technical notes from research agent -- Define what's out of scope to prevent scope creep - -### Step 5: Configure Task Metadata - -```bash -# Set branch name -python3 ./.trellis/scripts/task.py set-branch "$PLAN_TASK_DIR" "feature/${PLAN_TASK_NAME}" - -# Set scope (from research agent suggestion) -python3 ./.trellis/scripts/task.py set-scope "$PLAN_TASK_DIR" "<scope>" - -# Update dev_type in task.json -jq --arg type "$PLAN_DEV_TYPE" '.dev_type = $type' \ - "$PLAN_TASK_DIR/task.json" > "$PLAN_TASK_DIR/task.json.tmp" \ - && mv "$PLAN_TASK_DIR/task.json.tmp" "$PLAN_TASK_DIR/task.json" -``` - -### Step 6: Validate Configuration - -```bash -python3 ./.trellis/scripts/task.py validate "$PLAN_TASK_DIR" -``` - -If validation fails, fix the invalid paths and re-validate. - -### Step 7: Output Summary - -Print a summary for the caller: - -```bash -echo "=== Plan Complete ===" -echo "Task Directory: $PLAN_TASK_DIR" -echo "" -echo "Files created:" -ls -la "$PLAN_TASK_DIR" -echo "" -echo "Context summary:" -python3 ./.trellis/scripts/task.py list-context "$PLAN_TASK_DIR" -echo "" -echo "Ready for: python3 ./.trellis/scripts/multi_agent/start.py $PLAN_TASK_DIR" -``` - ---- - -## Key Principles - -1. **Reject early, reject clearly** - Don't waste time on bad requirements -2. **Research before configure** - Always call research agent to understand the codebase -3. **Validate all paths** - Every file in jsonl must exist -4. **Be specific in prd.md** - Vague requirements lead to wrong implementations -5. **Include acceptance criteria** - Check agent needs to verify something concrete -6. **Set appropriate scope** - This affects commit message format - ---- - -## Error Handling - -### Research Agent Returns No Results - -If research agent finds no relevant specs: -- Use only the base specs from init-context -- Add a note in prd.md that this is a new area without existing patterns - -### Path Not Found - -If add-context fails because path doesn't exist: -- Skip that entry -- Log a warning -- Continue with other entries - -### Validation Fails - -If final validation fails: -- Read the error output -- Remove invalid entries from jsonl files -- Re-validate - ---- - -## Examples - -### Example: Accepted Requirement - -``` -Input: - PLAN_TASK_NAME = "add-rate-limiting" - PLAN_DEV_TYPE = "backend" - PLAN_REQUIREMENT = "Add rate limiting to API endpoints using a sliding window algorithm. Limit to 100 requests per minute per IP. Return 429 status when exceeded." - -Result: ACCEPTED - Clear, specific, has defined behavior - -Output: - .trellis/tasks/02-03-add-rate-limiting/ - ├── task.json # branch: feature/add-rate-limiting, scope: api - ├── prd.md # Detailed requirements with acceptance criteria - ├── implement.jsonl # Backend specs + existing middleware patterns - ├── check.jsonl # Quality guidelines + API testing specs - └── debug.jsonl # Error handling specs -``` - -### Example: Rejected - Vague Requirement - -``` -Input: - PLAN_REQUIREMENT = "Make the API faster" - -Result: REJECTED - -=== PLAN REJECTED === - -Reason: Unclear or Vague - -Details: -"Make the API faster" does not specify: -- Which endpoints need optimization -- Current performance baseline -- Target performance metrics -- Acceptable trade-offs (memory, complexity) - -Suggestions: -- Identify specific slow endpoints with response times -- Define target latency (e.g., "GET /users should respond in <100ms") -- Specify if caching, query optimization, or architecture changes are acceptable -``` - -### Example: Rejected - Too Large - -``` -Input: - PLAN_REQUIREMENT = "Add user authentication, authorization, password reset, 2FA, OAuth integration, and audit logging" - -Result: REJECTED - -=== PLAN REJECTED === - -Reason: Too Large / Should Be Split - -Details: -This requirement bundles 6 distinct features that should be implemented separately: -1. User authentication (login/logout) -2. Authorization (roles/permissions) -3. Password reset flow -4. Two-factor authentication -5. OAuth integration -6. Audit logging - -Suggestions: -- Start with basic authentication first -- Create separate features for each capability -- Consider dependencies (auth before authz, etc.) -``` diff --git a/.claude/agents/research.md b/.claude/agents/research.md deleted file mode 100644 index 659d59c6..00000000 --- a/.claude/agents/research.md +++ /dev/null @@ -1,120 +0,0 @@ ---- -name: research -description: | - Code and tech search expert. Pure research, no code modifications. Finds files, patterns, and tech solutions. -tools: Read, Glob, Grep, mcp__exa__web_search_exa, mcp__exa__get_code_context_exa, Skill, mcp__chrome-devtools__* -model: opus ---- -# Research Agent - -You are the Research Agent in the Trellis workflow. - -## Core Principle - -**You do one thing: find and explain information.** - -You are a documenter, not a reviewer. Your job is to help get the information needed. - ---- - -## Core Responsibilities - -### 1. Internal Search (Project Code) - -| Search Type | Goal | Tools | -|-------------|------|-------| -| **WHERE** | Locate files/components | Glob, Grep | -| **HOW** | Understand code logic | Read, Grep | -| **PATTERN** | Discover existing patterns | Grep, Read | - -### 2. External Search (Tech Solutions) - -Use web search for best practices and code examples. - ---- - -## Strict Boundaries - -### Only Allowed - -- Describe **what exists** -- Describe **where it is** -- Describe **how it works** -- Describe **how components interact** - -### Forbidden (unless explicitly asked) - -- Suggest improvements -- Criticize implementation -- Recommend refactoring -- Modify any files -- Execute git commands - ---- - -## Workflow - -### Step 1: Understand Search Request - -Analyze the query, determine: - -- Search type (internal/external/mixed) -- Search scope (global/specific directory) -- Expected output (file list/code patterns/tech solutions) - -### Step 2: Execute Search - -Execute multiple independent searches in parallel for efficiency. - -### Step 3: Organize Results - -Output structured results in report format. - ---- - -## Report Format - -```markdown -## Search Results - -### Query - -{original query} - -### Files Found - -| File Path | Description | -|-----------|-------------| -| `src/services/xxx.ts` | Main implementation | -| `src/types/xxx.ts` | Type definitions | - -### Code Pattern Analysis - -{Describe discovered patterns, cite specific files and line numbers} - -### Related Spec Documents - -- `.trellis/spec/xxx.md` - {description} - -### Not Found - -{If some content was not found, explain} -``` - ---- - -## Guidelines - -### DO - -- Provide specific file paths and line numbers -- Quote actual code snippets -- Distinguish "definitely found" and "possibly related" -- Explain search scope and limitations - -### DON'T - -- Don't guess uncertain info -- Don't omit important search results -- Don't add improvement suggestions in report (unless explicitly asked) -- Don't modify any files diff --git a/.claude/commands/trellis/before-backend-dev.md b/.claude/commands/trellis/before-backend-dev.md deleted file mode 100644 index 7dfcd365..00000000 --- a/.claude/commands/trellis/before-backend-dev.md +++ /dev/null @@ -1,13 +0,0 @@ -Read the backend development guidelines before starting your development task. - -Execute these steps: -1. Read `.trellis/spec/backend/index.md` to understand available guidelines -2. Based on your task, read the relevant guideline files: - - Database work → `.trellis/spec/backend/database-guidelines.md` - - Error handling → `.trellis/spec/backend/error-handling.md` - - Logging → `.trellis/spec/backend/logging-guidelines.md` - - Type questions → `.trellis/spec/backend/type-safety.md` -3. Understand the coding standards and patterns you need to follow -4. Then proceed with your development plan - -This step is **mandatory** before writing any backend code. diff --git a/.claude/commands/trellis/before-frontend-dev.md b/.claude/commands/trellis/before-frontend-dev.md deleted file mode 100644 index 9687edc1..00000000 --- a/.claude/commands/trellis/before-frontend-dev.md +++ /dev/null @@ -1,13 +0,0 @@ -Read the frontend development guidelines before starting your development task. - -Execute these steps: -1. Read `.trellis/spec/frontend/index.md` to understand available guidelines -2. Based on your task, read the relevant guideline files: - - Component work → `.trellis/spec/frontend/component-guidelines.md` - - Hook work → `.trellis/spec/frontend/hook-guidelines.md` - - State management → `.trellis/spec/frontend/state-management.md` - - Type questions → `.trellis/spec/frontend/type-safety.md` -3. Understand the coding standards and patterns you need to follow -4. Then proceed with your development plan - -This step is **mandatory** before writing any frontend code. diff --git a/.claude/commands/trellis/brainstorm.md b/.claude/commands/trellis/brainstorm.md deleted file mode 100644 index bc2b8afe..00000000 --- a/.claude/commands/trellis/brainstorm.md +++ /dev/null @@ -1,487 +0,0 @@ -# Brainstorm - Requirements Discovery (AI Coding Enhanced) - -Guide AI through collaborative requirements discovery **before implementation**, optimized for AI coding workflows: - -* **Task-first** (capture ideas immediately) -* **Action-before-asking** (reduce low-value questions) -* **Research-first** for technical choices (avoid asking users to invent options) -* **Diverge → Converge** (expand thinking, then lock MVP) - ---- - -## When to Use - -Triggered from `/trellis:start` when the user describes a development task, especially when: - -* requirements are unclear or evolving -* there are multiple valid implementation paths -* trade-offs matter (UX, reliability, maintainability, cost, performance) -* the user might not know the best options up front - ---- - -## Core Principles (Non-negotiable) - -1. **Task-first (capture early)** - Always ensure a task exists at the start so the user's ideas are recorded immediately. - -2. **Action before asking** - If you can derive the answer from repo code, docs, configs, conventions, or quick research — do that first. - -3. **One question per message** - Never overwhelm the user with a list of questions. Ask one, update PRD, repeat. - -4. **Prefer concrete options** - For preference/decision questions, present 2–3 feasible, specific approaches with trade-offs. - -5. **Research-first for technical choices** - If the decision depends on industry conventions / similar tools / established patterns, do research first, then propose options. - -6. **Diverge → Converge** - After initial understanding, proactively consider future evolution, related scenarios, and failure/edge cases — then converge to an MVP with explicit out-of-scope. - -7. **No meta questions** - Do not ask "should I search?" or "can you paste the code so I can continue?" - If you need information: search/inspect. If blocked: ask the minimal blocking question. - ---- - -## Step 0: Ensure Task Exists (ALWAYS) - -Before any Q&A, ensure a task exists. If none exists, create one immediately. - -* Use a **temporary working title** derived from the user's message. -* It's OK if the title is imperfect — refine later in PRD. - -```bash -TASK_DIR=$(python3 ./.trellis/scripts/task.py create "brainstorm: <short goal>" --slug <auto>) -``` - -Create/seed `prd.md` immediately with what you know: - -```markdown -# brainstorm: <short goal> - -## Goal - -<one paragraph: what + why> - -## What I already know - -* <facts from user message> -* <facts discovered from repo/docs> - -## Assumptions (temporary) - -* <assumptions to validate> - -## Open Questions - -* <ONLY Blocking / Preference questions; keep list short> - -## Requirements (evolving) - -* <start with what is known> - -## Acceptance Criteria (evolving) - -* [ ] <testable criterion> - -## Definition of Done (team quality bar) - -* Tests added/updated (unit/integration where appropriate) -* Lint / typecheck / CI green -* Docs/notes updated if behavior changes -* Rollout/rollback considered if risky - -## Out of Scope (explicit) - -* <what we will not do in this task> - -## Technical Notes - -* <files inspected, constraints, links, references> -* <research notes summary if applicable> -``` - ---- - -## Step 1: Auto-Context (DO THIS BEFORE ASKING QUESTIONS) - -Before asking questions like "what does the code look like?", gather context yourself: - -### Repo inspection checklist - -* Identify likely modules/files impacted -* Locate existing patterns (similar features, conventions, error handling style) -* Check configs, scripts, existing command definitions -* Note any constraints (runtime, dependency policy, build tooling) - -### Documentation checklist - -* Look for existing PRDs/specs/templates -* Look for command usage examples, README, ADRs if any - -Write findings into PRD: - -* Add to `What I already know` -* Add constraints/links to `Technical Notes` - ---- - -## Step 2: Classify Complexity (still useful, not gating task creation) - -| Complexity | Criteria | Action | -| ------------ | ------------------------------------------------------ | ------------------------------------------- | -| **Trivial** | Single-line fix, typo, obvious change | Skip brainstorm, implement directly | -| **Simple** | Clear goal, 1–2 files, scope well-defined | Ask 1 confirm question, then implement | -| **Moderate** | Multiple files, some ambiguity | Light brainstorm (2–3 high-value questions) | -| **Complex** | Vague goal, architectural choices, multiple approaches | Full brainstorm | - -> Note: Task already exists from Step 0. Classification only affects depth of brainstorming. - ---- - -## Step 3: Question Gate (Ask ONLY high-value questions) - -Before asking ANY question, run the following gate: - -### Gate A — Can I derive this without the user? - -If answer is available via: - -* repo inspection (code/config) -* docs/specs/conventions -* quick market/OSS research - -→ **Do not ask.** Fetch it, summarize, update PRD. - -### Gate B — Is this a meta/lazy question? - -Examples: - -* "Should I search?" -* "Can you paste the code so I can proceed?" -* "What does the code look like?" (when repo is available) - -→ **Do not ask.** Take action. - -### Gate C — What type of question is it? - -* **Blocking**: cannot proceed without user input -* **Preference**: multiple valid choices, depends on product/UX/risk preference -* **Derivable**: should be answered by inspection/research - -→ Only ask **Blocking** or **Preference**. - ---- - -## Step 4: Research-first Mode (Mandatory for technical choices) - -### Trigger conditions (any → research-first) - -* The task involves selecting an approach, library, protocol, framework, template system, plugin mechanism, or CLI UX convention -* The user asks for "best practice", "how others do it", "recommendation" -* The user can't reasonably enumerate options - -### Research steps - -1. Identify 2–4 comparable tools/patterns -2. Summarize common conventions and why they exist -3. Map conventions onto our repo constraints -4. Produce **2–3 feasible approaches** for our project - -### Research output format (PRD) - -Add a section in PRD (either within Technical Notes or as its own): - -```markdown -## Research Notes - -### What similar tools do - -* ... -* ... - -### Constraints from our repo/project - -* ... - -### Feasible approaches here - -**Approach A: <name>** (Recommended) - -* How it works: -* Pros: -* Cons: - -**Approach B: <name>** - -* How it works: -* Pros: -* Cons: - -**Approach C: <name>** (optional) - -* ... -``` - -Then ask **one** preference question: - -* "Which approach do you prefer: A / B / C (or other)?" - ---- - -## Step 5: Expansion Sweep (DIVERGE) — Required after initial understanding - -After you can summarize the goal, proactively broaden thinking before converging. - -### Expansion categories (keep to 1–2 bullets each) - -1. **Future evolution** - - * What might this feature become in 1–3 months? - * What extension points are worth preserving now? - -2. **Related scenarios** - - * What adjacent commands/flows should remain consistent with this? - * Are there parity expectations (create vs update, import vs export, etc.)? - -3. **Failure & edge cases** - - * Conflicts, offline/network failure, retries, idempotency, compatibility, rollback - * Input validation, security boundaries, permission checks - -### Expansion message template (to user) - -```markdown -I understand you want to implement: <current goal>. - -Before diving into design, let me quickly diverge to consider three categories (to avoid rework later): - -1. Future evolution: <1–2 bullets> -2. Related scenarios: <1–2 bullets> -3. Failure/edge cases: <1–2 bullets> - -For this MVP, which would you like to include (or none)? - -1. Current requirement only (minimal viable) -2. Add <X> (reserve for future extension) -3. Add <Y> (improve robustness/consistency) -4. Other: describe your preference -``` - -Then update PRD: - -* What's in MVP → `Requirements` -* What's excluded → `Out of Scope` - ---- - -## Step 6: Q&A Loop (CONVERGE) - -### Rules - -* One question per message -* Prefer multiple-choice when possible -* After each user answer: - - * Update PRD immediately - * Move answered items from `Open Questions` → `Requirements` - * Update `Acceptance Criteria` with testable checkboxes - * Clarify `Out of Scope` - -### Question priority (recommended) - -1. **MVP scope boundary** (what is included/excluded) -2. **Preference decisions** (after presenting concrete options) -3. **Failure/edge behavior** (only for MVP-critical paths) -4. **Success metrics & Acceptance Criteria** (what proves it works) - -### Preferred question format (multiple choice) - -```markdown -For <topic>, which approach do you prefer? - -1. **Option A** — <what it means + trade-off> -2. **Option B** — <what it means + trade-off> -3. **Option C** — <what it means + trade-off> -4. **Other** — describe your preference -``` - ---- - -## Step 7: Propose Approaches + Record Decisions (Complex tasks) - -After requirements are clear enough, propose 2–3 approaches (if not already done via research-first): - -```markdown -Based on current information, here are 2–3 feasible approaches: - -**Approach A: <name>** (Recommended) - -* How: -* Pros: -* Cons: - -**Approach B: <name>** - -* How: -* Pros: -* Cons: - -Which direction do you prefer? -``` - -Record the outcome in PRD as an ADR-lite section: - -```markdown -## Decision (ADR-lite) - -**Context**: Why this decision was needed -**Decision**: Which approach was chosen -**Consequences**: Trade-offs, risks, potential future improvements -``` - ---- - -## Step 8: Final Confirmation + Implementation Plan - -When open questions are resolved, confirm complete requirements with a structured summary: - -### Final confirmation format - -```markdown -Here's my understanding of the complete requirements: - -**Goal**: <one sentence> - -**Requirements**: - -* ... -* ... - -**Acceptance Criteria**: - -* [ ] ... -* [ ] ... - -**Definition of Done**: - -* ... - -**Out of Scope**: - -* ... - -**Technical Approach**: -<brief summary + key decisions> - -**Implementation Plan (small PRs)**: - -* PR1: <scaffolding + tests + minimal plumbing> -* PR2: <core behavior> -* PR3: <edge cases + docs + cleanup> - -Does this look correct? If yes, I'll proceed with implementation. -``` - -### Subtask Decomposition (Complex Tasks) - -For complex tasks with multiple independent work items, create subtasks: - -```bash -# Create child tasks -CHILD1=$(python3 ./.trellis/scripts/task.py create "Child task 1" --slug child1 --parent "$TASK_DIR") -CHILD2=$(python3 ./.trellis/scripts/task.py create "Child task 2" --slug child2 --parent "$TASK_DIR") - -# Or link existing tasks -python3 ./.trellis/scripts/task.py add-subtask "$TASK_DIR" "$CHILD_DIR" -``` - ---- - -## PRD Target Structure (final) - -`prd.md` should converge to: - -```markdown -# <Task Title> - -## Goal - -<why + what> - -## Requirements - -* ... - -## Acceptance Criteria - -* [ ] ... - -## Definition of Done - -* ... - -## Technical Approach - -<key design + decisions> - -## Decision (ADR-lite) - -Context / Decision / Consequences - -## Out of Scope - -* ... - -## Technical Notes - -<constraints, references, files, research notes> -``` - ---- - -## Anti-Patterns (Hard Avoid) - -* Asking user for code/context that can be derived from repo -* Asking user to choose an approach before presenting concrete options -* Meta questions about whether to research -* Staying narrowly on the initial request without considering evolution/edges -* Letting brainstorming drift without updating PRD - ---- - -## Integration with Start Workflow - -After brainstorm completes (Step 8 confirmation approved), the flow continues to the Task Workflow's **Phase 2: Prepare for Implementation**: - -```text -Brainstorm - Step 0: Create task directory + seed PRD - Step 1–7: Discover requirements, research, converge - Step 8: Final confirmation → user approves - ↓ -Task Workflow Phase 2 (Prepare for Implementation) - Code-Spec Depth Check (if applicable) - → Research codebase (based on confirmed PRD) - → Configure code-spec context (jsonl files) - → Activate task - ↓ -Task Workflow Phase 3 (Execute) - Implement → Check → Complete -``` - -The task directory and PRD already exist from brainstorm, so Phase 1 of the Task Workflow is skipped entirely. - ---- - -## Related Commands - -| Command | When to Use | -|---------|-------------| -| `/trellis:start` | Entry point that triggers brainstorm | -| `/trellis:finish-work` | After implementation is complete | -| `/trellis:update-spec` | If new patterns emerge during work | diff --git a/.claude/commands/trellis/break-loop.md b/.claude/commands/trellis/break-loop.md deleted file mode 100644 index 99057513..00000000 --- a/.claude/commands/trellis/break-loop.md +++ /dev/null @@ -1,125 +0,0 @@ -# Break the Loop - Deep Bug Analysis - -When debug is complete, use this command for deep analysis to break the "fix bug -> forget -> repeat" cycle. - ---- - -## Analysis Framework - -Analyze the bug you just fixed from these 5 dimensions: - -### 1. Root Cause Category - -Which category does this bug belong to? - -| Category | Characteristics | Example | -|----------|-----------------|---------| -| **A. Missing Spec** | No documentation on how to do it | New feature without checklist | -| **B. Cross-Layer Contract** | Interface between layers unclear | API returns different format than expected | -| **C. Change Propagation Failure** | Changed one place, missed others | Changed function signature, missed call sites | -| **D. Test Coverage Gap** | Unit test passes, integration fails | Works alone, breaks when combined | -| **E. Implicit Assumption** | Code relies on undocumented assumption | Timestamp seconds vs milliseconds | - -### 2. Why Fixes Failed (if applicable) - -If you tried multiple fixes before succeeding, analyze each failure: - -- **Surface Fix**: Fixed symptom, not root cause -- **Incomplete Scope**: Found root cause, didn't cover all cases -- **Tool Limitation**: grep missed it, type check wasn't strict -- **Mental Model**: Kept looking in same layer, didn't think cross-layer - -### 3. Prevention Mechanisms - -What mechanisms would prevent this from happening again? - -| Type | Description | Example | -|------|-------------|---------| -| **Documentation** | Write it down so people know | Update thinking guide | -| **Architecture** | Make the error impossible structurally | Type-safe wrappers | -| **Compile-time** | TypeScript strict, no any | Signature change causes compile error | -| **Runtime** | Monitoring, alerts, scans | Detect orphan entities | -| **Test Coverage** | E2E tests, integration tests | Verify full flow | -| **Code Review** | Checklist, PR template | "Did you check X?" | - -### 4. Systematic Expansion - -What broader problems does this bug reveal? - -- **Similar Issues**: Where else might this problem exist? -- **Design Flaw**: Is there a fundamental architecture issue? -- **Process Flaw**: Is there a development process improvement? -- **Knowledge Gap**: Is the team missing some understanding? - -### 5. Knowledge Capture - -Solidify insights into the system: - -- [ ] Update `.trellis/spec/guides/` thinking guides -- [ ] Update `.trellis/spec/backend/` or `frontend/` docs -- [ ] Create issue record (if applicable) -- [ ] Create feature ticket for root fix -- [ ] Update check commands if needed - ---- - -## Output Format - -Please output analysis in this format: - -```markdown -## Bug Analysis: [Short Description] - -### 1. Root Cause Category -- **Category**: [A/B/C/D/E] - [Category Name] -- **Specific Cause**: [Detailed description] - -### 2. Why Fixes Failed (if applicable) -1. [First attempt]: [Why it failed] -2. [Second attempt]: [Why it failed] -... - -### 3. Prevention Mechanisms -| Priority | Mechanism | Specific Action | Status | -|----------|-----------|-----------------|--------| -| P0 | ... | ... | TODO/DONE | - -### 4. Systematic Expansion -- **Similar Issues**: [List places with similar problems] -- **Design Improvement**: [Architecture-level suggestions] -- **Process Improvement**: [Development process suggestions] - -### 5. Knowledge Capture -- [ ] [Documents to update / tickets to create] -``` - ---- - -## Core Philosophy - -> **The value of debugging is not in fixing the bug, but in making this class of bugs never happen again.** - -Three levels of insight: -1. **Tactical**: How to fix THIS bug -2. **Strategic**: How to prevent THIS CLASS of bugs -3. **Philosophical**: How to expand thinking patterns - -30 minutes of analysis saves 30 hours of future debugging. - ---- - -## After Analysis: Immediate Actions - -**IMPORTANT**: After completing the analysis above, you MUST immediately: - -1. **Update spec/guides** - Don't just list TODOs, actually update the relevant files: - - If it's a cross-platform issue → update `cross-platform-thinking-guide.md` - - If it's a cross-layer issue → update `cross-layer-thinking-guide.md` - - If it's a code reuse issue → update `code-reuse-thinking-guide.md` - - If it's domain-specific → update `backend/*.md` or `frontend/*.md` - -2. **Sync templates** - After updating `.trellis/spec/`, sync to `src/templates/markdown/spec/` - -3. **Commit the spec updates** - This is the primary output, not just the analysis text - -> **The analysis is worthless if it stays in chat. The value is in the updated specs.** diff --git a/.claude/commands/trellis/check-backend.md b/.claude/commands/trellis/check-backend.md deleted file mode 100644 index 886f5c9f..00000000 --- a/.claude/commands/trellis/check-backend.md +++ /dev/null @@ -1,13 +0,0 @@ -Check if the code you just wrote follows the backend development guidelines. - -Execute these steps: -1. Run `git status` to see modified files -2. Read `.trellis/spec/backend/index.md` to understand which guidelines apply -3. Based on what you changed, read the relevant guideline files: - - Database changes → `.trellis/spec/backend/database-guidelines.md` - - Error handling → `.trellis/spec/backend/error-handling.md` - - Logging changes → `.trellis/spec/backend/logging-guidelines.md` - - Type changes → `.trellis/spec/backend/type-safety.md` - - Any changes → `.trellis/spec/backend/quality-guidelines.md` -4. Review your code against the guidelines -5. Report any violations and fix them if found diff --git a/.claude/commands/trellis/check-cross-layer.md b/.claude/commands/trellis/check-cross-layer.md deleted file mode 100644 index 591d39b5..00000000 --- a/.claude/commands/trellis/check-cross-layer.md +++ /dev/null @@ -1,153 +0,0 @@ -# Cross-Layer Check - -Check if your changes considered all dimensions. Most bugs come from "didn't think of it", not lack of technical skill. - -> **Note**: This is a **post-implementation** safety net. Ideally, read the [Pre-Implementation Checklist](.trellis/spec/guides/pre-implementation-checklist.md) **before** writing code. - ---- - -## Related Documents - -| Document | Purpose | Timing | -|----------|---------|--------| -| [Pre-Implementation Checklist](.trellis/spec/guides/pre-implementation-checklist.md) | Questions before coding | **Before** writing code | -| [Code Reuse Thinking Guide](.trellis/spec/guides/code-reuse-thinking-guide.md) | Pattern recognition | During implementation | -| **`/trellis:check-cross-layer`** (this) | Verification check | **After** implementation | - ---- - -## Execution Steps - -### 1. Identify Change Scope - -```bash -git status -git diff --name-only -``` - -### 2. Select Applicable Check Dimensions - -Based on your change type, execute relevant checks below: - ---- - -## Dimension A: Cross-Layer Data Flow (Required when 3+ layers) - -**Trigger**: Changes involve 3 or more layers - -| Layer | Common Locations | -|-------|------------------| -| API/Routes | `routes/`, `api/`, `handlers/`, `controllers/` | -| Service/Business Logic | `services/`, `lib/`, `core/`, `domain/` | -| Database/Storage | `db/`, `models/`, `repositories/`, `schema/` | -| UI/Presentation | `components/`, `views/`, `templates/`, `pages/` | -| Utility | `utils/`, `helpers/`, `common/` | - -**Checklist**: -- [ ] Read flow: Database -> Service -> API -> UI -- [ ] Write flow: UI -> API -> Service -> Database -- [ ] Types/schemas correctly passed between layers? -- [ ] Errors properly propagated to caller? -- [ ] Loading/pending states handled at each layer? - -**Detailed Guide**: `.trellis/spec/guides/cross-layer-thinking-guide.md` - ---- - -## Dimension B: Code Reuse (Required when modifying constants/config) - -**Trigger**: -- Modifying UI constants (label, icon, color) -- Modifying any hardcoded value -- Seeing similar code in multiple places -- Creating a new utility/helper function -- Just finished batch modifications across files - -**Checklist**: -- [ ] Search first: How many places define this value? - ```bash - # Search in source files (adjust extensions for your project) - grep -r "value-to-change" src/ - ``` -- [ ] If 2+ places define same value -> Should extract to shared constant -- [ ] After modification, all usage sites updated? -- [ ] If creating utility: Does similar utility already exist? - -**Detailed Guide**: `.trellis/spec/guides/code-reuse-thinking-guide.md` - ---- - -## Dimension B2: New Utility Functions - -**Trigger**: About to create a new utility/helper function - -**Checklist**: -- [ ] Search for existing similar utilities first - ```bash - grep -r "functionNamePattern" src/ - ``` -- [ ] If similar exists, can you extend it instead? -- [ ] If creating new, is it in the right location (shared vs domain-specific)? - ---- - -## Dimension B3: After Batch Modifications - -**Trigger**: Just modified similar patterns in multiple files - -**Checklist**: -- [ ] Did you check ALL files with similar patterns? - ```bash - grep -r "patternYouChanged" src/ - ``` -- [ ] Any files missed that should also be updated? -- [ ] Should this pattern be abstracted to prevent future duplication? - ---- - -## Dimension C: Import/Dependency Paths (Required when creating new files) - -**Trigger**: Creating new source files - -**Checklist**: -- [ ] Using correct import paths (relative vs absolute)? -- [ ] No circular dependencies? -- [ ] Consistent with project's module organization? - ---- - -## Dimension D: Same-Layer Consistency - -**Trigger**: -- Modifying display logic or formatting -- Same domain concept used in multiple places - -**Checklist**: -- [ ] Search for other places using same concept - ```bash - grep -r "ConceptName" src/ - ``` -- [ ] Are these usages consistent? -- [ ] Should they share configuration/constants? - ---- - -## Common Issues Quick Reference - -| Issue | Root Cause | Prevention | -|-------|------------|------------| -| Changed one place, missed others | Didn't search impact scope | `grep` before changing | -| Data lost at some layer | Didn't check data flow | Trace data source to destination | -| Type/schema mismatch | Cross-layer types inconsistent | Use shared type definitions | -| UI/output inconsistent | Same concept in multiple places | Extract shared constants | -| Similar utility exists | Didn't search first | Search before creating | -| Batch fix incomplete | Didn't verify all occurrences | grep after fixing | - ---- - -## Output - -Report: -1. Which dimensions your changes involve -2. Check results for each dimension -3. Issues found and fix suggestions diff --git a/.claude/commands/trellis/check-frontend.md b/.claude/commands/trellis/check-frontend.md deleted file mode 100644 index 3771ae3a..00000000 --- a/.claude/commands/trellis/check-frontend.md +++ /dev/null @@ -1,13 +0,0 @@ -Check if the code you just wrote follows the frontend development guidelines. - -Execute these steps: -1. Run `git status` to see modified files -2. Read `.trellis/spec/frontend/index.md` to understand which guidelines apply -3. Based on what you changed, read the relevant guideline files: - - Component changes → `.trellis/spec/frontend/component-guidelines.md` - - Hook changes → `.trellis/spec/frontend/hook-guidelines.md` - - State changes → `.trellis/spec/frontend/state-management.md` - - Type changes → `.trellis/spec/frontend/type-safety.md` - - Any changes → `.trellis/spec/frontend/quality-guidelines.md` -4. Review your code against the guidelines -5. Report any violations and fix them if found diff --git a/.claude/commands/trellis/create-command.md b/.claude/commands/trellis/create-command.md deleted file mode 100644 index 121d37f9..00000000 --- a/.claude/commands/trellis/create-command.md +++ /dev/null @@ -1,154 +0,0 @@ -# Create New Slash Command - -Create a new slash command in both `.cursor/commands/` (with `trellis-` prefix) and `.claude/commands/trellis/` directories based on user requirements. - -## Usage - -``` -/trellis:create-command <command-name> <description> -``` - -**Example**: -``` -/trellis:create-command review-pr Check PR code changes against project guidelines -``` - -## Execution Steps - -### 1. Parse Input - -Extract from user input: -- **Command name**: Use kebab-case (e.g., `review-pr`) -- **Description**: What the command should accomplish - -### 2. Analyze Requirements - -Determine command type based on description: -- **Initialization**: Read docs, establish context -- **Pre-development**: Read guidelines, check dependencies -- **Code check**: Validate code quality and guideline compliance -- **Recording**: Record progress, questions, structure changes -- **Generation**: Generate docs, code templates - -### 3. Generate Command Content - -Based on command type, generate appropriate content: - -**Simple command** (1-3 lines): -```markdown -Concise instruction describing what to do -``` - -**Complex command** (with steps): -```markdown -# Command Title - -Command description - -## Steps - -### 1. First Step -Specific action - -### 2. Second Step -Specific action - -## Output Format (if needed) - -Template -``` - -### 4. Create Files - -Create in both directories: -- `.cursor/commands/trellis-<command-name>.md` -- `.claude/commands/trellis/<command-name>.md` - -### 5. Confirm Creation - -Output result: -``` -[OK] Created Slash Command: /<command-name> - -File paths: -- .cursor/commands/trellis-<command-name>.md -- .claude/commands/trellis/<command-name>.md - -Usage: -/trellis:<command-name> - -Description: -<description> -``` - -## Command Content Guidelines - -### [OK] Good command content - -1. **Clear and concise**: Immediately understandable -2. **Executable**: AI can follow steps directly -3. **Well-scoped**: Clear boundaries of what to do and not do -4. **Has output**: Specifies expected output format (if needed) - -### [X] Avoid - -1. **Too vague**: e.g., "optimize code" -2. **Too complex**: Single command should not exceed 100 lines -3. **Duplicate functionality**: Check if similar command exists first - -## Naming Conventions - -| Command Type | Prefix | Example | -|--------------|--------|---------| -| Session Start | `start` | `start` | -| Pre-development | `before-` | `before-frontend-dev` | -| Check | `check-` | `check-frontend` | -| Record | `record-` | `record-session` | -| Generate | `generate-` | `generate-api-doc` | -| Update | `update-` | `update-changelog` | -| Other | Verb-first | `review-code`, `sync-data` | - -## Example - -### Input -``` -/trellis:create-command review-pr Check PR code changes against project guidelines -``` - -### Generated Command Content -```markdown -# PR Code Review - -Check current PR code changes against project guidelines. - -## Steps - -### 1. Get Changed Files -```bash -git diff main...HEAD --name-only -``` - -### 2. Categorized Review - -**Frontend files** (`apps/web/`): -- Reference `.trellis/spec/frontend/index.md` - -**Backend files** (`packages/api/`): -- Reference `.trellis/spec/backend/index.md` - -### 3. Output Review Report - -Format: - -## PR Review Report - -### Changed Files -- [file list] - -### Check Results -- [OK] Passed items -- [X] Issues found - -### Suggestions -- [improvement suggestions] -``` diff --git a/.claude/commands/trellis/finish-work.md b/.claude/commands/trellis/finish-work.md deleted file mode 100644 index 9daea672..00000000 --- a/.claude/commands/trellis/finish-work.md +++ /dev/null @@ -1,153 +0,0 @@ -# Finish Work - Pre-Commit Checklist - -Before submitting or committing, use this checklist to ensure work completeness. - -**Timing**: After code is written and tested, before commit - ---- - -## Checklist - -### 1. Code Quality - -```bash -# Must pass -pnpm lint -pnpm type-check -pnpm test -``` - -- [ ] `pnpm lint` passes with 0 errors? -- [ ] `pnpm type-check` passes with no type errors? -- [ ] Tests pass? -- [ ] No `console.log` statements (use logger)? -- [ ] No non-null assertions (the `x!` operator)? -- [ ] No `any` types? - -### 1.5. Test Coverage - -Check if your change needs new or updated tests (see `.trellis/spec/unit-test/conventions.md`): - -- [ ] New pure function → unit test added? -- [ ] Bug fix → regression test added in `test/regression.test.ts`? -- [ ] Changed init/update behavior → integration test added/updated? -- [ ] No logic change (text/data only) → no test needed - -### 2. Code-Spec Sync - -**Code-Spec Docs**: -- [ ] Does `.trellis/spec/backend/` need updates? - - New patterns, new modules, new conventions -- [ ] Does `.trellis/spec/frontend/` need updates? - - New components, new hooks, new patterns -- [ ] Does `.trellis/spec/guides/` need updates? - - New cross-layer flows, lessons from bugs - -**Key Question**: -> "If I fixed a bug or discovered something non-obvious, should I document it so future me (or others) won't hit the same issue?" - -If YES -> Update the relevant code-spec doc. - -### 2.5. Code-Spec Hard Block (Infra/Cross-Layer) - -If this change touches infra or cross-layer contracts, this is a blocking checklist: - -- [ ] Spec content is executable (real signatures/contracts), not principle-only text -- [ ] Includes file path + command/API name + payload field names -- [ ] Includes validation and error matrix -- [ ] Includes Good/Base/Bad cases -- [ ] Includes required tests and assertion points - -**Block Rule**: -In pipeline mode, the finish agent will automatically detect and execute spec updates when gaps are found. -If running this checklist manually, ensure spec sync is complete before committing — run `/trellis:update-spec` if needed. - -### 3. API Changes - -If you modified API endpoints: - -- [ ] Input schema updated? -- [ ] Output schema updated? -- [ ] API documentation updated? -- [ ] Client code updated to match? - -### 4. Database Changes - -If you modified database schema: - -- [ ] Migration file created? -- [ ] Schema file updated? -- [ ] Related queries updated? -- [ ] Seed data updated (if applicable)? - -### 5. Cross-Layer Verification - -If the change spans multiple layers: - -- [ ] Data flows correctly through all layers? -- [ ] Error handling works at each boundary? -- [ ] Types are consistent across layers? -- [ ] Loading states handled? - -### 6. Manual Testing - -- [ ] Feature works in browser/app? -- [ ] Edge cases tested? -- [ ] Error states tested? -- [ ] Works after page refresh? - ---- - -## Quick Check Flow - -```bash -# 1. Code checks -pnpm lint && pnpm type-check - -# 2. View changes -git status -git diff --name-only - -# 3. Based on changed files, check relevant items above -``` - ---- - -## Common Oversights - -| Oversight | Consequence | Check | -|-----------|-------------|-------| -| Code-spec docs not updated | Others don't know the change | Check .trellis/spec/ | -| Spec text is abstract only | Easy regressions in infra/cross-layer changes | Require signature/contract/matrix/cases/tests | -| Migration not created | Schema out of sync | Check db/migrations/ | -| Types not synced | Runtime errors | Check shared types | -| Tests not updated | False confidence | Run full test suite | -| Console.log left in | Noisy production logs | Search for console.log | - ---- - -## Relationship to Other Commands - -``` -Development Flow: - Write code -> Test -> /trellis:finish-work -> git commit -> /trellis:record-session - | | - Ensure completeness Record progress - -Debug Flow: - Hit bug -> Fix -> /trellis:break-loop -> Knowledge capture - | - Deep analysis -``` - -- `/trellis:finish-work` - Check work completeness (this command) -- `/trellis:record-session` - Record session and commits -- `/trellis:break-loop` - Deep analysis after debugging - ---- - -## Core Principle - -> **Delivery includes not just code, but also documentation, verification, and knowledge capture.** - -Complete work = Code + Docs + Tests + Verification diff --git a/.claude/commands/trellis/integrate-skill.md b/.claude/commands/trellis/integrate-skill.md deleted file mode 100644 index cacafd5a..00000000 --- a/.claude/commands/trellis/integrate-skill.md +++ /dev/null @@ -1,219 +0,0 @@ -# Integrate Claude Skill into Project Guidelines - -Adapt and integrate a Claude global skill into your project's development guidelines (not directly into project code). - -## Usage - -``` -/trellis:integrate-skill <skill-name> -``` - -**Examples**: -``` -/trellis:integrate-skill frontend-design -/trellis:integrate-skill mcp-builder -``` - -## Core Principle - -> [!] **Important**: The goal of skill integration is to update **development guidelines**, not to generate project code directly. -> -> - Guidelines content -> Write to `.trellis/spec/{target}/doc.md` -> - Code examples -> Place in `.trellis/spec/{target}/examples/skills/<skill-name>/` -> - Example files -> Use `.template` suffix (e.g., `component.tsx.template`) to avoid IDE errors -> -> Where `{target}` is `frontend` or `backend`, determined by skill type. - -## Execution Steps - -### 1. Read Skill Content - -```bash -openskills read <skill-name> -``` - -If the skill doesn't exist, prompt user to check available skills: -```bash -# Available skills are listed in AGENTS.md under <available_skills> -``` - -### 2. Determine Integration Target - -Based on skill type, determine which guidelines to update: - -| Skill Category | Integration Target | -|----------------|-------------------| -| UI/Frontend (`frontend-design`, `web-artifacts-builder`) | `.trellis/spec/frontend/` | -| Backend/API (`mcp-builder`) | `.trellis/spec/backend/` | -| Documentation (`doc-coauthoring`, `docx`, `pdf`) | `.trellis/` or create dedicated guidelines | -| Testing (`webapp-testing`) | `.trellis/spec/frontend/` (E2E) | - -### 3. Analyze Skill Content - -Extract from the skill: -- **Core concepts**: How the skill works and key concepts -- **Best practices**: Recommended approaches -- **Code patterns**: Reusable code templates -- **Caveats**: Common issues and solutions - -### 4. Execute Integration - -#### 4.1 Update Guidelines Document - -Add a new section to the corresponding `doc.md`: - -```markdown -@@@section:skill-<skill-name> -## # <Skill Name> Integration Guide - -### Overview -[Core functionality and use cases of the skill] - -### Project Adaptation -[How to use this skill in the current project] - -### Usage Steps -1. [Step 1] -2. [Step 2] - -### Caveats -- [Project-specific constraints] -- [Differences from default behavior] - -### Reference Examples -See `examples/skills/<skill-name>/` - -@@@/section:skill-<skill-name> -``` - -#### 4.2 Create Examples Directory (if code examples exist) - -```bash -# Directory structure ({target} = frontend or backend) -.trellis/spec/{target}/ -|-- doc.md # Add skill-related section -|-- index.md # Update index -+-- examples/ - +-- skills/ - +-- <skill-name>/ - |-- README.md # Example documentation - |-- example-1.ts.template # Code example (use .template suffix) - +-- example-2.tsx.template -``` - -**File naming conventions**: -- Code files: `<name>.<ext>.template` (e.g., `component.tsx.template`) -- Config files: `<name>.config.template` (e.g., `tailwind.config.template`) -- Documentation: `README.md` (normal suffix) - -#### 4.3 Update Index File - -Add to the Quick Navigation table in `index.md`: - -```markdown -| <Skill-related task> | <Section name> | `skill-<skill-name>` | -``` - -### 5. Generate Integration Report - ---- - -## Skill Integration Report: `<skill-name>` - -### # Overview -- **Skill description**: [Functionality description] -- **Integration target**: `.trellis/spec/{target}/` - -### # Tech Stack Compatibility - -| Skill Requirement | Project Status | Compatibility | -|-------------------|----------------|---------------| -| [Tech 1] | [Project tech] | [OK]/[!]/[X] | - -### # Integration Locations - -| Type | Path | -|------|------| -| Guidelines doc | `.trellis/spec/{target}/doc.md` (section: `skill-<name>`) | -| Code examples | `.trellis/spec/{target}/examples/skills/<name>/` | -| Index update | `.trellis/spec/{target}/index.md` | - -> `{target}` = `frontend` or `backend` - -### # Dependencies (if needed) - -```bash -# Install required dependencies (adjust for your package manager) -npm install <package> -# or -pnpm add <package> -# or -yarn add <package> -``` - -### [OK] Completed Changes - -- [ ] Added `@@@section:skill-<name>` section to `doc.md` -- [ ] Added index entry to `index.md` -- [ ] Created example files in `examples/skills/<name>/` -- [ ] Example files use `.template` suffix - -### # Related Guidelines - -- [Existing related section IDs] - ---- - -## 6. Optional: Create Usage Command - -If this skill is frequently used, create a shortcut command: - -```bash -/trellis:create-command use-<skill-name> Use <skill-name> skill following project guidelines -``` - -## Common Skill Integration Reference - -| Skill | Integration Target | Examples Directory | -|-------|-------------------|-------------------| -| `frontend-design` | `frontend` | `examples/skills/frontend-design/` | -| `mcp-builder` | `backend` | `examples/skills/mcp-builder/` | -| `webapp-testing` | `frontend` | `examples/skills/webapp-testing/` | -| `doc-coauthoring` | `.trellis/` | N/A (documentation workflow only) | - -## Example: Integrating `mcp-builder` Skill - -### Directory Structure - -``` -.trellis/spec/backend/ -|-- doc.md # Add MCP section -|-- index.md # Add index entry -+-- examples/ - +-- skills/ - +-- mcp-builder/ - |-- README.md - |-- server.ts.template - |-- tools.ts.template - +-- types.ts.template -``` - -### New Section in doc.md - -```markdown -@@@section:skill-mcp-builder -## # MCP Server Development Guide - -### Overview -Create LLM-callable tool services using MCP (Model Context Protocol). - -### Project Adaptation -- Place services in a dedicated directory -- Follow existing TypeScript and type definition conventions -- Use project's logging system - -### Reference Examples -See `examples/skills/mcp-builder/` - -@@@/section:skill-mcp-builder -``` diff --git a/.claude/commands/trellis/onboard.md b/.claude/commands/trellis/onboard.md deleted file mode 100644 index 732f80d1..00000000 --- a/.claude/commands/trellis/onboard.md +++ /dev/null @@ -1,358 +0,0 @@ -You are a senior developer onboarding a new team member to this project's AI-assisted workflow system. - -YOUR ROLE: Be a mentor and teacher. Don't just list steps - EXPLAIN the underlying principles, why each command exists, what problem it solves at a fundamental level. - -## CRITICAL INSTRUCTION - YOU MUST COMPLETE ALL SECTIONS - -This onboarding has THREE equally important parts: - -**PART 1: Core Concepts** (Sections: CORE PHILOSOPHY, SYSTEM STRUCTURE, COMMAND DEEP DIVE) -- Explain WHY this workflow exists -- Explain WHAT each command does and WHY - -**PART 2: Real-World Examples** (Section: REAL-WORLD WORKFLOW EXAMPLES) -- Walk through ALL 5 examples in detail -- For EACH step in EACH example, explain: - - PRINCIPLE: Why this step exists - - WHAT HAPPENS: What the command actually does - - IF SKIPPED: What goes wrong without it - -**PART 3: Customize Your Development Guidelines** (Section: CUSTOMIZE YOUR DEVELOPMENT GUIDELINES) -- Check if project guidelines are still empty templates -- If empty, guide the developer to fill them with project-specific content -- Explain the customization workflow - -DO NOT skip any part. All three parts are essential: -- Part 1 teaches the concepts -- Part 2 shows how concepts work in practice -- Part 3 ensures the project has proper guidelines for AI to follow - -After completing ALL THREE parts, ask the developer about their first task. - ---- - -## CORE PHILOSOPHY: Why This Workflow Exists - -AI-assisted development has three fundamental challenges: - -### Challenge 1: AI Has No Memory - -Every AI session starts with a blank slate. Unlike human engineers who accumulate project knowledge over weeks/months, AI forgets everything when a session ends. - -**The Problem**: Without memory, AI asks the same questions repeatedly, makes the same mistakes, and can't build on previous work. - -**The Solution**: The `.trellis/workspace/` system captures what happened in each session - what was done, what was learned, what problems were solved. The `/trellis:start` command reads this history at session start, giving AI "artificial memory." - -### Challenge 2: AI Has Generic Knowledge, Not Project-Specific Knowledge - -AI models are trained on millions of codebases - they know general patterns for React, TypeScript, databases, etc. But they don't know YOUR project's conventions. - -**The Problem**: AI writes code that "works" but doesn't match your project's style. It uses patterns that conflict with existing code. It makes decisions that violate unwritten team rules. - -**The Solution**: The `.trellis/spec/` directory contains project-specific guidelines. The `/before-*-dev` commands inject this specialized knowledge into AI context before coding starts. - -### Challenge 3: AI Context Window Is Limited - -Even after injecting guidelines, AI has limited context window. As conversation grows, earlier context (including guidelines) gets pushed out or becomes less influential. - -**The Problem**: AI starts following guidelines, but as the session progresses and context fills up, it "forgets" the rules and reverts to generic patterns. - -**The Solution**: The `/check-*` commands re-verify code against guidelines AFTER writing, catching drift that occurred during development. The `/trellis:finish-work` command does a final holistic review. - ---- - -## SYSTEM STRUCTURE - -``` -.trellis/ -|-- .developer # Your identity (gitignored) -|-- workflow.md # Complete workflow documentation -|-- workspace/ # "AI Memory" - session history -| |-- index.md # All developers' progress -| +-- {developer}/ # Per-developer directory -| |-- index.md # Personal progress index -| +-- journal-N.md # Session records (max 2000 lines) -|-- tasks/ # Task tracking (unified) -| +-- {MM}-{DD}-{slug}/ # Task directory -| |-- task.json # Task metadata -| +-- prd.md # Requirements doc -|-- spec/ # "AI Training Data" - project knowledge -| |-- frontend/ # Frontend conventions -| |-- backend/ # Backend conventions -| +-- guides/ # Thinking patterns -+-- scripts/ # Automation tools -``` - -### Understanding spec/ subdirectories - -**frontend/** - Single-layer frontend knowledge: -- Component patterns (how to write components in THIS project) -- State management rules (Redux? Zustand? Context?) -- Styling conventions (CSS modules? Tailwind? Styled-components?) -- Hook patterns (custom hooks, data fetching) - -**backend/** - Single-layer backend knowledge: -- API design patterns (REST? GraphQL? tRPC?) -- Database conventions (query patterns, migrations) -- Error handling standards -- Logging and monitoring rules - -**guides/** - Cross-layer thinking guides: -- Code reuse thinking guide -- Cross-layer thinking guide -- Pre-implementation checklists - ---- - -## COMMAND DEEP DIVE - -### /trellis:start - Restore AI Memory - -**WHY IT EXISTS**: -When a human engineer joins a project, they spend days/weeks learning: What is this project? What's been built? What's in progress? What's the current state? - -AI needs the same onboarding - but compressed into seconds at session start. - -**WHAT IT ACTUALLY DOES**: -1. Reads developer identity (who am I in this project?) -2. Checks git status (what branch? uncommitted changes?) -3. Reads recent session history from `workspace/` (what happened before?) -4. Identifies active features (what's in progress?) -5. Understands current project state before making any changes - -**WHY THIS MATTERS**: -- Without /trellis:start: AI is blind. It might work on wrong branch, conflict with others' work, or redo already-completed work. -- With /trellis:start: AI knows project context, can continue where previous session left off, avoids conflicts. - ---- - -### /trellis:before-frontend-dev and /trellis:before-backend-dev - Inject Specialized Knowledge - -**WHY IT EXISTS**: -AI models have "pre-trained knowledge" - general patterns from millions of codebases. But YOUR project has specific conventions that differ from generic patterns. - -**WHAT IT ACTUALLY DOES**: -1. Reads `.trellis/spec/frontend/` or `.trellis/spec/backend/` -2. Loads project-specific patterns into AI's working context: - - Component naming conventions - - State management patterns - - Database query patterns - - Error handling standards - -**WHY THIS MATTERS**: -- Without before-*-dev: AI writes generic code that doesn't match project style. -- With before-*-dev: AI writes code that looks like the rest of the codebase. - ---- - -### /trellis:check-frontend and /trellis:check-backend - Combat Context Drift - -**WHY IT EXISTS**: -AI context window has limited capacity. As conversation progresses, guidelines injected at session start become less influential. This causes "context drift." - -**WHAT IT ACTUALLY DOES**: -1. Re-reads the guidelines that were injected earlier -2. Compares written code against those guidelines -3. Runs type checker and linter -4. Identifies violations and suggests fixes - -**WHY THIS MATTERS**: -- Without check-*: Context drift goes unnoticed, code quality degrades. -- With check-*: Drift is caught and corrected before commit. - ---- - -### /trellis:check-cross-layer - Multi-Dimension Verification - -**WHY IT EXISTS**: -Most bugs don't come from lack of technical skill - they come from "didn't think of it": -- Changed a constant in one place, missed 5 other places -- Modified database schema, forgot to update the API layer -- Created a utility function, but similar one already exists - -**WHAT IT ACTUALLY DOES**: -1. Identifies which dimensions your change involves -2. For each dimension, runs targeted checks: - - Cross-layer data flow - - Code reuse analysis - - Import path validation - - Consistency checks - ---- - -### /trellis:finish-work - Holistic Pre-Commit Review - -**WHY IT EXISTS**: -The `/check-*` commands focus on code quality within a single layer. But real changes often have cross-cutting concerns. - -**WHAT IT ACTUALLY DOES**: -1. Reviews all changes holistically -2. Checks cross-layer consistency -3. Identifies broader impacts -4. Checks if new patterns should be documented - ---- - -### /trellis:record-session - Persist Memory for Future - -**WHY IT EXISTS**: -All the context AI built during this session will be lost when session ends. The next session's `/trellis:start` needs this information. - -**WHAT IT ACTUALLY DOES**: -1. Records session summary to `workspace/{developer}/journal-N.md` -2. Captures what was done, learned, and what's remaining -3. Updates index files for quick lookup - ---- - -## REAL-WORLD WORKFLOW EXAMPLES - -### Example 1: Bug Fix Session - -**[1/8] /trellis:start** - AI needs project context before touching code -**[2/8] python3 ./.trellis/scripts/task.py create "Fix bug" --slug fix-bug** - Track work for future reference -**[3/8] /trellis:before-frontend-dev** - Inject project-specific frontend knowledge -**[4/8] Investigate and fix the bug** - Actual development work -**[5/8] /trellis:check-frontend** - Re-verify code against guidelines -**[6/8] /trellis:finish-work** - Holistic cross-layer review -**[7/8] Human tests and commits** - Human validates before code enters repo -**[8/8] /trellis:record-session** - Persist memory for future sessions - -### Example 2: Planning Session (No Code) - -**[1/4] /trellis:start** - Context needed even for non-coding work -**[2/4] python3 ./.trellis/scripts/task.py create "Planning task" --slug planning-task** - Planning is valuable work -**[3/4] Review docs, create subtask list** - Actual planning work -**[4/4] /trellis:record-session (with --summary)** - Planning decisions must be recorded - -### Example 3: Code Review Fixes - -**[1/6] /trellis:start** - Resume context from previous session -**[2/6] /trellis:before-backend-dev** - Re-inject guidelines before fixes -**[3/6] Fix each CR issue** - Address feedback with guidelines in context -**[4/6] /trellis:check-backend** - Verify fixes didn't introduce new issues -**[5/6] /trellis:finish-work** - Document lessons from CR -**[6/6] Human commits, then /trellis:record-session** - Preserve CR lessons - -### Example 4: Large Refactoring - -**[1/5] /trellis:start** - Clear baseline before major changes -**[2/5] Plan phases** - Break into verifiable chunks -**[3/5] Execute phase by phase with /check-* after each** - Incremental verification -**[4/5] /trellis:finish-work** - Check if new patterns should be documented -**[5/5] Record with multiple commit hashes** - Link all commits to one feature - -### Example 5: Debug Session - -**[1/6] /trellis:start** - See if this bug was investigated before -**[2/6] /trellis:before-backend-dev** - Guidelines might document known gotchas -**[3/6] Investigation** - Actual debugging work -**[4/6] /trellis:check-backend** - Verify debug changes don't break other things -**[5/6] /trellis:finish-work** - Debug findings might need documentation -**[6/6] Human commits, then /trellis:record-session** - Debug knowledge is valuable - ---- - -## KEY RULES TO EMPHASIZE - -1. **AI NEVER commits** - Human tests and approves. AI prepares, human validates. -2. **Guidelines before code** - /before-*-dev commands inject project knowledge. -3. **Check after code** - /check-* commands catch context drift. -4. **Record everything** - /trellis:record-session persists memory. - ---- - -# PART 3: Customize Your Development Guidelines - -After explaining Part 1 and Part 2, check if the project's development guidelines need customization. - -## Step 1: Check Current Guidelines Status - -Check if `.trellis/spec/` contains empty templates or customized guidelines: - -```bash -# Check if files are still empty templates (look for placeholder text) -grep -l "To be filled by the team" .trellis/spec/backend/*.md 2>/dev/null | wc -l -grep -l "To be filled by the team" .trellis/spec/frontend/*.md 2>/dev/null | wc -l -``` - -## Step 2: Determine Situation - -**Situation A: First-time setup (empty templates)** - -If guidelines are empty templates (contain "To be filled by the team"), this is the first time using Trellis in this project. - -Explain to the developer: - -"I see that the development guidelines in `.trellis/spec/` are still empty templates. This is normal for a new Trellis setup! - -The templates contain placeholder text that needs to be replaced with YOUR project's actual conventions. Without this, `/before-*-dev` commands won't provide useful guidance. - -**Your first task should be to fill in these guidelines:** - -1. Look at your existing codebase -2. Identify the patterns and conventions already in use -3. Document them in the guideline files - -For example, for `.trellis/spec/backend/database-guidelines.md`: -- What ORM/query library does your project use? -- How are migrations managed? -- What naming conventions for tables/columns? - -Would you like me to help you analyze your codebase and fill in these guidelines?" - -**Situation B: Guidelines already customized** - -If guidelines have real content (no "To be filled" placeholders), this is an existing setup. - -Explain to the developer: - -"Great! Your team has already customized the development guidelines. You can start using `/before-*-dev` commands right away. - -I recommend reading through `.trellis/spec/` to familiarize yourself with the team's coding standards." - -## Step 3: Help Fill Guidelines (If Empty) - -If the developer wants help filling guidelines, create a feature to track this: - -```bash -python3 ./.trellis/scripts/task.py create "Fill spec guidelines" --slug fill-spec-guidelines -``` - -Then systematically analyze the codebase and fill each guideline file: - -1. **Analyze the codebase** - Look at existing code patterns -2. **Document conventions** - Write what you observe, not ideals -3. **Include examples** - Reference actual files in the project -4. **List forbidden patterns** - Document anti-patterns the team avoids - -Work through one file at a time: -- `backend/directory-structure.md` -- `backend/database-guidelines.md` -- `backend/error-handling.md` -- `backend/quality-guidelines.md` -- `backend/logging-guidelines.md` -- `frontend/directory-structure.md` -- `frontend/component-guidelines.md` -- `frontend/hook-guidelines.md` -- `frontend/state-management.md` -- `frontend/quality-guidelines.md` -- `frontend/type-safety.md` - ---- - -## Completing the Onboard Session - -After covering all three parts, summarize: - -"You're now onboarded to the Trellis workflow system! Here's what we covered: -- Part 1: Core concepts (why this workflow exists) -- Part 2: Real-world examples (how to apply the workflow) -- Part 3: Guidelines status (empty templates need filling / already customized) - -**Next steps** (tell user): -1. Run `/trellis:record-session` to record this onboard session -2. [If guidelines empty] Start filling in `.trellis/spec/` guidelines -3. [If guidelines ready] Start your first development task - -What would you like to do first?" diff --git a/.claude/commands/trellis/parallel.md b/.claude/commands/trellis/parallel.md deleted file mode 100644 index 3db5c3ef..00000000 --- a/.claude/commands/trellis/parallel.md +++ /dev/null @@ -1,193 +0,0 @@ -# Multi-Agent Pipeline Orchestrator - -You are the Multi-Agent Pipeline Orchestrator Agent, running in the main repository, responsible for collaborating with users to manage parallel development tasks. - -## Role Definition - -- **You are in the main repository**, not in a worktree -- **You don't write code directly** - code work is done by agents in worktrees -- **You are responsible for planning and dispatching**: discuss requirements, create plans, configure context, start worktree agents -- **Delegate complex analysis to research agent**: finding specs, analyzing code structure - ---- - -## Operation Types - -Operations in this document are categorized as: - -| Marker | Meaning | Executor | -|--------|---------|----------| -| `[AI]` | Bash scripts or Task calls executed by AI | You (AI) | -| `[USER]` | Slash commands executed by user | User | - ---- - -## Startup Flow - -### Step 1: Understand Trellis Workflow `[AI]` - -First, read the workflow guide to understand the development process: - -```bash -cat .trellis/workflow.md # Development process, conventions, and quick start guide -``` - -### Step 2: Get Current Status `[AI]` - -```bash -python3 ./.trellis/scripts/get_context.py -``` - -### Step 3: Read Project Guidelines `[AI]` - -```bash -cat .trellis/spec/frontend/index.md # Frontend guidelines index -cat .trellis/spec/backend/index.md # Backend guidelines index -cat .trellis/spec/guides/index.md # Thinking guides -``` - -### Step 4: Ask User for Requirements - -Ask the user: - -1. What feature to develop? -2. Which modules are involved? -3. Development type? (backend / frontend / fullstack) - ---- - -## Planning: Choose Your Approach - -Based on requirement complexity, choose one of these approaches: - -### Option A: Plan Agent (Recommended for complex features) `[AI]` - -Use when: -- Requirements need analysis and validation -- Multiple modules or cross-layer changes -- Unclear scope that needs research - -```bash -python3 ./.trellis/scripts/multi_agent/plan.py \ - --name "<feature-name>" \ - --type "<backend|frontend|fullstack>" \ - --requirement "<user requirement description>" -``` - -Plan Agent will: -1. Evaluate requirement validity (may reject if unclear/too large) -2. Call research agent to analyze codebase -3. Create and configure task directory -4. Write prd.md with acceptance criteria -5. Output ready-to-use task directory - -After plan.py completes, start the worktree agent: - -```bash -python3 ./.trellis/scripts/multi_agent/start.py "$TASK_DIR" -``` - -### Option B: Manual Configuration (For simple/clear features) `[AI]` - -Use when: -- Requirements are already clear and specific -- You know exactly which files are involved -- Simple, well-scoped changes - -#### Step 1: Create Task Directory - -```bash -# title is task description, --slug for task directory name -TASK_DIR=$(python3 ./.trellis/scripts/task.py create "<title>" --slug <task-name>) -``` - -#### Step 2: Configure Task - -```bash -# Initialize jsonl context files -python3 ./.trellis/scripts/task.py init-context "$TASK_DIR" <dev_type> - -# Set branch and scope -python3 ./.trellis/scripts/task.py set-branch "$TASK_DIR" feature/<name> -python3 ./.trellis/scripts/task.py set-scope "$TASK_DIR" <scope> -``` - -#### Step 3: Add Context (optional: use research agent) - -```bash -python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" implement "<path>" "<reason>" -python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" check "<path>" "<reason>" -``` - -#### Step 4: Create prd.md - -```bash -cat > "$TASK_DIR/prd.md" << 'EOF' -# Feature: <name> - -## Requirements -- ... - -## Acceptance Criteria -- ... -EOF -``` - -#### Step 5: Validate and Start - -```bash -python3 ./.trellis/scripts/task.py validate "$TASK_DIR" -python3 ./.trellis/scripts/multi_agent/start.py "$TASK_DIR" -``` - ---- - -## After Starting: Report Status - -Tell the user the agent has started and provide monitoring commands. - ---- - -## User Available Commands `[USER]` - -The following slash commands are for users (not AI): - -| Command | Description | -|---------|-------------| -| `/trellis:parallel` | Start Multi-Agent Pipeline (this command) | -| `/trellis:start` | Start normal development mode (single process) | -| `/trellis:record-session` | Record session progress | -| `/trellis:finish-work` | Pre-completion checklist | - ---- - -## Monitoring Commands (for user reference) - -Tell the user they can use these commands to monitor: - -```bash -python3 ./.trellis/scripts/multi_agent/status.py # Overview -python3 ./.trellis/scripts/multi_agent/status.py --log <name> # View log -python3 ./.trellis/scripts/multi_agent/status.py --watch <name> # Real-time monitoring -python3 ./.trellis/scripts/multi_agent/cleanup.py <branch> # Cleanup worktree -``` - ---- - -## Pipeline Phases - -The dispatch agent in worktree will automatically execute: - -1. implement → Implement feature -2. check → Check code quality -3. finish → Final verification -4. create-pr → Create PR - ---- - -## Core Rules - -- **Don't write code directly** - delegate to agents in worktree -- **Don't execute git commit** - agent does it via create-pr action -- **Delegate complex analysis to research** - finding specs, analyzing code structure -- **All sub agents use opus model** - ensure output quality diff --git a/.claude/commands/trellis/record-session.md b/.claude/commands/trellis/record-session.md deleted file mode 100644 index 4a7e6ff0..00000000 --- a/.claude/commands/trellis/record-session.md +++ /dev/null @@ -1,61 +0,0 @@ -[!] **Prerequisite**: This command should only be used AFTER the human has tested and committed the code. - -**Do NOT run `git commit` directly** — the scripts below handle their own commits for `.trellis/` metadata. You only need to read git history (`git log`, `git status`, `git diff`) and run the Python scripts. - ---- - -## Record Work Progress - -### Step 1: Get Context & Check Tasks - -```bash -python3 ./.trellis/scripts/get_context.py --mode record -``` - -[!] Archive tasks whose work is **actually done** — judge by work status, not the `status` field in task.json: -- Code committed? → Archive it (don't wait for PR) -- All acceptance criteria met? → Archive it -- Don't skip archiving just because `status` still says `planning` or `in_progress` - -```bash -python3 ./.trellis/scripts/task.py archive <task-name> -``` - -### Step 2: One-Click Add Session - -```bash -# Method 1: Simple parameters -python3 ./.trellis/scripts/add_session.py \ - --title "Session Title" \ - --commit "hash1,hash2" \ - --summary "Brief summary of what was done" - -# Method 2: Pass detailed content via stdin -cat << 'EOF' | python3 ./.trellis/scripts/add_session.py --title "Title" --commit "hash" -| Feature | Description | -|---------|-------------| -| New API | Added user authentication endpoint | -| Frontend | Updated login form | - -**Updated Files**: -- `packages/api/modules/auth/router.ts` -- `apps/web/modules/auth/components/login-form.tsx` -EOF -``` - -**Auto-completes**: -- [OK] Appends session to journal-N.md -- [OK] Auto-detects line count, creates new file if >2000 lines -- [OK] Updates index.md (Total Sessions +1, Last Active, line stats, history) -- [OK] Auto-commits .trellis/workspace and .trellis/tasks changes - ---- - -## Script Command Reference - -| Command | Purpose | -|---------|---------| -| `python3 ./.trellis/scripts/get_context.py --mode record` | Get context for record-session | -| `python3 ./.trellis/scripts/add_session.py --title "..." --commit "..."` | **One-click add session (recommended)** | -| `python3 ./.trellis/scripts/task.py archive <name>` | Archive completed task (auto-commits) | -| `python3 ./.trellis/scripts/task.py list` | List active tasks | diff --git a/.claude/commands/trellis/start.md b/.claude/commands/trellis/start.md deleted file mode 100644 index 39fd44f7..00000000 --- a/.claude/commands/trellis/start.md +++ /dev/null @@ -1,389 +0,0 @@ -# Start Session - -Initialize your AI development session and begin working on tasks. - ---- - -## Operation Types - -| Marker | Meaning | Executor | -|--------|---------|----------| -| `[AI]` | Bash scripts or Task calls executed by AI | You (AI) | -| `[USER]` | Slash commands executed by user | User | - ---- - -## Initialization `[AI]` - -### Step 1: Understand Development Workflow - -First, read the workflow guide to understand the development process: - -```bash -cat .trellis/workflow.md -``` - -**Follow the instructions in workflow.md** - it contains: -- Core principles (Read Before Write, Follow Standards, etc.) -- File system structure -- Development process -- Best practices - -### Step 2: Get Current Context - -```bash -python3 ./.trellis/scripts/get_context.py -``` - -This shows: developer identity, git status, current task (if any), active tasks. - -### Step 3: Read Guidelines Index - -```bash -cat .trellis/spec/frontend/index.md # Frontend guidelines -cat .trellis/spec/backend/index.md # Backend guidelines -cat .trellis/spec/guides/index.md # Thinking guides -cat .trellis/spec/unit-test/index.md # Testing guidelines -``` - -> **Important**: The index files are navigation — they list the actual guideline files (e.g., `error-handling.md`, `conventions.md`, `mock-strategies.md`). -> At this step, just read the indexes to understand what's available. -> When you start actual development, you MUST go back and read the specific guideline files relevant to your task, as listed in the index's Pre-Development Checklist. - -### Step 4: Report and Ask - -Report what you learned and ask: "What would you like to work on?" - ---- - -## Task Classification - -When user describes a task, classify it: - -| Type | Criteria | Workflow | -|------|----------|----------| -| **Question** | User asks about code, architecture, or how something works | Answer directly | -| **Trivial Fix** | Typo fix, comment update, single-line change | Direct Edit | -| **Simple Task** | Clear goal, 1-2 files, well-defined scope | Quick confirm → Implement | -| **Complex Task** | Vague goal, multiple files, architectural decisions | **Brainstorm → Task Workflow** | - -### Classification Signals - -**Trivial/Simple indicators:** -- User specifies exact file and change -- "Fix the typo in X" -- "Add field Y to component Z" -- Clear acceptance criteria already stated - -**Complex indicators:** -- "I want to add a feature for..." -- "Can you help me improve..." -- Mentions multiple areas or systems -- No clear implementation path -- User seems unsure about approach - -### Decision Rule - -> **If in doubt, use Brainstorm + Task Workflow.** -> -> Task Workflow ensures code-spec context is injected to agents, resulting in higher quality code. -> The overhead is minimal, but the benefit is significant. - ---- - -## Question / Trivial Fix - -For questions or trivial fixes, work directly: - -1. Answer question or make the fix -2. If code was changed, remind user to run `/trellis:finish-work` - ---- - -## Simple Task - -For simple, well-defined tasks: - -1. Quick confirm: "I understand you want to [goal]. Shall I proceed?" -2. If no, clarify and confirm again -3. **If yes: execute ALL steps below without stopping. Do NOT ask for additional confirmation between steps.** - - Create task directory (Phase 1 Path B, Step 2) - - Write PRD (Step 3) - - Research codebase (Phase 2, Step 5) - - Configure context (Step 6) - - Activate task (Step 7) - - Implement (Phase 3, Step 8) - - Check quality (Step 9) - - Complete (Step 10) - ---- - -## Complex Task - Brainstorm First - -For complex or vague tasks, **automatically start the brainstorm process** — do NOT skip directly to implementation. - -See `/trellis:brainstorm` for the full process. Summary: - -1. **Acknowledge and classify** - State your understanding -2. **Create task directory** - Track evolving requirements in `prd.md` -3. **Ask questions one at a time** - Update PRD after each answer -4. **Propose approaches** - For architectural decisions -5. **Confirm final requirements** - Get explicit approval -6. **Proceed to Task Workflow** - With clear requirements in PRD - -> **Subtask Decomposition**: If brainstorm reveals multiple independent work items, -> consider creating subtasks using `--parent` flag or `add-subtask` command. -> See `/trellis:brainstorm` Step 8 for details. - -### Key Brainstorm Principles - -| Principle | Description | -|-----------|-------------| -| **One question at a time** | Never overwhelm with multiple questions | -| **Update PRD immediately** | After each answer, update the document | -| **Prefer multiple choice** | Easier for users to answer | -| **YAGNI** | Challenge unnecessary complexity | - ---- - -## Task Workflow (Development Tasks) - -**Why this workflow?** -- Research Agent analyzes what code-spec files are needed -- Code-spec files are configured in jsonl files -- Implement Agent receives code-spec context via Hook injection -- Check Agent verifies against code-spec requirements -- Result: Code that follows project conventions automatically - -### Overview: Two Entry Points - -``` -From Brainstorm (Complex Task): - PRD confirmed → Research → Configure Context → Activate → Implement → Check → Complete - -From Simple Task: - Confirm → Create Task → Write PRD → Research → Configure Context → Activate → Implement → Check → Complete -``` - -**Key principle: Research happens AFTER requirements are clear (PRD exists).** - ---- - -### Phase 1: Establish Requirements - -#### Path A: From Brainstorm (skip to Phase 2) - -PRD and task directory already exist from brainstorm. Skip directly to Phase 2. - -#### Path B: From Simple Task - -**Step 1: Confirm Understanding** `[AI]` - -Quick confirm: -- What is the goal? -- What type of development? (frontend / backend / fullstack) -- Any specific requirements or constraints? - -**Step 2: Create Task Directory** `[AI]` - -```bash -TASK_DIR=$(python3 ./.trellis/scripts/task.py create "<title>" --slug <name>) -``` - -**Step 3: Write PRD** `[AI]` - -Create `prd.md` in the task directory with: - -```markdown -# <Task Title> - -## Goal -<What we're trying to achieve> - -## Requirements -- <Requirement 1> -- <Requirement 2> - -## Acceptance Criteria -- [ ] <Criterion 1> -- [ ] <Criterion 2> - -## Technical Notes -<Any technical decisions or constraints> -``` - ---- - -### Phase 2: Prepare for Implementation (shared) - -> Both paths converge here. PRD and task directory must exist before proceeding. - -**Step 4: Code-Spec Depth Check** `[AI]` - -If the task touches infra or cross-layer contracts, do not start implementation until code-spec depth is defined. - -Trigger this requirement when the change includes any of: -- New or changed command/API signatures -- Database schema or migration changes -- Infra integrations (storage, queue, cache, secrets, env contracts) -- Cross-layer payload transformations - -Must-have before proceeding: -- [ ] Target code-spec files to update are identified -- [ ] Concrete contract is defined (signature, fields, env keys) -- [ ] Validation and error matrix is defined -- [ ] At least one Good/Base/Bad case is defined - -**Step 5: Research the Codebase** `[AI]` - -Based on the confirmed PRD, call Research Agent to find relevant specs and patterns: - -``` -Task( - subagent_type: "research", - prompt: "Analyze the codebase for this task: - - Task: <goal from PRD> - Type: <frontend/backend/fullstack> - - Please find: - 1. Relevant code-spec files in .trellis/spec/ - 2. Existing code patterns to follow (find 2-3 examples) - 3. Files that will likely need modification - - Output: - ## Relevant Code-Specs - - <path>: <why it's relevant> - - ## Code Patterns Found - - <pattern>: <example file path> - - ## Files to Modify - - <path>: <what change>", - model: "opus" -) -``` - -**Step 6: Configure Context** `[AI]` - -Initialize default context: - -```bash -python3 ./.trellis/scripts/task.py init-context "$TASK_DIR" <type> -# type: backend | frontend | fullstack -``` - -Add code-spec files found by Research Agent: - -```bash -# For each relevant code-spec and code pattern: -python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" implement "<path>" "<reason>" -python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" check "<path>" "<reason>" -``` - -**Step 7: Activate Task** `[AI]` - -```bash -python3 ./.trellis/scripts/task.py start "$TASK_DIR" -``` - -This sets `.current-task` so hooks can inject context. - ---- - -### Phase 3: Execute (shared) - -**Step 8: Implement** `[AI]` - -Call Implement Agent (code-spec context is auto-injected by hook): - -``` -Task( - subagent_type: "implement", - prompt: "Implement the task described in prd.md. - - Follow all code-spec files that have been injected into your context. - Run lint and typecheck before finishing.", - model: "opus" -) -``` - -**Step 9: Check Quality** `[AI]` - -Call Check Agent (code-spec context is auto-injected by hook): - -``` -Task( - subagent_type: "check", - prompt: "Review all code changes against the code-spec requirements. - - Fix any issues you find directly. - Ensure lint and typecheck pass.", - model: "opus" -) -``` - -**Step 10: Complete** `[AI]` - -1. Verify lint and typecheck pass -2. Report what was implemented -3. Remind user to: - - Test the changes - - Commit when ready - - Run `/trellis:record-session` to record this session - ---- - -## Continuing Existing Task - -If `get_context.py` shows a current task: - -1. Read the task's `prd.md` to understand the goal -2. Check `task.json` for current status and phase -3. Ask user: "Continue working on <task-name>?" - -If yes, resume from the appropriate step (usually Step 7 or 8). - ---- - -## Commands Reference - -### User Commands `[USER]` - -| Command | When to Use | -|---------|-------------| -| `/trellis:start` | Begin a session (this command) | -| `/trellis:brainstorm` | Clarify vague requirements (called from start) | -| `/trellis:parallel` | Complex tasks needing isolated worktree | -| `/trellis:finish-work` | Before committing changes | -| `/trellis:record-session` | After completing a task | - -### AI Scripts `[AI]` - -| Script | Purpose | -|--------|---------| -| `python3 ./.trellis/scripts/get_context.py` | Get session context | -| `python3 ./.trellis/scripts/task.py create` | Create task directory | -| `python3 ./.trellis/scripts/task.py init-context` | Initialize jsonl files | -| `python3 ./.trellis/scripts/task.py add-context` | Add code-spec/context file to jsonl | -| `python3 ./.trellis/scripts/task.py start` | Set current task | -| `python3 ./.trellis/scripts/task.py finish` | Clear current task | -| `python3 ./.trellis/scripts/task.py archive` | Archive completed task | - -### Sub Agents `[AI]` - -| Agent | Purpose | Hook Injection | -|-------|---------|----------------| -| research | Analyze codebase | No (reads directly) | -| implement | Write code | Yes (implement.jsonl) | -| check | Review & fix | Yes (check.jsonl) | -| debug | Fix specific issues | Yes (debug.jsonl) | - ---- - -## Key Principle - -> **Code-spec context is injected, not remembered.** -> -> The Task Workflow ensures agents receive relevant code-spec context automatically. -> This is more reliable than hoping the AI "remembers" conventions. diff --git a/.claude/commands/trellis/update-spec.md b/.claude/commands/trellis/update-spec.md deleted file mode 100644 index 3f0b2e77..00000000 --- a/.claude/commands/trellis/update-spec.md +++ /dev/null @@ -1,354 +0,0 @@ -# Update Code-Spec - Capture Executable Contracts - -When you learn something valuable (from debugging, implementing, or discussion), use this command to update the relevant code-spec documents. - -**Timing**: After completing a task, fixing a bug, or discovering a new pattern - ---- - -## Code-Spec First Rule (CRITICAL) - -In this project, "spec" for implementation work means **code-spec**: -- Executable contracts (not principle-only text) -- Concrete signatures, payload fields, env keys, and boundary behavior -- Testable validation/error behavior - -If the change touches infra or cross-layer contracts, code-spec depth is mandatory. - -### Mandatory Triggers - -Apply code-spec depth when the change includes any of: -- New/changed command or API signature -- Cross-layer request/response contract change -- Database schema/migration change -- Infra integration (storage, queue, cache, secrets, env wiring) - -### Mandatory Output (7 Sections) - -For triggered tasks, include all sections below: -1. Scope / Trigger -2. Signatures (command/API/DB) -3. Contracts (request/response/env) -4. Validation & Error Matrix -5. Good/Base/Bad Cases -6. Tests Required (with assertion points) -7. Wrong vs Correct (at least one pair) - ---- - -## When to Update Code-Specs - -| Trigger | Example | Target Spec | -|---------|---------|-------------| -| **Implemented a feature** | Added template download with giget | Relevant `backend/` or `frontend/` file | -| **Made a design decision** | Used type field + mapping table for extensibility | Relevant code-spec + "Design Decisions" section | -| **Fixed a bug** | Found a subtle issue with error handling | `backend/error-handling.md` | -| **Discovered a pattern** | Found a better way to structure code | Relevant `backend/` or `frontend/` file | -| **Hit a gotcha** | Learned that X must be done before Y | Relevant code-spec + "Common Mistakes" section | -| **Established a convention** | Team agreed on naming pattern | `quality-guidelines.md` | -| **New thinking trigger** | "Don't forget to check X before doing Y" | `guides/*.md` (as a checklist item, not detailed rules) | - -**Key Insight**: Code-spec updates are NOT just for problems. Every feature implementation contains design decisions and contracts that future AI/developers need to execute safely. - ---- - -## Spec Structure Overview - -``` -.trellis/spec/ -├── backend/ # Backend coding standards -│ ├── index.md # Overview and links -│ └── *.md # Topic-specific guidelines -├── frontend/ # Frontend coding standards -│ ├── index.md # Overview and links -│ └── *.md # Topic-specific guidelines -└── guides/ # Thinking checklists (NOT coding specs!) - ├── index.md # Guide index - └── *.md # Topic-specific guides -``` - -### CRITICAL: Code-Spec vs Guide - Know the Difference - -| Type | Location | Purpose | Content Style | -|------|----------|---------|---------------| -| **Code-Spec** | `backend/*.md`, `frontend/*.md` | Tell AI "how to implement safely" | Signatures, contracts, matrices, cases, test points | -| **Guide** | `guides/*.md` | Help AI "what to think about" | Checklists, questions, pointers to specs | - -**Decision Rule**: Ask yourself: - -- "This is **how to write** the code" → Put in `backend/` or `frontend/` -- "This is **what to consider** before writing" → Put in `guides/` - -**Example**: - -| Learning | Wrong Location | Correct Location | -|----------|----------------|------------------| -| "Use `reconfigure()` not `TextIOWrapper` for Windows stdout" | ❌ `guides/cross-platform-thinking-guide.md` | ✅ `backend/script-conventions.md` | -| "Remember to check encoding when writing cross-platform code" | ❌ `backend/script-conventions.md` | ✅ `guides/cross-platform-thinking-guide.md` | - -**Guides should be short checklists that point to specs**, not duplicate the detailed rules. - ---- - -## Update Process - -### Step 1: Identify What You Learned - -Answer these questions: - -1. **What did you learn?** (Be specific) -2. **Why is it important?** (What problem does it prevent?) -3. **Where does it belong?** (Which spec file?) - -### Step 2: Classify the Update Type - -| Type | Description | Action | -|------|-------------|--------| -| **Design Decision** | Why we chose approach X over Y | Add to "Design Decisions" section | -| **Project Convention** | How we do X in this project | Add to relevant section with examples | -| **New Pattern** | A reusable approach discovered | Add to "Patterns" section | -| **Forbidden Pattern** | Something that causes problems | Add to "Anti-patterns" or "Don't" section | -| **Common Mistake** | Easy-to-make error | Add to "Common Mistakes" section | -| **Convention** | Agreed-upon standard | Add to relevant section | -| **Gotcha** | Non-obvious behavior | Add warning callout | - -### Step 3: Read the Target Code-Spec - -Before editing, read the current code-spec to: -- Understand existing structure -- Avoid duplicating content -- Find the right section for your update - -```bash -cat .trellis/spec/<category>/<file>.md -``` - -### Step 4: Make the Update - -Follow these principles: - -1. **Be Specific**: Include concrete examples, not just abstract rules -2. **Explain Why**: State the problem this prevents -3. **Show Contracts**: Add signatures, payload fields, and error behavior -4. **Show Code**: Add code snippets for key patterns -5. **Keep it Short**: One concept per section - -### Step 5: Update the Index (if needed) - -If you added a new section or the code-spec status changed, update the category's `index.md`. - ---- - -## Update Templates - -### Mandatory Template for Infra/Cross-Layer Work - -```markdown -## Scenario: <name> - -### 1. Scope / Trigger -- Trigger: <why this requires code-spec depth> - -### 2. Signatures -- Backend command/API/DB signature(s) - -### 3. Contracts -- Request fields (name, type, constraints) -- Response fields (name, type, constraints) -- Environment keys (required/optional) - -### 4. Validation & Error Matrix -- <condition> -> <error> - -### 5. Good/Base/Bad Cases -- Good: ... -- Base: ... -- Bad: ... - -### 6. Tests Required -- Unit/Integration/E2E with assertion points - -### 7. Wrong vs Correct -#### Wrong -... -#### Correct -... -``` - -### Adding a Design Decision - -```markdown -### Design Decision: [Decision Name] - -**Context**: What problem were we solving? - -**Options Considered**: -1. Option A - brief description -2. Option B - brief description - -**Decision**: We chose Option X because... - -**Example**: -\`\`\`typescript -// How it's implemented -code example -\`\`\` - -**Extensibility**: How to extend this in the future... -``` - -### Adding a Project Convention - -```markdown -### Convention: [Convention Name] - -**What**: Brief description of the convention. - -**Why**: Why we do it this way in this project. - -**Example**: -\`\`\`typescript -// How to follow this convention -code example -\`\`\` - -**Related**: Links to related conventions or specs. -``` - -### Adding a New Pattern - -```markdown -### Pattern Name - -**Problem**: What problem does this solve? - -**Solution**: Brief description of the approach. - -**Example**: -\`\`\` -// Good -code example - -// Bad -code example -\`\`\` - -**Why**: Explanation of why this works better. -``` - -### Adding a Forbidden Pattern - -```markdown -### Don't: Pattern Name - -**Problem**: -\`\`\` -// Don't do this -bad code example -\`\`\` - -**Why it's bad**: Explanation of the issue. - -**Instead**: -\`\`\` -// Do this instead -good code example -\`\`\` -``` - -### Adding a Common Mistake - -```markdown -### Common Mistake: Description - -**Symptom**: What goes wrong - -**Cause**: Why this happens - -**Fix**: How to correct it - -**Prevention**: How to avoid it in the future -``` - -### Adding a Gotcha - -```markdown -> **Warning**: Brief description of the non-obvious behavior. -> -> Details about when this happens and how to handle it. -``` - ---- - -## Interactive Mode - -If you're unsure what to update, answer these prompts: - -1. **What did you just finish?** - - [ ] Fixed a bug - - [ ] Implemented a feature - - [ ] Refactored code - - [ ] Had a discussion about approach - -2. **What did you learn or decide?** - - Design decision (why X over Y) - - Project convention (how we do X) - - Non-obvious behavior (gotcha) - - Better approach (pattern) - -3. **Would future AI/developers need to know this?** - - To understand how the code works → Yes, update spec - - To maintain or extend the feature → Yes, update spec - - To avoid repeating mistakes → Yes, update spec - - Purely one-off implementation detail → Maybe skip - -4. **Which area does it relate to?** - - [ ] Backend code - - [ ] Frontend code - - [ ] Cross-layer data flow - - [ ] Code organization/reuse - - [ ] Quality/testing - ---- - -## Quality Checklist - -Before finishing your code-spec update: - -- [ ] Is the content specific and actionable? -- [ ] Did you include a code example? -- [ ] Did you explain WHY, not just WHAT? -- [ ] Did you include executable signatures/contracts? -- [ ] Did you include validation and error matrix? -- [ ] Did you include Good/Base/Bad cases? -- [ ] Did you include required tests with assertion points? -- [ ] Is it in the right code-spec file? -- [ ] Does it duplicate existing content? -- [ ] Would a new team member understand it? - ---- - -## Relationship to Other Commands - -``` -Development Flow: - Learn something → /trellis:update-spec → Knowledge captured - ↑ ↓ - /trellis:break-loop ←──────────────────── Future sessions benefit - (deep bug analysis) -``` - -- `/trellis:break-loop` - Analyzes bugs deeply, often reveals spec updates needed -- `/trellis:update-spec` - Actually makes the updates (this command) -- `/trellis:finish-work` - Reminds you to check if specs need updates - ---- - -## Core Philosophy - -> **Code-specs are living documents. Every debugging session, every "aha moment" is an opportunity to make the implementation contract clearer.** - -The goal is **institutional memory**: -- What one person learns, everyone benefits from -- What AI learns in one session, persists to future sessions -- Mistakes become documented guardrails diff --git a/.claude/hooks/inject-subagent-context.py b/.claude/hooks/inject-subagent-context.py deleted file mode 100644 index 95e7f5ce..00000000 --- a/.claude/hooks/inject-subagent-context.py +++ /dev/null @@ -1,788 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Multi-Agent Pipeline Context Injection Hook - -Core Design Philosophy: -- Dispatch becomes a pure dispatcher, only responsible for "calling subagents" -- Hook is responsible for injecting all context, subagent works autonomously with complete info -- Each agent has a dedicated jsonl file defining its context -- No resume needed, no segmentation, behavior controlled by code not prompt - -Trigger: PreToolUse (before Task tool call) - -Context Source: .trellis/.current-task points to task directory -- implement.jsonl - Implement agent dedicated context -- check.jsonl - Check agent dedicated context -- debug.jsonl - Debug agent dedicated context -- research.jsonl - Research agent dedicated context (optional, usually not needed) -- cr.jsonl - Code review dedicated context -- prd.md - Requirements document -- info.md - Technical design -- codex-review-output.txt - Code Review results -""" - -# IMPORTANT: Suppress all warnings FIRST -import warnings -warnings.filterwarnings("ignore") - -import json -import os -import sys -from pathlib import Path - -# IMPORTANT: Force stdout to use UTF-8 on Windows -# This fixes UnicodeEncodeError when outputting non-ASCII characters -if sys.platform == "win32": - import io as _io - if hasattr(sys.stdout, "reconfigure"): - sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] - elif hasattr(sys.stdout, "detach"): - sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr] - -# ============================================================================= -# Path Constants (change here to rename directories) -# ============================================================================= - -DIR_WORKFLOW = ".trellis" -DIR_WORKSPACE = "workspace" -DIR_TASKS = "tasks" -DIR_SPEC = "spec" -FILE_CURRENT_TASK = ".current-task" -FILE_TASK_JSON = "task.json" - -# Agents that don't update phase (can be called at any time) -AGENTS_NO_PHASE_UPDATE = {"debug", "research"} - -# ============================================================================= -# Subagent Constants (change here to rename subagent types) -# ============================================================================= - -AGENT_IMPLEMENT = "implement" -AGENT_CHECK = "check" -AGENT_DEBUG = "debug" -AGENT_RESEARCH = "research" - -# Agents that require a task directory -AGENTS_REQUIRE_TASK = (AGENT_IMPLEMENT, AGENT_CHECK, AGENT_DEBUG) -# All supported agents -AGENTS_ALL = (AGENT_IMPLEMENT, AGENT_CHECK, AGENT_DEBUG, AGENT_RESEARCH) - - -def find_repo_root(start_path: str) -> str | None: - """ - Find git repo root from start_path upwards - - Returns: - Repo root path, or None if not found - """ - current = Path(start_path).resolve() - while current != current.parent: - if (current / ".git").exists(): - return str(current) - current = current.parent - return None - - -def get_current_task(repo_root: str) -> str | None: - """ - Read current task directory path from .trellis/.current-task - - Returns: - Task directory relative path (relative to repo_root) - None if not set - """ - current_task_file = os.path.join(repo_root, DIR_WORKFLOW, FILE_CURRENT_TASK) - if not os.path.exists(current_task_file): - return None - - try: - with open(current_task_file, "r", encoding="utf-8") as f: - content = f.read().strip() - return content if content else None - except Exception: - return None - - -def update_current_phase(repo_root: str, task_dir: str, subagent_type: str) -> None: - """ - Update current_phase in task.json based on subagent_type. - - This ensures phase tracking is always accurate, regardless of whether - dispatch agent remembers to update it. - - Logic: - - Read next_action array from task.json - - Find the next phase whose action matches subagent_type - - Only move forward, never backward - - Some agents (debug, research) don't update phase - """ - if subagent_type in AGENTS_NO_PHASE_UPDATE: - return - - task_json_path = os.path.join(repo_root, task_dir, FILE_TASK_JSON) - if not os.path.exists(task_json_path): - return - - try: - with open(task_json_path, "r", encoding="utf-8") as f: - task_data = json.load(f) - - current_phase = task_data.get("current_phase", 0) - next_actions = task_data.get("next_action", []) - - # Map action names to subagent types - # "implement" -> "implement", "check" -> "check", "finish" -> "check" - action_to_agent = { - "implement": "implement", - "check": "check", - "finish": "check", # finish uses check agent - } - - # Find the next phase that matches this subagent_type - new_phase = None - for action in next_actions: - phase_num = action.get("phase", 0) - action_name = action.get("action", "") - expected_agent = action_to_agent.get(action_name) - - # Only consider phases after current_phase - if phase_num > current_phase and expected_agent == subagent_type: - new_phase = phase_num - break - - if new_phase is not None: - task_data["current_phase"] = new_phase - - with open(task_json_path, "w", encoding="utf-8") as f: - json.dump(task_data, f, indent=2, ensure_ascii=False) - except Exception: - # Don't fail the hook if phase update fails - pass - - -def read_file_content(base_path: str, file_path: str) -> str | None: - """Read file content, return None if file doesn't exist""" - full_path = os.path.join(base_path, file_path) - if os.path.exists(full_path) and os.path.isfile(full_path): - try: - with open(full_path, "r", encoding="utf-8") as f: - return f.read() - except Exception: - return None - return None - - -def read_directory_contents( - base_path: str, dir_path: str, max_files: int = 20 -) -> list[tuple[str, str]]: - """ - Read all .md files in a directory - - Args: - base_path: Base path (usually repo_root) - dir_path: Directory relative path - max_files: Max files to read (prevent huge directories) - - Returns: - [(file_path, content), ...] - """ - full_path = os.path.join(base_path, dir_path) - if not os.path.exists(full_path) or not os.path.isdir(full_path): - return [] - - results = [] - try: - # Only read .md files, sorted by filename - md_files = sorted( - [ - f - for f in os.listdir(full_path) - if f.endswith(".md") and os.path.isfile(os.path.join(full_path, f)) - ] - ) - - for filename in md_files[:max_files]: - file_full_path = os.path.join(full_path, filename) - relative_path = os.path.join(dir_path, filename) - try: - with open(file_full_path, "r", encoding="utf-8") as f: - content = f.read() - results.append((relative_path, content)) - except Exception: - continue - except Exception: - pass - - return results - - -def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[tuple[str, str]]: - """ - Read all file/directory contents referenced in jsonl file - - Schema: - {"file": "path/to/file.md", "reason": "..."} - {"file": "path/to/dir/", "type": "directory", "reason": "..."} - - Returns: - [(path, content), ...] - """ - full_path = os.path.join(base_path, jsonl_path) - if not os.path.exists(full_path): - return [] - - results = [] - try: - with open(full_path, "r", encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line: - continue - try: - item = json.loads(line) - file_path = item.get("file") or item.get("path") - entry_type = item.get("type", "file") - - if not file_path: - continue - - if entry_type == "directory": - # Read all .md files in directory - dir_contents = read_directory_contents(base_path, file_path) - results.extend(dir_contents) - else: - # Read single file - content = read_file_content(base_path, file_path) - if content: - results.append((file_path, content)) - except json.JSONDecodeError: - continue - except Exception: - pass - - return results - - -def get_agent_context(repo_root: str, task_dir: str, agent_type: str) -> str: - """ - Get complete context for specified agent - - Prioritize agent-specific jsonl, fallback to spec.jsonl if not exists - """ - context_parts = [] - - # 1. Try agent-specific jsonl - agent_jsonl = f"{task_dir}/{agent_type}.jsonl" - agent_entries = read_jsonl_entries(repo_root, agent_jsonl) - - # 2. If agent-specific jsonl doesn't exist or empty, fallback to spec.jsonl - if not agent_entries: - agent_entries = read_jsonl_entries(repo_root, f"{task_dir}/spec.jsonl") - - # 3. Add all files from jsonl - for file_path, content in agent_entries: - context_parts.append(f"=== {file_path} ===\n{content}") - - return "\n\n".join(context_parts) - - -def get_implement_context(repo_root: str, task_dir: str) -> str: - """ - Complete context for Implement Agent - - Read order: - 1. All files in implement.jsonl (dev specs) - 2. prd.md (requirements) - 3. info.md (technical design) - """ - context_parts = [] - - # 1. Read implement.jsonl (or fallback to spec.jsonl) - base_context = get_agent_context(repo_root, task_dir, "implement") - if base_context: - context_parts.append(base_context) - - # 2. Requirements document - prd_content = read_file_content(repo_root, f"{task_dir}/prd.md") - if prd_content: - context_parts.append(f"=== {task_dir}/prd.md (Requirements) ===\n{prd_content}") - - # 3. Technical design - info_content = read_file_content(repo_root, f"{task_dir}/info.md") - if info_content: - context_parts.append( - f"=== {task_dir}/info.md (Technical Design) ===\n{info_content}" - ) - - return "\n\n".join(context_parts) - - -def get_check_context(repo_root: str, task_dir: str) -> str: - """ - Complete context for Check Agent - - Read order: - 1. All files in check.jsonl (check specs + dev specs) - 2. prd.md (for understanding task intent) - """ - context_parts = [] - - # 1. Read check.jsonl (or fallback to spec.jsonl + hardcoded check files) - check_entries = read_jsonl_entries(repo_root, f"{task_dir}/check.jsonl") - - if check_entries: - for file_path, content in check_entries: - context_parts.append(f"=== {file_path} ===\n{content}") - else: - # Fallback: use hardcoded check files + spec.jsonl - check_files = [ - (".claude/commands/trellis/finish-work.md", "Finish work checklist"), - (".claude/commands/trellis/check-cross-layer.md", "Cross-layer check spec"), - (".claude/commands/trellis/check-backend.md", "Backend check spec"), - (".claude/commands/trellis/check-frontend.md", "Frontend check spec"), - ] - for file_path, description in check_files: - content = read_file_content(repo_root, file_path) - if content: - context_parts.append(f"=== {file_path} ({description}) ===\n{content}") - - # Add spec.jsonl - spec_entries = read_jsonl_entries(repo_root, f"{task_dir}/spec.jsonl") - for file_path, content in spec_entries: - context_parts.append(f"=== {file_path} (Dev spec) ===\n{content}") - - # 2. Requirements document (for understanding task intent) - prd_content = read_file_content(repo_root, f"{task_dir}/prd.md") - if prd_content: - context_parts.append( - f"=== {task_dir}/prd.md (Requirements - for understanding intent) ===\n{prd_content}" - ) - - return "\n\n".join(context_parts) - - -def get_finish_context(repo_root: str, task_dir: str) -> str: - """ - Complete context for Finish phase (final check before PR) - - Read order: - 1. All files in finish.jsonl (if exists) - 2. Fallback to finish-work.md only (lightweight final check) - 3. update-spec.md (for active spec sync) - 4. prd.md (for verifying requirements are met) - """ - context_parts = [] - - # 1. Try finish.jsonl first - finish_entries = read_jsonl_entries(repo_root, f"{task_dir}/finish.jsonl") - - if finish_entries: - for file_path, content in finish_entries: - context_parts.append(f"=== {file_path} ===\n{content}") - else: - # Fallback: only finish-work.md (lightweight) - finish_work = read_file_content( - repo_root, ".claude/commands/trellis/finish-work.md" - ) - if finish_work: - context_parts.append( - f"=== .claude/commands/trellis/finish-work.md (Finish checklist) ===\n{finish_work}" - ) - - # 2. Spec update process (for active spec sync) - update_spec = read_file_content( - repo_root, ".claude/commands/trellis/update-spec.md" - ) - if update_spec: - context_parts.append( - f"=== .claude/commands/trellis/update-spec.md (Spec update process) ===\n{update_spec}" - ) - - # 3. Requirements document (for verifying requirements are met) - prd_content = read_file_content(repo_root, f"{task_dir}/prd.md") - if prd_content: - context_parts.append( - f"=== {task_dir}/prd.md (Requirements - verify all met) ===\n{prd_content}" - ) - - return "\n\n".join(context_parts) - - -def get_debug_context(repo_root: str, task_dir: str) -> str: - """ - Complete context for Debug Agent - - Read order: - 1. All files in debug.jsonl (specs needed for fixing) - 2. codex-review-output.txt (Codex Review results) - """ - context_parts = [] - - # 1. Read debug.jsonl (or fallback to spec.jsonl + hardcoded check files) - debug_entries = read_jsonl_entries(repo_root, f"{task_dir}/debug.jsonl") - - if debug_entries: - for file_path, content in debug_entries: - context_parts.append(f"=== {file_path} ===\n{content}") - else: - # Fallback: use spec.jsonl + hardcoded check files - spec_entries = read_jsonl_entries(repo_root, f"{task_dir}/spec.jsonl") - for file_path, content in spec_entries: - context_parts.append(f"=== {file_path} (Dev spec) ===\n{content}") - - check_files = [ - (".claude/commands/trellis/check-backend.md", "Backend check spec"), - (".claude/commands/trellis/check-frontend.md", "Frontend check spec"), - (".claude/commands/trellis/check-cross-layer.md", "Cross-layer check spec"), - ] - for file_path, description in check_files: - content = read_file_content(repo_root, file_path) - if content: - context_parts.append(f"=== {file_path} ({description}) ===\n{content}") - - # 2. Codex review output (if exists) - codex_output = read_file_content(repo_root, f"{task_dir}/codex-review-output.txt") - if codex_output: - context_parts.append( - f"=== {task_dir}/codex-review-output.txt (Codex Review Results) ===\n{codex_output}" - ) - - return "\n\n".join(context_parts) - - -def build_implement_prompt(original_prompt: str, context: str) -> str: - """Build complete prompt for Implement""" - return f"""# Implement Agent Task - -You are the Implement Agent in the Multi-Agent Pipeline. - -## Your Context - -All the information you need has been prepared for you: - -{context} - ---- - -## Your Task - -{original_prompt} - ---- - -## Workflow - -1. **Understand specs** - All dev specs are injected above, understand them -2. **Understand requirements** - Read requirements document and technical design -3. **Implement feature** - Implement following specs and design -4. **Self-check** - Ensure code quality against check specs - -## Important Constraints - -- Do NOT execute git commit, only code modifications -- Follow all dev specs injected above -- Report list of modified/created files when done""" - - -def build_check_prompt(original_prompt: str, context: str) -> str: - """Build complete prompt for Check""" - return f"""# Check Agent Task - -You are the Check Agent in the Multi-Agent Pipeline (code and cross-layer checker). - -## Your Context - -All check specs and dev specs you need: - -{context} - ---- - -## Your Task - -{original_prompt} - ---- - -## Workflow - -1. **Get changes** - Run `git diff --name-only` and `git diff` to get code changes -2. **Check against specs** - Check item by item against specs above -3. **Self-fix** - Fix issues directly, don't just report -4. **Run verification** - Run project's lint and typecheck commands - -## Important Constraints - -- Fix issues yourself, don't just report -- Must execute complete checklist in check specs -- Pay special attention to impact radius analysis (L1-L5)""" - - -def build_finish_prompt(original_prompt: str, context: str) -> str: - """Build complete prompt for Finish (final check before PR)""" - return f"""# Finish Agent Task - -You are performing the final check before creating a PR. - -## Your Context - -Finish checklist and requirements: - -{context} - ---- - -## Your Task - -{original_prompt} - ---- - -## Workflow - -1. **Review changes** - Run `git diff --name-only` to see all changed files -2. **Verify requirements** - Check each requirement in prd.md is implemented -3. **Spec sync** - Analyze whether changes introduce new patterns, contracts, or conventions - - If new pattern/convention found: read target spec file → update it → update index.md if needed - - If infra/cross-layer change: follow the 7-section mandatory template from update-spec.md - - If pure code fix with no new patterns: skip this step -4. **Run final checks** - Execute lint and typecheck -5. **Confirm ready** - Ensure code is ready for PR - -## Important Constraints - -- You MAY update spec files when gaps are detected (use update-spec.md as guide) -- MUST read the target spec file BEFORE editing (avoid duplicating existing content) -- Do NOT update specs for trivial changes (typos, formatting, obvious fixes) -- If critical CODE issues found, report them clearly (fix specs, not code) -- Verify all acceptance criteria in prd.md are met""" - - -def build_debug_prompt(original_prompt: str, context: str) -> str: - """Build complete prompt for Debug""" - return f"""# Debug Agent Task - -You are the Debug Agent in the Multi-Agent Pipeline (issue fixer). - -## Your Context - -Dev specs and Codex Review results: - -{context} - ---- - -## Your Task - -{original_prompt} - ---- - -## Workflow - -1. **Understand issues** - Analyze issues pointed out in Codex Review -2. **Locate code** - Find positions that need fixing -3. **Fix against specs** - Fix issues following dev specs -4. **Verify fixes** - Run typecheck to ensure no new issues - -## Important Constraints - -- Do NOT execute git commit, only code modifications -- Run typecheck after each fix to verify -- Report which issues were fixed and which files were modified""" - - -def get_research_context(repo_root: str, task_dir: str | None) -> str: - """ - Context for Research Agent - - Research doesn't need much preset context, only needs: - 1. Project structure overview (where spec directories are) - 2. Optional research.jsonl (if there are specific search needs) - """ - context_parts = [] - - # 1. Project structure overview (uses constants for paths) - spec_path = f"{DIR_WORKFLOW}/{DIR_SPEC}" - project_structure = f"""## Project Spec Directory Structure - -``` -{spec_path}/ -├── shared/ # Cross-project common specs (TypeScript, code quality, git) -├── frontend/ # Frontend standards -├── backend/ # Backend standards -└── guides/ # Thinking guides (cross-layer, code reuse, etc.) - -{DIR_WORKFLOW}/big-question/ # Known issues and pitfalls -``` - -## Search Tips - -- Spec files: `{spec_path}/**/*.md` -- Known issues: `{DIR_WORKFLOW}/big-question/` -- Code search: Use Glob and Grep tools -- Tech solutions: Use mcp__exa__web_search_exa or mcp__exa__get_code_context_exa""" - - context_parts.append(project_structure) - - # 2. If task directory exists, try reading research.jsonl (optional) - if task_dir: - research_entries = read_jsonl_entries(repo_root, f"{task_dir}/research.jsonl") - if research_entries: - context_parts.append( - "\n## Additional Search Context (from research.jsonl)\n" - ) - for file_path, content in research_entries: - context_parts.append(f"=== {file_path} ===\n{content}") - - return "\n\n".join(context_parts) - - -def build_research_prompt(original_prompt: str, context: str) -> str: - """Build complete prompt for Research""" - return f"""# Research Agent Task - -You are the Research Agent in the Multi-Agent Pipeline (search researcher). - -## Core Principle - -**You do one thing: find and explain information.** - -You are a documenter, not a reviewer. - -## Project Info - -{context} - ---- - -## Your Task - -{original_prompt} - ---- - -## Workflow - -1. **Understand query** - Determine search type (internal/external) and scope -2. **Plan search** - List search steps for complex queries -3. **Execute search** - Execute multiple independent searches in parallel -4. **Organize results** - Output structured report - -## Search Tools - -| Tool | Purpose | -|------|---------| -| Glob | Search by filename pattern | -| Grep | Search by content | -| Read | Read file content | -| mcp__exa__web_search_exa | External web search | -| mcp__exa__get_code_context_exa | External code/doc search | - -## Strict Boundaries - -**Only allowed**: Describe what exists, where it is, how it works - -**Forbidden** (unless explicitly asked): -- Suggest improvements -- Criticize implementation -- Recommend refactoring -- Modify any files - -## Report Format - -Provide structured search results including: -- List of files found (with paths) -- Code pattern analysis (if applicable) -- Related spec documents -- External references (if any)""" - - -def main(): - try: - input_data = json.load(sys.stdin) - except json.JSONDecodeError: - sys.exit(0) - - tool_name = input_data.get("tool_name", "") - - if tool_name not in ("Task", "Agent"): - sys.exit(0) - - tool_input = input_data.get("tool_input", {}) - subagent_type = tool_input.get("subagent_type", "") - original_prompt = tool_input.get("prompt", "") - cwd = input_data.get("cwd", os.getcwd()) - - # Only handle subagent types we care about - if subagent_type not in AGENTS_ALL: - sys.exit(0) - - # Find repo root - repo_root = find_repo_root(cwd) - if not repo_root: - sys.exit(0) - - # Get current task directory (research doesn't require it) - task_dir = get_current_task(repo_root) - - # implement/check/debug need task directory - if subagent_type in AGENTS_REQUIRE_TASK: - if not task_dir: - sys.exit(0) - # Check if task directory exists - task_dir_full = os.path.join(repo_root, task_dir) - if not os.path.exists(task_dir_full): - sys.exit(0) - - # Update current_phase in task.json (system-level enforcement) - update_current_phase(repo_root, task_dir, subagent_type) - - # Check for [finish] marker in prompt (check agent with finish context) - is_finish_phase = "[finish]" in original_prompt.lower() - - # Get context and build prompt based on subagent type - if subagent_type == AGENT_IMPLEMENT: - assert task_dir is not None # validated above - context = get_implement_context(repo_root, task_dir) - new_prompt = build_implement_prompt(original_prompt, context) - elif subagent_type == AGENT_CHECK: - assert task_dir is not None # validated above - if is_finish_phase: - # Finish phase: use finish context (lighter, focused on final verification) - context = get_finish_context(repo_root, task_dir) - new_prompt = build_finish_prompt(original_prompt, context) - else: - # Regular check phase: use check context (full specs for self-fix loop) - context = get_check_context(repo_root, task_dir) - new_prompt = build_check_prompt(original_prompt, context) - elif subagent_type == AGENT_DEBUG: - assert task_dir is not None # validated above - context = get_debug_context(repo_root, task_dir) - new_prompt = build_debug_prompt(original_prompt, context) - elif subagent_type == AGENT_RESEARCH: - # Research can work without task directory - context = get_research_context(repo_root, task_dir) - new_prompt = build_research_prompt(original_prompt, context) - else: - sys.exit(0) - - if not context: - sys.exit(0) - - # Return updated input with correct Claude Code PreToolUse format - output = { - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "allow", - "updatedInput": {**tool_input, "prompt": new_prompt}, - } - } - - print(json.dumps(output, ensure_ascii=False)) - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/.claude/hooks/ralph-loop.py b/.claude/hooks/ralph-loop.py deleted file mode 100644 index 983660fc..00000000 --- a/.claude/hooks/ralph-loop.py +++ /dev/null @@ -1,388 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Ralph Loop - SubagentStop Hook for Check Agent Loop Control - -Based on the Ralph Wiggum technique for autonomous agent loops. -Uses completion promises to control when the check agent can stop. - -Mechanism: -- Intercepts when check subagent tries to stop (SubagentStop event) -- If verify commands configured in worktree.yaml, runs them to verify -- Otherwise, reads check.jsonl to get dynamic completion markers ({reason}_FINISH) -- Blocks stopping until verification passes or all markers found -- Has max iterations as safety limit - -State file: .trellis/.ralph-state.json -- Tracks current iteration count per session -- Resets when task changes -""" - -# IMPORTANT: Suppress all warnings FIRST -import warnings -warnings.filterwarnings("ignore") - -import json -import os -import subprocess -import sys -from datetime import datetime -from pathlib import Path - -# IMPORTANT: Force stdout to use UTF-8 on Windows -# This fixes UnicodeEncodeError when outputting non-ASCII characters -if sys.platform == "win32": - import io as _io - if hasattr(sys.stdout, "reconfigure"): - sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] - elif hasattr(sys.stdout, "detach"): - sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr] - -# ============================================================================= -# Configuration -# ============================================================================= - -MAX_ITERATIONS = 5 # Safety limit to prevent infinite loops -STATE_TIMEOUT_MINUTES = 30 # Reset state if older than this -STATE_FILE = ".trellis/.ralph-state.json" -WORKTREE_YAML = ".trellis/worktree.yaml" -DIR_WORKFLOW = ".trellis" -FILE_CURRENT_TASK = ".current-task" - -# Only control loop for check agent -TARGET_AGENT = "check" - - -def find_repo_root(start_path: str) -> str | None: - """Find git repo root from start_path upwards""" - current = Path(start_path).resolve() - while current != current.parent: - if (current / ".git").exists(): - return str(current) - current = current.parent - return None - - -def get_current_task(repo_root: str) -> str | None: - """Read current task directory path""" - current_task_file = os.path.join(repo_root, DIR_WORKFLOW, FILE_CURRENT_TASK) - if not os.path.exists(current_task_file): - return None - - try: - with open(current_task_file, "r", encoding="utf-8") as f: - content = f.read().strip() - return content if content else None - except Exception: - return None - - -def get_verify_commands(repo_root: str) -> list[str]: - """ - Read verify commands from worktree.yaml. - - Returns list of commands to run, or empty list if not configured. - Uses simple YAML parsing without external dependencies. - """ - yaml_path = os.path.join(repo_root, WORKTREE_YAML) - if not os.path.exists(yaml_path): - return [] - - try: - with open(yaml_path, "r", encoding="utf-8") as f: - content = f.read() - - # Simple YAML parsing for verify section - # Look for "verify:" followed by list items - lines = content.split("\n") - in_verify_section = False - commands = [] - - for line in lines: - stripped = line.strip() - - # Check for section start - if stripped.startswith("verify:"): - in_verify_section = True - continue - - # Check for new section (not indented, ends with :) - if ( - not line.startswith(" ") - and not line.startswith("\t") - and stripped.endswith(":") - and stripped != "" - ): - in_verify_section = False - continue - - # If in verify section, look for list items - if in_verify_section: - # Skip comments and empty lines - if stripped.startswith("#") or stripped == "": - continue - # Parse list item (- command) - if stripped.startswith("- "): - cmd = stripped[2:].strip() - if cmd: - commands.append(cmd) - - return commands - except Exception: - return [] - - -def run_verify_commands(repo_root: str, commands: list[str]) -> tuple[bool, str]: - """ - Run verify commands and return (success, message). - - All commands must pass for success. - """ - for cmd in commands: - try: - result = subprocess.run( - cmd, - shell=True, - cwd=repo_root, - capture_output=True, - timeout=120, # 2 minute timeout per command - ) - if result.returncode != 0: - stderr = result.stderr.decode("utf-8", errors="replace") - stdout = result.stdout.decode("utf-8", errors="replace") - error_output = stderr or stdout - # Truncate long output - if len(error_output) > 500: - error_output = error_output[:500] + "..." - return False, f"Command failed: {cmd}\n{error_output}" - except subprocess.TimeoutExpired: - return False, f"Command timed out: {cmd}" - except Exception as e: - return False, f"Command error: {cmd} - {str(e)}" - - return True, "All verify commands passed" - - -def get_completion_markers(repo_root: str, task_dir: str) -> list[str]: - """ - Read check.jsonl and generate completion markers from reasons. - - Each entry's "reason" field becomes {REASON}_FINISH marker. - Example: {"file": "...", "reason": "TypeCheck"} -> "TYPECHECK_FINISH" - """ - check_jsonl_path = os.path.join(repo_root, task_dir, "check.jsonl") - markers = [] - - if not os.path.exists(check_jsonl_path): - # Fallback: if no check.jsonl, use default marker - return ["ALL_CHECKS_FINISH"] - - try: - with open(check_jsonl_path, "r", encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line: - continue - try: - item = json.loads(line) - reason = item.get("reason", "") - if reason: - # Convert to uppercase and add _FINISH suffix - marker = f"{reason.upper().replace(' ', '_')}_FINISH" - if marker not in markers: - markers.append(marker) - except json.JSONDecodeError: - continue - except Exception: - pass - - # If no markers found, use default - if not markers: - markers = ["ALL_CHECKS_FINISH"] - - return markers - - -def load_state(repo_root: str) -> dict: - """Load Ralph Loop state from file""" - state_path = os.path.join(repo_root, STATE_FILE) - if not os.path.exists(state_path): - return {"task": None, "iteration": 0, "started_at": None} - - try: - with open(state_path, "r", encoding="utf-8") as f: - return json.load(f) - except Exception: - return {"task": None, "iteration": 0, "started_at": None} - - -def save_state(repo_root: str, state: dict) -> None: - """Save Ralph Loop state to file""" - state_path = os.path.join(repo_root, STATE_FILE) - try: - # Ensure directory exists - os.makedirs(os.path.dirname(state_path), exist_ok=True) - with open(state_path, "w", encoding="utf-8") as f: - json.dump(state, f, indent=2, ensure_ascii=False) - except Exception: - pass - - -def check_completion(agent_output: str, markers: list[str]) -> tuple[bool, list[str]]: - """ - Check if all completion markers are present in agent output. - - Returns: - (all_complete, missing_markers) - """ - missing = [] - for marker in markers: - if marker not in agent_output: - missing.append(marker) - - return len(missing) == 0, missing - - -def main(): - try: - input_data = json.load(sys.stdin) - except json.JSONDecodeError: - # If can't parse input, allow stop - sys.exit(0) - - # Get event info - hook_event = input_data.get("hook_event_name", "") - - # Only handle SubagentStop event - if hook_event != "SubagentStop": - sys.exit(0) - - # Get subagent info - subagent_type = input_data.get("subagent_type", "") - agent_output = input_data.get("agent_output", "") - original_prompt = input_data.get("prompt", "") - cwd = input_data.get("cwd", os.getcwd()) - - # Only control check agent - if subagent_type != TARGET_AGENT: - sys.exit(0) - - # Skip Ralph Loop for finish phase (already verified in check phase) - if "[finish]" in original_prompt.lower(): - sys.exit(0) - - # Find repo root - repo_root = find_repo_root(cwd) - if not repo_root: - sys.exit(0) - - # Get current task - task_dir = get_current_task(repo_root) - if not task_dir: - sys.exit(0) - - # Load state - state = load_state(repo_root) - - # Reset state if task changed or state is too old - should_reset = False - if state.get("task") != task_dir: - should_reset = True - elif state.get("started_at"): - try: - started = datetime.fromisoformat(state["started_at"]) - if (datetime.now() - started).total_seconds() > STATE_TIMEOUT_MINUTES * 60: - should_reset = True - except (ValueError, TypeError): - should_reset = True - - if should_reset: - state = { - "task": task_dir, - "iteration": 0, - "started_at": datetime.now().isoformat(), - } - - # Increment iteration - state["iteration"] = state.get("iteration", 0) + 1 - current_iteration = state["iteration"] - - # Save state - save_state(repo_root, state) - - # Safety check: max iterations - if current_iteration >= MAX_ITERATIONS: - # Allow stop, reset state for next run - state["iteration"] = 0 - save_state(repo_root, state) - output = { - "decision": "allow", - "reason": f"Max iterations ({MAX_ITERATIONS}) reached. Stopping to prevent infinite loop.", - } - print(json.dumps(output, ensure_ascii=False)) - sys.exit(0) - - # Check if verify commands are configured - verify_commands = get_verify_commands(repo_root) - - if verify_commands: - # Use programmatic verification - passed, message = run_verify_commands(repo_root, verify_commands) - - if passed: - # All verify commands passed, allow stop - state["iteration"] = 0 - save_state(repo_root, state) - output = { - "decision": "allow", - "reason": "All verify commands passed. Check phase complete.", - } - print(json.dumps(output, ensure_ascii=False)) - sys.exit(0) - else: - # Verification failed, block stop - output = { - "decision": "block", - "reason": f"Iteration {current_iteration}/{MAX_ITERATIONS}. Verification failed:\n{message}\n\nPlease fix the issues and try again.", - } - print(json.dumps(output, ensure_ascii=False)) - sys.exit(0) - else: - # No verify commands, fall back to completion markers - markers = get_completion_markers(repo_root, task_dir) - all_complete, missing = check_completion(agent_output, markers) - - if all_complete: - # All checks complete, allow stop - state["iteration"] = 0 - save_state(repo_root, state) - output = { - "decision": "allow", - "reason": "All completion markers found. Check phase complete.", - } - print(json.dumps(output, ensure_ascii=False)) - sys.exit(0) - else: - # Missing markers, block stop and continue - output = { - "decision": "block", - "reason": f"""Iteration {current_iteration}/{MAX_ITERATIONS}. Missing completion markers: {", ".join(missing)}. - -IMPORTANT: You must ACTUALLY run the checks, not just output the markers. -- Did you run lint? What was the output? -- Did you run typecheck? What was the output? -- Did they actually pass with zero errors? - -Only output a marker (e.g., LINT_FINISH) AFTER: -1. You have executed the corresponding command -2. The command completed with zero errors -3. You have shown the command output in your response - -Do NOT output markers just to escape the loop. The loop exists to ensure quality.""", - } - print(json.dumps(output, ensure_ascii=False)) - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/.claude/hooks/session-start.py b/.claude/hooks/session-start.py deleted file mode 100644 index eeee0c1a..00000000 --- a/.claude/hooks/session-start.py +++ /dev/null @@ -1,205 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Session Start Hook - Inject structured context -""" - -# IMPORTANT: Suppress all warnings FIRST -import warnings -warnings.filterwarnings("ignore") - -import json -import os -import subprocess -import sys -from io import StringIO -from pathlib import Path - -# IMPORTANT: Force stdout to use UTF-8 on Windows -# This fixes UnicodeEncodeError when outputting non-ASCII characters -if sys.platform == "win32": - import io as _io - if hasattr(sys.stdout, "reconfigure"): - sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] - elif hasattr(sys.stdout, "detach"): - sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr] - - -def should_skip_injection() -> bool: - return ( - os.environ.get("CLAUDE_NON_INTERACTIVE") == "1" - or os.environ.get("OPENCODE_NON_INTERACTIVE") == "1" - ) - - -def read_file(path: Path, fallback: str = "") -> str: - try: - return path.read_text(encoding="utf-8") - except (FileNotFoundError, PermissionError): - return fallback - - -def run_script(script_path: Path) -> str: - try: - if script_path.suffix == ".py": - # Add PYTHONIOENCODING to force UTF-8 in subprocess - env = os.environ.copy() - env["PYTHONIOENCODING"] = "utf-8" - cmd = [sys.executable, "-W", "ignore", str(script_path)] - else: - env = os.environ - cmd = [str(script_path)] - - result = subprocess.run( - cmd, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=5, - cwd=script_path.parent.parent.parent, - env=env, - ) - return result.stdout if result.returncode == 0 else "No context available" - except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError): - return "No context available" - - -def _get_task_status(trellis_dir: Path) -> str: - """Check current task status and return structured status string.""" - current_task_file = trellis_dir / ".current-task" - if not current_task_file.is_file(): - return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on" - - task_ref = current_task_file.read_text(encoding="utf-8").strip() - if not task_ref: - return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on" - - # Resolve task directory - if Path(task_ref).is_absolute(): - task_dir = Path(task_ref) - elif task_ref.startswith(".trellis/"): - task_dir = trellis_dir.parent / task_ref - else: - task_dir = trellis_dir / "tasks" / task_ref - if not task_dir.is_dir(): - return f"Status: STALE POINTER\nTask: {task_ref}\nNext: Task directory not found. Run: python3 ./.trellis/scripts/task.py finish" - - # Read task.json - task_json_path = task_dir / "task.json" - task_data = {} - if task_json_path.is_file(): - try: - task_data = json.loads(task_json_path.read_text(encoding="utf-8")) - except (json.JSONDecodeError, PermissionError): - pass - - task_title = task_data.get("title", task_ref) - task_status = task_data.get("status", "unknown") - - if task_status == "completed": - return f"Status: COMPLETED\nTask: {task_title}\nNext: Archive with `python3 ./.trellis/scripts/task.py archive {task_dir.name}` or start a new task" - - # Check if context is configured (jsonl files exist and non-empty) - has_context = False - for jsonl_name in ("implement.jsonl", "check.jsonl", "spec.jsonl"): - jsonl_path = task_dir / jsonl_name - if jsonl_path.is_file() and jsonl_path.stat().st_size > 0: - has_context = True - break - - has_prd = (task_dir / "prd.md").is_file() - - if not has_prd: - return f"Status: NOT READY\nTask: {task_title}\nMissing: prd.md not created\nNext: Write PRD, then research → init-context → start" - - if not has_context: - return f"Status: NOT READY\nTask: {task_title}\nMissing: Context not configured (no jsonl files)\nNext: Complete Phase 2 (research → init-context → start) before implementing" - - return f"Status: READY\nTask: {task_title}\nNext: Continue with implement or check" - - -def main(): - if should_skip_injection(): - sys.exit(0) - - project_dir = Path(os.environ.get("CLAUDE_PROJECT_DIR", ".")).resolve() - trellis_dir = project_dir / ".trellis" - claude_dir = project_dir / ".claude" - - output = StringIO() - - output.write("""<session-context> -You are starting a new session in a Trellis-managed project. -Read and follow all instructions below carefully. -</session-context> - -""") - - output.write("<current-state>\n") - context_script = trellis_dir / "scripts" / "get_context.py" - output.write(run_script(context_script)) - output.write("\n</current-state>\n\n") - - output.write("<workflow>\n") - workflow_content = read_file(trellis_dir / "workflow.md", "No workflow.md found") - output.write(workflow_content) - output.write("\n</workflow>\n\n") - - output.write("<guidelines>\n") - output.write("**Note**: The guidelines below are index files — they list available guideline documents and their locations.\n") - output.write("During actual development, you MUST read the specific guideline files listed in each index's Pre-Development Checklist.\n\n") - - spec_dir = trellis_dir / "spec" - if spec_dir.is_dir(): - for sub in sorted(spec_dir.iterdir()): - if not sub.is_dir() or sub.name.startswith("."): - continue - index_file = sub / "index.md" - if index_file.is_file(): - output.write(f"## {sub.name}\n") - output.write(read_file(index_file)) - output.write("\n\n") - else: - # Check for nested package dirs (monorepo: spec/<pkg>/<layer>/index.md) - for nested in sorted(sub.iterdir()): - if not nested.is_dir(): - continue - nested_index = nested / "index.md" - if nested_index.is_file(): - output.write(f"## {sub.name}/{nested.name}\n") - output.write(read_file(nested_index)) - output.write("\n\n") - - output.write("</guidelines>\n\n") - - output.write("<instructions>\n") - start_md = read_file( - claude_dir / "commands" / "trellis" / "start.md", "No start.md found" - ) - output.write(start_md) - output.write("\n</instructions>\n\n") - - # R2: Check task status and inject structured tag - task_status = _get_task_status(trellis_dir) - output.write(f"<task-status>\n{task_status}\n</task-status>\n\n") - - output.write("""<ready> -Context loaded. Steps 1-3 (workflow, context, guidelines) are already injected above — do NOT re-read them. -Start from Step 4. Wait for user's first message, then follow <instructions> to handle their request. -If there is an active task, ask whether to continue it. -</ready>""") - - result = { - "hookSpecificOutput": { - "hookEventName": "SessionStart", - "additionalContext": output.getvalue(), - } - } - - # Output JSON - stdout is already configured for UTF-8 - print(json.dumps(result, ensure_ascii=False), flush=True) - - -if __name__ == "__main__": - main() diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index d6ed2708..00000000 --- a/.claude/settings.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "hooks": { - "SessionStart": [ - { - "matcher": "startup", - "hooks": [ - { - "type": "command", - "command": "python3 .claude/hooks/session-start.py", - "timeout": 10 - } - ] - }, - { - "matcher": "clear", - "hooks": [ - { - "type": "command", - "command": "python3 .claude/hooks/session-start.py", - "timeout": 10 - } - ] - }, - { - "matcher": "compact", - "hooks": [ - { - "type": "command", - "command": "python3 .claude/hooks/session-start.py", - "timeout": 10 - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "Task", - "hooks": [ - { - "type": "command", - "command": "python3 .claude/hooks/inject-subagent-context.py", - "timeout": 30 - } - ] - }, - { - "matcher": "Agent", - "hooks": [ - { - "type": "command", - "command": "python3 .claude/hooks/inject-subagent-context.py", - "timeout": 30 - } - ] - } - ], - "SubagentStop": [ - { - "matcher": "check", - "hooks": [ - { - "type": "command", - "command": "python3 .claude/hooks/ralph-loop.py", - "timeout": 10 - } - ] - } - ] - }, - "enabledPlugins": {} -} diff --git a/.gitignore b/.gitignore index 31530819..d54f7cfa 100644 --- a/.gitignore +++ b/.gitignore @@ -52,5 +52,12 @@ debian/libdfm-burn-dev/* debian/*.log debian/*.substvars debian/files + +# ai +CLAUDE.md +AGENTS.md .cursor +.trellis +.claude +.agents diff --git a/.trellis/.gitignore b/.trellis/.gitignore deleted file mode 100644 index 46135ba0..00000000 --- a/.trellis/.gitignore +++ /dev/null @@ -1,29 +0,0 @@ -# Developer identity (local only) -.developer - -# Current task pointer (each dev works on different task) -.current-task - -# Ralph Loop state file -.ralph-state.json - -# Agent runtime files -.agents/ -.agent-log -.session-id - -# Task directory runtime files -.plan-log - -# Atomic update temp files -*.tmp - -# Update backup directories -.backup-* - -# Conflict resolution temp files -*.new - -# Python cache -**/__pycache__/ -**/*.pyc diff --git a/.trellis/.template-hashes.json b/.trellis/.template-hashes.json deleted file mode 100644 index a451d294..00000000 --- a/.trellis/.template-hashes.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - ".trellis/config.yaml": "fe1fba0961e589c6f49190f5e19d4edb0d5bf894dba8468f06882c6e1c5e2aa1", - ".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c", - ".trellis/scripts/add_session.py": "7c869be8146e6f675bd95e424909ff301ea0a8f8fd82a4f056f6d320e755a406", - ".trellis/scripts/common/__init__.py": "301724230abcce6e9fc99054c12d21c30eea7bc3b330ae6350aa3b6158461273", - ".trellis/scripts/common/cli_adapter.py": "66ef4f75470807b531490a6b6928604eb59781148fe3c5412f39e132ffab0850", - ".trellis/scripts/common/config.py": "909257b442d7d1e7a2596996622c4f2f010d8c1343e1efd088ef8615d99554c7", - ".trellis/scripts/common/developer.py": "69f6145c4c48953677de3ba06f487ba2a1675f4d66153346ab40594bb06a01c9", - ".trellis/scripts/common/git_context.py": "f154d358c858f7bcfc21a03c9b909af3a8dfa20be37b2c5012d84b8e0588b493", - ".trellis/scripts/common/paths.py": "058f333fb80c71c90ddc131742e8e64949c2f1ed07c1254d8f7232506d891ffc", - ".trellis/scripts/common/phase.py": "f9bdd553c7a278b97736b04c066ed06d8baa2ef179ed8219befcf6c27afcc9cd", - ".trellis/scripts/common/registry.py": "6c65db45a487ef839b0a4b5b20abe201547269c20c7257254293a89dc01b56dc", - ".trellis/scripts/common/task_queue.py": "6de22c7731465ee52d2b5cd4853b191d3cf869bf259fbc93079b426ba1c3756c", - ".trellis/scripts/common/task_utils.py": "e19c290d90f9a779db161aeb9fefda27852847fbc67d358d471530b8ede64131", - ".trellis/scripts/common/worktree.py": "434880e02dfa2e92f0c717ed2a28e4cdee681ea10c329a2438d533bdbc612408", - ".trellis/scripts/create_bootstrap.py": "aa5dd1f39a77b2f4bb827fd14ce7a83fb51870e77f556fe508afce3f8eac0b4e", - ".trellis/scripts/get_context.py": "ca5bf9e90bdb1d75d3de182b95f820f9d108ab28793d29097b24fd71315adcf5", - ".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f", - ".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e", - ".trellis/scripts/multi_agent/__init__.py": "af6fceb4d9a64da04be03ba0f5a6daf71066503eca832b8b58d8a7d4b2844fa4", - ".trellis/scripts/multi_agent/cleanup.py": "db50c4fbb32261905a8278c2760b33029f187963cd4e448938e57f3db3facd6c", - ".trellis/scripts/multi_agent/create_pr.py": "6a2423aba5720a2150c32349faa957cdc59c6bb96511e56c79ca08d92d69c666", - ".trellis/scripts/multi_agent/plan.py": "242b870b7667f730c910d629f16d44d5d3fd0a58f6451d9003c175fb2e77cee5", - ".trellis/scripts/multi_agent/start.py": "32ed1a13405b7c71881b2507a79e1a3733bc3fcedbc92fcee0d733ce00d759d0", - ".trellis/scripts/multi_agent/status.py": "5fc46b6d605c69b6044967a6b33ffb0c9d6f99dd919374572ac614222864a811", - ".trellis/scripts/task.py": "ecf52885a698dc93af67fd693825a2f71163ab86b5c2abe76d8aa2e2caa44372", - ".trellis/workflow.md": "9b6d6e8027bd2cf32d9efd7ef77d6524c59fcaa4ad6052f72d028a07a5fd69a7", - ".trellis/worktree.yaml": "c57de79e40d5f748f099625ed4a17d5f0afbf25cac598aced0b3c964e7b7c226", - ".claude/agents/check.md": "7c7400e7ea8bf3f3f879bfa028fd5b4d41673e0150d44c52292161ba33612812", - ".claude/agents/debug.md": "94be0b1cfbae4c64caee4775ef504f43acfcd4a80427a26d6f680ceaddcbee24", - ".claude/agents/dispatch.md": "20e699a87aeb0b046c51d8485e433190916c645e21db9a06f9e468272738347e", - ".claude/agents/implement.md": "d537797d3fa510afdeaa365d43ef897a261e71c9144ef6986b8574be8d09055c", - ".claude/agents/plan.md": "d796f689b8b8945d1809679d0c74475f419325b30f36ef0c59b7fae73386e90b", - ".claude/agents/research.md": "086ae23120151b3591089a4de20fd54e6ae2b89038f5903ee9a52269cd7ded6a", - ".claude/commands/trellis/before-backend-dev.md": "7e35444de2a5779ef39944f17f566ea21d2ed7f4994246f4cfe6ebf9a11dd3e3", - ".claude/commands/trellis/before-frontend-dev.md": "a6225f9d123dbd4a7aec822652030cae50be3f5b308297015e04d42b23a27b2a", - ".claude/commands/trellis/brainstorm.md": "7c7731eda092275a5d87f2569a69584f3c39b544a126a76e727a1e9d250c4a65", - ".claude/commands/trellis/break-loop.md": "ba4dd4022dde1e4bbcfc1cc99e6a118e51b9db95bd962d88f1c29d0c9c433112", - ".claude/commands/trellis/check-backend.md": "4e81a28d681ea770f780df55a212fd504ce21ee49b44ba16023b74b5c243cef3", - ".claude/commands/trellis/check-cross-layer.md": "b9ab24515ead84330d6634f6ad912ca3547db3a36139d62c5688161824097d60", - ".claude/commands/trellis/check-frontend.md": "5e8e3b682032ba0dd6bb843dd4826fff0159f78a7084964ccb119c6cf98b3d91", - ".claude/commands/trellis/create-command.md": "c2825c7941b4ef4a3f3365c4c807ff138096a39aece3d051776f3c11a4e4857d", - ".claude/commands/trellis/finish-work.md": "cc92cad9e94ce1cc4f29e3de16a640db7e9176e3ecfc9c19a566153671ca2168", - ".claude/commands/trellis/integrate-skill.md": "3940442485341832257c595ddfb45582e2d60e5a4716f2bd15b7bce0498b130a", - ".claude/commands/trellis/onboard.md": "a5dbd5db094b13fd006ec856efa53a688e209bcdc3ed1680b63b15f1e3293ab4", - ".claude/commands/trellis/parallel.md": "f4c81fe1a468be214caf362263b14b6a6f40935497363109148cb7b19e644738", - ".claude/commands/trellis/record-session.md": "0c4f61283c2f262c1f9c900d9207309107497d4ac848cca86eb62bc5b7189fe7", - ".claude/commands/trellis/start.md": "2d4259d8d146d32c7b6c33dda36c14da76e1c3f1be35b27dc18e5eb5551c9276", - ".claude/commands/trellis/update-spec.md": "ff4d5a0405a763e61936f5b9df175fd25ea20ec5c20fa999855020ab78a919b6", - ".claude/hooks/inject-subagent-context.py": "75ce4cc175a00f9afa5fe1c80298e29521359ad90a66701c3c1166aa588f3080", - ".claude/hooks/ralph-loop.py": "a367a5dd4f605730cf8157c61658e848176ae480be19029126ff9bbd90a37712", - ".claude/hooks/session-start.py": "5c048949cbf8ac58c7c26ef51cd90bf91454574425f2158f4778c200b8098f53", - ".claude/settings.json": "fdb7fcf660961b4b52f22f08e91f942a193e1a3f5ebbca9cbba21a157d1c359d", - ".claude/settings.local.json": "08bcd54315e5bf82516a005a17a95b5d46b3c40bdfbd5cc7431ff12e17a4fcb9", - ".agents/skills/before-backend-dev/SKILL.md": "4537ccee0071353beee636a052c01642a27a87b6b0a73e7bc872b2501547fa64", - ".agents/skills/before-frontend-dev/SKILL.md": "679c1708a4d9fbad5214db299a38366581684a9383cf51a5d8ac21f890d6ba0d", - ".agents/skills/brainstorm/SKILL.md": "0cabc8e663a871dee6c8bbf7f149fe10f83f39835e66ad0a8d0867049aacb6f8", - ".agents/skills/break-loop/SKILL.md": "b19a47854ca66bde4ee03a30603480b4af2c131d5d81d752d1d28d2ef5131172", - ".agents/skills/check-backend/SKILL.md": "9b312cfd7a07ed036769b387d84d642cd5e20f06b88e7b6a4626705fa8beb6fa", - ".agents/skills/check-cross-layer/SKILL.md": "bc72df11d79a8ee809f45eae120c1cce91ab997541ce30d665af9978c83843f6", - ".agents/skills/check-frontend/SKILL.md": "27b75f9eea472ed104f39a65bb78ae559cfe8730c85e0742e55fd575a4a2f854", - ".agents/skills/create-command/SKILL.md": "5c24ca19c1cec64486f1a147e1dd4a37200270cbf3d0987dc6536f7de85a78f2", - ".agents/skills/finish-work/SKILL.md": "f3f77e3902021bb7d95452a6072ae3f67993bf7b7d0e172e33756a633b654bf2", - ".agents/skills/integrate-skill/SKILL.md": "47b7374345d8a31f9df07c5e8e875ca4fdc30d0cc45860d77df893250e2d97fc", - ".agents/skills/onboard/SKILL.md": "1808f578d21eae3cbcf650d6aa4cf35ac42bf466df740b830593c9bda212d51a", - ".agents/skills/record-session/SKILL.md": "ce27e953630a71ef989c5582790e9c8a600a2614ec668b674816c1daac73ce0a", - ".agents/skills/start/SKILL.md": "788e517f9e57c4ce68497c1cefabd51faa8253c681be7965915f21e6de9c5886" -} \ No newline at end of file diff --git a/.trellis/.version b/.trellis/.version deleted file mode 100644 index 81de5c57..00000000 --- a/.trellis/.version +++ /dev/null @@ -1 +0,0 @@ -0.3.10 \ No newline at end of file diff --git a/.trellis/config.yaml b/.trellis/config.yaml deleted file mode 100644 index 7d18551b..00000000 --- a/.trellis/config.yaml +++ /dev/null @@ -1,33 +0,0 @@ -# Trellis Configuration -# Project-level settings for the Trellis workflow system -# -# All values have sensible defaults. Only override what you need. - -#------------------------------------------------------------------------------- -# Session Recording -#------------------------------------------------------------------------------- - -# Commit message used when auto-committing journal/index changes -# after running add_session.py -session_commit_message: "chore: record journal" - -# Maximum lines per journal file before rotating to a new one -max_journal_lines: 2000 - -#------------------------------------------------------------------------------- -# Task Lifecycle Hooks -#------------------------------------------------------------------------------- - -# Shell commands to run after task lifecycle events. -# Each hook receives TASK_JSON_PATH environment variable pointing to task.json. -# Hook failures print a warning but do not block the main operation. -# -# hooks: -# after_create: -# - "echo 'Task created'" -# after_start: -# - "echo 'Task started'" -# after_finish: -# - "echo 'Task finished'" -# after_archive: -# - "echo 'Task archived'" diff --git a/.trellis/scripts/__init__.py b/.trellis/scripts/__init__.py deleted file mode 100755 index 815a1374..00000000 --- a/.trellis/scripts/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -""" -Trellis Python Scripts - -This module provides Python implementations of Trellis workflow scripts. -""" diff --git a/.trellis/scripts/add_session.py b/.trellis/scripts/add_session.py deleted file mode 100755 index 71606e5b..00000000 --- a/.trellis/scripts/add_session.py +++ /dev/null @@ -1,423 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Add a new session to journal file and update index.md. - -Usage: - python3 add_session.py --title "Title" --commit "hash" --summary "Summary" - echo "content" | python3 add_session.py --title "Title" --commit "hash" -""" - -from __future__ import annotations - -import argparse -import re -import subprocess -import sys -from datetime import datetime -from pathlib import Path - -from common.paths import ( - FILE_JOURNAL_PREFIX, - get_repo_root, - get_developer, - get_workspace_dir, -) -from common.developer import ensure_developer -from common.config import get_session_commit_message, get_max_journal_lines - - -# ============================================================================= -# Helper Functions -# ============================================================================= - -def get_latest_journal_info(dev_dir: Path) -> tuple[Path | None, int, int]: - """Get latest journal file info. - - Returns: - Tuple of (file_path, file_number, line_count). - """ - latest_file: Path | None = None - latest_num = -1 - - for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"): - if not f.is_file(): - continue - - match = re.search(r"(\d+)$", f.stem) - if match: - num = int(match.group(1)) - if num > latest_num: - latest_num = num - latest_file = f - - if latest_file: - lines = len(latest_file.read_text(encoding="utf-8").splitlines()) - return latest_file, latest_num, lines - - return None, 0, 0 - - -def get_current_session(index_file: Path) -> int: - """Get current session number from index.md.""" - if not index_file.is_file(): - return 0 - - content = index_file.read_text(encoding="utf-8") - for line in content.splitlines(): - if "Total Sessions" in line: - match = re.search(r":\s*(\d+)", line) - if match: - return int(match.group(1)) - return 0 - - -def _extract_journal_num(filename: str) -> int: - """Extract journal number from filename for sorting.""" - match = re.search(r"(\d+)", filename) - return int(match.group(1)) if match else 0 - - -def count_journal_files(dev_dir: Path, active_num: int) -> str: - """Count journal files and return table rows.""" - active_file = f"{FILE_JOURNAL_PREFIX}{active_num}.md" - result_lines = [] - - files = sorted( - [f for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md") if f.is_file()], - key=lambda f: _extract_journal_num(f.stem), - reverse=True - ) - - for f in files: - filename = f.name - lines = len(f.read_text(encoding="utf-8").splitlines()) - status = "Active" if filename == active_file else "Archived" - result_lines.append(f"| `{filename}` | ~{lines} | {status} |") - - return "\n".join(result_lines) - - -def create_new_journal_file( - dev_dir: Path, num: int, developer: str, today: str, max_lines: int = 2000, -) -> Path: - """Create a new journal file.""" - prev_num = num - 1 - new_file = dev_dir / f"{FILE_JOURNAL_PREFIX}{num}.md" - - content = f"""# Journal - {developer} (Part {num}) - -> Continuation from `{FILE_JOURNAL_PREFIX}{prev_num}.md` (archived at ~{max_lines} lines) -> Started: {today} - ---- - -""" - new_file.write_text(content, encoding="utf-8") - return new_file - - -def generate_session_content( - session_num: int, - title: str, - commit: str, - summary: str, - extra_content: str, - today: str -) -> str: - """Generate session content.""" - if commit and commit != "-": - commit_table = """| Hash | Message | -|------|---------|""" - for c in commit.split(","): - c = c.strip() - commit_table += f"\n| `{c}` | (see git log) |" - else: - commit_table = "(No commits - planning session)" - - return f""" - -## Session {session_num}: {title} - -**Date**: {today} -**Task**: {title} - -### Summary - -{summary} - -### Main Changes - -{extra_content} - -### Git Commits - -{commit_table} - -### Testing - -- [OK] (Add test results) - -### Status - -[OK] **Completed** - -### Next Steps - -- None - task complete -""" - - -def update_index( - index_file: Path, - dev_dir: Path, - title: str, - commit: str, - new_session: int, - active_file: str, - today: str -) -> bool: - """Update index.md with new session info.""" - # Format commit for display - commit_display = "-" - if commit and commit != "-": - commit_display = re.sub(r"([a-f0-9]{7,})", r"`\1`", commit.replace(",", ", ")) - - # Get file number from active_file name - match = re.search(r"(\d+)", active_file) - active_num = int(match.group(1)) if match else 0 - files_table = count_journal_files(dev_dir, active_num) - - print(f"Updating index.md for session {new_session}...") - print(f" Title: {title}") - print(f" Commit: {commit_display}") - print(f" Active File: {active_file}") - print() - - content = index_file.read_text(encoding="utf-8") - - if "@@@auto:current-status" not in content: - print("Error: Markers not found in index.md. Please ensure markers exist.", file=sys.stderr) - return False - - # Process sections - lines = content.splitlines() - new_lines = [] - - in_current_status = False - in_active_documents = False - in_session_history = False - header_written = False - - for line in lines: - if "@@@auto:current-status" in line: - new_lines.append(line) - in_current_status = True - new_lines.append(f"- **Active File**: `{active_file}`") - new_lines.append(f"- **Total Sessions**: {new_session}") - new_lines.append(f"- **Last Active**: {today}") - continue - - if "@@@/auto:current-status" in line: - in_current_status = False - new_lines.append(line) - continue - - if "@@@auto:active-documents" in line: - new_lines.append(line) - in_active_documents = True - new_lines.append("| File | Lines | Status |") - new_lines.append("|------|-------|--------|") - new_lines.append(files_table) - continue - - if "@@@/auto:active-documents" in line: - in_active_documents = False - new_lines.append(line) - continue - - if "@@@auto:session-history" in line: - new_lines.append(line) - in_session_history = True - header_written = False - continue - - if "@@@/auto:session-history" in line: - in_session_history = False - new_lines.append(line) - continue - - if in_current_status: - continue - - if in_active_documents: - continue - - if in_session_history: - new_lines.append(line) - if re.match(r"^\|\s*-", line) and not header_written: - new_lines.append(f"| {new_session} | {today} | {title} | {commit_display} |") - header_written = True - continue - - new_lines.append(line) - - index_file.write_text("\n".join(new_lines), encoding="utf-8") - print("[OK] Updated index.md successfully!") - return True - - -# ============================================================================= -# Main Function -# ============================================================================= - -def _auto_commit_workspace(repo_root: Path) -> None: - """Stage .trellis/workspace and .trellis/tasks, then commit with a configured message.""" - commit_msg = get_session_commit_message(repo_root) - subprocess.run( - ["git", "add", "-A", ".trellis/workspace", ".trellis/tasks"], - cwd=repo_root, - capture_output=True, - ) - # Check if there are staged changes - result = subprocess.run( - ["git", "diff", "--cached", "--quiet", "--", ".trellis/workspace", ".trellis/tasks"], - cwd=repo_root, - ) - if result.returncode == 0: - print("[OK] No workspace changes to commit.", file=sys.stderr) - return - commit_result = subprocess.run( - ["git", "commit", "-m", commit_msg], - cwd=repo_root, - capture_output=True, - text=True, - ) - if commit_result.returncode == 0: - print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr) - else: - print(f"[WARN] Auto-commit failed: {commit_result.stderr.strip()}", file=sys.stderr) - - -def add_session( - title: str, - commit: str = "-", - summary: str = "(Add summary)", - extra_content: str = "(Add details)", - auto_commit: bool = True, -) -> int: - """Add a new session.""" - repo_root = get_repo_root() - ensure_developer(repo_root) - - developer = get_developer(repo_root) - if not developer: - print("Error: Developer not initialized", file=sys.stderr) - return 1 - - dev_dir = get_workspace_dir(repo_root) - if not dev_dir: - print("Error: Workspace directory not found", file=sys.stderr) - return 1 - - max_lines = get_max_journal_lines(repo_root) - - index_file = dev_dir / "index.md" - today = datetime.now().strftime("%Y-%m-%d") - - journal_file, current_num, current_lines = get_latest_journal_info(dev_dir) - current_session = get_current_session(index_file) - new_session = current_session + 1 - - session_content = generate_session_content( - new_session, title, commit, summary, extra_content, today - ) - content_lines = len(session_content.splitlines()) - - print("========================================", file=sys.stderr) - print("ADD SESSION", file=sys.stderr) - print("========================================", file=sys.stderr) - print("", file=sys.stderr) - print(f"Session: {new_session}", file=sys.stderr) - print(f"Title: {title}", file=sys.stderr) - print(f"Commit: {commit}", file=sys.stderr) - print("", file=sys.stderr) - print(f"Current journal file: {FILE_JOURNAL_PREFIX}{current_num}.md", file=sys.stderr) - print(f"Current lines: {current_lines}", file=sys.stderr) - print(f"New content lines: {content_lines}", file=sys.stderr) - print(f"Total after append: {current_lines + content_lines}", file=sys.stderr) - print("", file=sys.stderr) - - target_file = journal_file - target_num = current_num - - if current_lines + content_lines > max_lines: - target_num = current_num + 1 - print(f"[!] Exceeds {max_lines} lines, creating {FILE_JOURNAL_PREFIX}{target_num}.md", file=sys.stderr) - target_file = create_new_journal_file(dev_dir, target_num, developer, today, max_lines) - print(f"Created: {target_file}", file=sys.stderr) - - # Append session content - if target_file: - with target_file.open("a", encoding="utf-8") as f: - f.write(session_content) - print(f"[OK] Appended session to {target_file.name}", file=sys.stderr) - - print("", file=sys.stderr) - - # Update index.md - active_file = f"{FILE_JOURNAL_PREFIX}{target_num}.md" - if not update_index(index_file, dev_dir, title, commit, new_session, active_file, today): - return 1 - - print("", file=sys.stderr) - print("========================================", file=sys.stderr) - print(f"[OK] Session {new_session} added successfully!", file=sys.stderr) - print("========================================", file=sys.stderr) - print("", file=sys.stderr) - print("Files updated:", file=sys.stderr) - print(f" - {target_file.name if target_file else 'journal'}", file=sys.stderr) - print(" - index.md", file=sys.stderr) - - # Auto-commit workspace changes - if auto_commit: - print("", file=sys.stderr) - _auto_commit_workspace(repo_root) - - return 0 - - -# ============================================================================= -# Main Entry -# ============================================================================= - -def main() -> int: - """CLI entry point.""" - parser = argparse.ArgumentParser( - description="Add a new session to journal file and update index.md" - ) - parser.add_argument("--title", required=True, help="Session title") - parser.add_argument("--commit", default="-", help="Comma-separated commit hashes") - parser.add_argument("--summary", default="(Add summary)", help="Brief summary") - parser.add_argument("--content-file", help="Path to file with detailed content") - parser.add_argument("--no-commit", action="store_true", - help="Skip auto-commit of workspace changes") - - args = parser.parse_args() - - extra_content = "(Add details)" - if args.content_file: - content_path = Path(args.content_file) - if content_path.is_file(): - extra_content = content_path.read_text(encoding="utf-8") - elif not sys.stdin.isatty(): - extra_content = sys.stdin.read() - - return add_session( - args.title, args.commit, args.summary, extra_content, - auto_commit=not args.no_commit, - ) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/scripts/common/__init__.py b/.trellis/scripts/common/__init__.py deleted file mode 100755 index 17729781..00000000 --- a/.trellis/scripts/common/__init__.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -Common utilities for Trellis workflow scripts. - -This module provides shared functionality used by other Trellis scripts. -""" - -import io -import sys - -# ============================================================================= -# Windows Encoding Fix (MUST be at top, before any other output) -# ============================================================================= -# On Windows, stdout defaults to the system code page (often GBK/CP936). -# This causes UnicodeEncodeError when printing non-ASCII characters. -# -# Any script that imports from common will automatically get this fix. -# ============================================================================= - - -def _configure_stream(stream: object) -> object: - """Configure a stream for UTF-8 encoding on Windows.""" - # Try reconfigure() first (Python 3.7+, more reliable) - if hasattr(stream, "reconfigure"): - stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] - return stream - # Fallback: detach and rewrap with TextIOWrapper - elif hasattr(stream, "detach"): - return io.TextIOWrapper( - stream.detach(), # type: ignore[union-attr] - encoding="utf-8", - errors="replace", - ) - return stream - - -if sys.platform == "win32": - sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment] - sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment] - sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment] - - -def configure_encoding() -> None: - """ - Configure stdout/stderr/stdin for UTF-8 encoding on Windows. - - This is automatically called when importing from common, - but can be called manually for scripts that don't import common. - - Safe to call multiple times. - """ - global sys - if sys.platform == "win32": - sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment] - sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment] - sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment] - - -from .paths import ( - DIR_WORKFLOW, - DIR_WORKSPACE, - DIR_TASKS, - DIR_ARCHIVE, - DIR_SPEC, - DIR_SCRIPTS, - FILE_DEVELOPER, - FILE_CURRENT_TASK, - FILE_TASK_JSON, - FILE_JOURNAL_PREFIX, - get_repo_root, - get_developer, - check_developer, - get_tasks_dir, - get_workspace_dir, - get_active_journal_file, - count_lines, - get_current_task, - get_current_task_abs, - set_current_task, - clear_current_task, - has_current_task, - generate_task_date_prefix, -) diff --git a/.trellis/scripts/common/cli_adapter.py b/.trellis/scripts/common/cli_adapter.py deleted file mode 100755 index ce3323b4..00000000 --- a/.trellis/scripts/common/cli_adapter.py +++ /dev/null @@ -1,628 +0,0 @@ -""" -CLI Adapter for Multi-Platform Support. - -Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, and Qoder interfaces. - -Supported platforms: -- claude: Claude Code (default) -- opencode: OpenCode -- cursor: Cursor IDE -- iflow: iFlow CLI -- codex: Codex CLI (skills-based) -- kilo: Kilo CLI -- kiro: Kiro Code (skills-based) -- gemini: Gemini CLI -- antigravity: Antigravity (workflow-based) -- qoder: Qoder - -Usage: - from common.cli_adapter import CLIAdapter - - adapter = CLIAdapter("opencode") - cmd = adapter.build_run_command( - agent="dispatch", - session_id="abc123", - prompt="Start the pipeline" - ) -""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -from typing import ClassVar, Literal - -Platform = Literal[ - "claude", - "opencode", - "cursor", - "iflow", - "codex", - "kilo", - "kiro", - "gemini", - "antigravity", - "qoder", -] - - -@dataclass -class CLIAdapter: - """Adapter for different AI coding CLI tools.""" - - platform: Platform - - # ========================================================================= - # Agent Name Mapping - # ========================================================================= - - # OpenCode has built-in agents that cannot be overridden - # See: https://github.com/sst/opencode/issues/4271 - # Note: Class-level constant, not a dataclass field - _AGENT_NAME_MAP: ClassVar[dict[Platform, dict[str, str]]] = { - "claude": {}, # No mapping needed - "opencode": { - "plan": "trellis-plan", # 'plan' is built-in in OpenCode - }, - } - - def get_agent_name(self, agent: str) -> str: - """Get platform-specific agent name. - - Args: - agent: Original agent name (e.g., 'plan', 'dispatch') - - Returns: - Platform-specific agent name (e.g., 'trellis-plan' for OpenCode) - """ - mapping = self._AGENT_NAME_MAP.get(self.platform, {}) - return mapping.get(agent, agent) - - # ========================================================================= - # Agent Path - # ========================================================================= - - @property - def config_dir_name(self) -> str: - """Get platform-specific config directory name. - - Returns: - Directory name ('.claude', '.opencode', '.cursor', '.iflow', '.agents', '.kilocode', '.kiro', '.gemini', '.agent', or '.qoder') - """ - if self.platform == "opencode": - return ".opencode" - elif self.platform == "cursor": - return ".cursor" - elif self.platform == "iflow": - return ".iflow" - elif self.platform == "codex": - return ".agents" - elif self.platform == "kilo": - return ".kilocode" - elif self.platform == "kiro": - return ".kiro" - elif self.platform == "gemini": - return ".gemini" - elif self.platform == "antigravity": - return ".agent" - elif self.platform == "qoder": - return ".qoder" - else: - return ".claude" - - def get_config_dir(self, project_root: Path) -> Path: - """Get platform-specific config directory. - - Args: - project_root: Project root directory - - Returns: - Path to config directory (.claude, .opencode, .cursor, .iflow, .agents, .kilocode, .kiro, .gemini, .agent, or .qoder) - """ - return project_root / self.config_dir_name - - def get_agent_path(self, agent: str, project_root: Path) -> Path: - """Get path to agent definition file. - - Args: - agent: Agent name (original, before mapping) - project_root: Project root directory - - Returns: - Path to agent .md file - """ - mapped_name = self.get_agent_name(agent) - return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.md" - - def get_commands_path(self, project_root: Path, *parts: str) -> Path: - """Get path to commands directory or specific command file. - - Args: - project_root: Project root directory - *parts: Additional path parts (e.g., 'trellis', 'finish-work.md') - - Returns: - Path to commands directory or file - - Note: - Cursor uses prefix naming: .cursor/commands/trellis-<name>.md - Antigravity uses workflow directory: .agent/workflows/<name>.md - Claude/OpenCode use subdirectory: .claude/commands/trellis/<name>.md - """ - if self.platform in ("antigravity", "kilo"): - workflow_dir = self.get_config_dir(project_root) / "workflows" - if not parts: - return workflow_dir - if len(parts) >= 2 and parts[0] == "trellis": - filename = parts[-1] - return workflow_dir / filename - return workflow_dir / Path(*parts) - - if not parts: - return self.get_config_dir(project_root) / "commands" - - # Cursor uses prefix naming instead of subdirectory - if self.platform == "cursor" and len(parts) >= 2 and parts[0] == "trellis": - # Convert trellis/<name>.md to trellis-<name>.md - filename = parts[-1] - return ( - self.get_config_dir(project_root) / "commands" / f"trellis-{filename}" - ) - - return self.get_config_dir(project_root) / "commands" / Path(*parts) - - def get_trellis_command_path(self, name: str) -> str: - """Get relative path to a trellis command file. - - Args: - name: Command name without extension (e.g., 'finish-work', 'check-backend') - - Returns: - Relative path string for use in JSONL entries - - Note: - Cursor: .cursor/commands/trellis-<name>.md - Codex: .agents/skills/<name>/SKILL.md - Kiro: .kiro/skills/<name>/SKILL.md - Gemini: .gemini/commands/trellis/<name>.toml - Antigravity: .agent/workflows/<name>.md - Others: .{platform}/commands/trellis/<name>.md - """ - if self.platform == "cursor": - return f".cursor/commands/trellis-{name}.md" - elif self.platform == "codex": - return f".agents/skills/{name}/SKILL.md" - elif self.platform == "kiro": - return f".kiro/skills/{name}/SKILL.md" - elif self.platform == "gemini": - return f".gemini/commands/trellis/{name}.toml" - elif self.platform == "antigravity": - return f".agent/workflows/{name}.md" - elif self.platform == "kilo": - return f".kilocode/workflows/{name}.md" - else: - return f"{self.config_dir_name}/commands/trellis/{name}.md" - - # ========================================================================= - # Environment Variables - # ========================================================================= - - def get_non_interactive_env(self) -> dict[str, str]: - """Get environment variables for non-interactive mode. - - Returns: - Dict of environment variables to set - """ - if self.platform == "opencode": - return {"OPENCODE_NON_INTERACTIVE": "1"} - elif self.platform == "iflow": - return {"IFLOW_NON_INTERACTIVE": "1"} - elif self.platform == "codex": - return {"CODEX_NON_INTERACTIVE": "1"} - elif self.platform == "kiro": - return {"KIRO_NON_INTERACTIVE": "1"} - elif self.platform == "gemini": - return {} # Gemini CLI doesn't have a non-interactive env var - elif self.platform == "antigravity": - return {} - elif self.platform == "qoder": - return {} - else: - return {"CLAUDE_NON_INTERACTIVE": "1"} - - # ========================================================================= - # CLI Command Building - # ========================================================================= - - def build_run_command( - self, - agent: str, - prompt: str, - session_id: str | None = None, - skip_permissions: bool = True, - verbose: bool = True, - json_output: bool = True, - ) -> list[str]: - """Build CLI command for running an agent. - - Args: - agent: Agent name (will be mapped if needed) - prompt: Prompt to send to the agent - session_id: Optional session ID (Claude Code only for creation) - skip_permissions: Whether to skip permission prompts - verbose: Whether to enable verbose output - json_output: Whether to use JSON output format - - Returns: - List of command arguments - """ - mapped_agent = self.get_agent_name(agent) - - if self.platform == "opencode": - cmd = ["opencode", "run"] - cmd.extend(["--agent", mapped_agent]) - - # Note: OpenCode 'run' mode is non-interactive by default - # No equivalent to Claude Code's --dangerously-skip-permissions - # See: https://github.com/anomalyco/opencode/issues/9070 - - if json_output: - cmd.extend(["--format", "json"]) - - if verbose: - cmd.extend(["--log-level", "DEBUG", "--print-logs"]) - - # Note: OpenCode doesn't support --session-id on creation - # Session ID must be extracted from logs after startup - - cmd.append(prompt) - - elif self.platform == "iflow": - cmd = ["iflow", "-p"] - cmd.extend(["-y", "--agent", mapped_agent]) - # iFlow doesn't support --session-id on creation - if verbose: - cmd.append("--verbose") - cmd.append(prompt) - elif self.platform == "codex": - cmd = ["codex", "exec"] - cmd.append(prompt) - elif self.platform == "kiro": - cmd = ["kiro", "run", prompt] - elif self.platform == "gemini": - cmd = ["gemini"] - cmd.append(prompt) - elif self.platform == "antigravity": - raise ValueError( - "Antigravity workflows are UI slash commands; CLI agent run is not supported." - ) - elif self.platform == "qoder": - cmd = ["qodercli", "-p", prompt] - - else: # claude - cmd = ["claude", "-p"] - cmd.extend(["--agent", mapped_agent]) - - if session_id: - cmd.extend(["--session-id", session_id]) - - if skip_permissions: - cmd.append("--dangerously-skip-permissions") - - if json_output: - cmd.extend(["--output-format", "stream-json"]) - - if verbose: - cmd.append("--verbose") - - cmd.append(prompt) - - return cmd - - def build_resume_command(self, session_id: str) -> list[str]: - """Build CLI command for resuming a session. - - Args: - session_id: Session ID to resume (ignored for iFlow) - - Returns: - List of command arguments - """ - if self.platform == "opencode": - return ["opencode", "run", "--session", session_id] - elif self.platform == "iflow": - # iFlow uses -c to continue most recent conversation - # session_id is ignored as iFlow doesn't support session IDs - return ["iflow", "-c"] - elif self.platform == "codex": - return ["codex", "resume", session_id] - elif self.platform == "kiro": - return ["kiro", "resume", session_id] - elif self.platform == "gemini": - return ["gemini", "--resume", session_id] - elif self.platform == "antigravity": - raise ValueError( - "Antigravity workflows are UI slash commands; CLI resume is not supported." - ) - elif self.platform == "qoder": - return ["qodercli", "--resume", session_id] - else: - return ["claude", "--resume", session_id] - - def get_resume_command_str(self, session_id: str, cwd: str | None = None) -> str: - """Get human-readable resume command string. - - Args: - session_id: Session ID to resume - cwd: Optional working directory to cd into - - Returns: - Command string for display - """ - cmd = self.build_resume_command(session_id) - cmd_str = " ".join(cmd) - - if cwd: - return f"cd {cwd} && {cmd_str}" - return cmd_str - - # ========================================================================= - # Platform Detection Helpers - # ========================================================================= - - @property - def is_opencode(self) -> bool: - """Check if platform is OpenCode.""" - return self.platform == "opencode" - - @property - def is_claude(self) -> bool: - """Check if platform is Claude Code.""" - return self.platform == "claude" - - @property - def is_cursor(self) -> bool: - """Check if platform is Cursor.""" - return self.platform == "cursor" - - @property - def is_iflow(self) -> bool: - """Check if platform is iFlow CLI.""" - return self.platform == "iflow" - - @property - def cli_name(self) -> str: - """Get CLI executable name. - - Note: Cursor doesn't have a CLI tool, returns None-like value. - """ - if self.is_opencode: - return "opencode" - elif self.is_cursor: - return "cursor" # Note: Cursor is IDE-only, no CLI - elif self.platform == "iflow": - return "iflow" - elif self.platform == "kiro": - return "kiro" - elif self.platform == "gemini": - return "gemini" - elif self.platform == "antigravity": - return "agy" - elif self.platform == "qoder": - return "qodercli" - else: - return "claude" - - @property - def supports_cli_agents(self) -> bool: - """Check if platform supports running agents via CLI. - - Claude Code, OpenCode, and iFlow support CLI agent execution. - Cursor is IDE-only and doesn't support CLI agents. - """ - return self.platform in ("claude", "opencode", "iflow") - - # ========================================================================= - # Session ID Handling - # ========================================================================= - - @property - def supports_session_id_on_create(self) -> bool: - """Check if platform supports specifying session ID on creation. - - Claude Code: Yes (--session-id) - OpenCode: No (auto-generated, extract from logs) - iFlow: No (no session ID support) - """ - return self.platform == "claude" - - def extract_session_id_from_log(self, log_content: str) -> str | None: - """Extract session ID from log output (OpenCode only). - - OpenCode generates session IDs in format: ses_xxx - - Args: - log_content: Log file content - - Returns: - Session ID if found, None otherwise - """ - import re - - # OpenCode session ID pattern - match = re.search(r"ses_[a-zA-Z0-9]+", log_content) - if match: - return match.group(0) - return None - - -# ============================================================================= -# Factory Function -# ============================================================================= - - -def get_cli_adapter(platform: str = "claude") -> CLIAdapter: - """Get CLI adapter for the specified platform. - - Args: - platform: Platform name ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', or 'qoder') - - Returns: - CLIAdapter instance - - Raises: - ValueError: If platform is not supported - """ - if platform not in ( - "claude", - "opencode", - "cursor", - "iflow", - "codex", - "kilo", - "kiro", - "gemini", - "antigravity", - "qoder", - ): - raise ValueError( - f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', or 'qoder')" - ) - - return CLIAdapter(platform=platform) # type: ignore - - -def detect_platform(project_root: Path) -> Platform: - """Auto-detect platform based on existing config directories. - - Detection order: - 1. TRELLIS_PLATFORM environment variable (if set) - 2. .opencode directory exists → opencode - 3. .iflow directory exists → iflow - 4. .cursor directory exists (without .claude) → cursor - 5. .agents/skills exists and no other platform dirs → codex - 6. .kilocode directory exists → kilo - 7. .kiro/skills exists and no other platform dirs → kiro - 8. .gemini directory exists → gemini - 9. .agent/workflows exists and no other platform dirs → antigravity - 10. .qoder directory exists → qoder - 11. Default → claude - - Args: - project_root: Project root directory - - Returns: - Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', or 'qoder') - """ - import os - - # Check environment variable first - env_platform = os.environ.get("TRELLIS_PLATFORM", "").lower() - if env_platform in ( - "claude", - "opencode", - "cursor", - "iflow", - "codex", - "kilo", - "kiro", - "gemini", - "antigravity", - "qoder", - ): - return env_platform # type: ignore - - # Check for .opencode directory (OpenCode-specific) - # Note: .claude might exist in both platforms during migration - if (project_root / ".opencode").is_dir(): - return "opencode" - - # Check for .iflow directory (iFlow-specific) - # Note: .claude might exist in both platforms during migration - if (project_root / ".iflow").is_dir(): - return "iflow" - - # Check for .cursor directory (Cursor-specific) - # Only detect as cursor if .claude doesn't exist (to avoid confusion) - if (project_root / ".cursor").is_dir() and not (project_root / ".claude").is_dir(): - return "cursor" - - # Check for .gemini directory (Gemini CLI-specific) - if (project_root / ".gemini").is_dir(): - return "gemini" - - # Check for Codex skills directory only when no other platform config exists - other_platform_dirs_codex = ( - ".claude", - ".cursor", - ".iflow", - ".opencode", - ".kilocode", - ".kiro", - ".gemini", - ".agent", - ) - has_other_platform_config = any( - (project_root / directory).is_dir() for directory in other_platform_dirs_codex - ) - if (project_root / ".agents" / "skills").is_dir() and not has_other_platform_config: - return "codex" - - # Check for .kilocode directory (Kilo-specific) - if (project_root / ".kilocode").is_dir(): - return "kilo" - - # Check for Kiro skills directory only when no other platform config exists - other_platform_dirs_kiro = ( - ".claude", - ".cursor", - ".iflow", - ".opencode", - ".agents", - ".kilocode", - ".gemini", - ".agent", - ) - has_other_platform_config = any( - (project_root / directory).is_dir() for directory in other_platform_dirs_kiro - ) - if (project_root / ".kiro" / "skills").is_dir() and not has_other_platform_config: - return "kiro" - - # Check for Antigravity workflow directory only when no other platform config exists - other_platform_dirs_antigravity = ( - ".claude", - ".cursor", - ".iflow", - ".opencode", - ".agents", - ".kilocode", - ".kiro", - ) - has_other_platform_config = any( - (project_root / directory).is_dir() - for directory in other_platform_dirs_antigravity - ) - if ( - project_root / ".agent" / "workflows" - ).is_dir() and not has_other_platform_config: - return "antigravity" - - # Check for .qoder directory (Qoder-specific) - if (project_root / ".qoder").is_dir(): - return "qoder" - - return "claude" - - -def get_cli_adapter_auto(project_root: Path) -> CLIAdapter: - """Get CLI adapter with auto-detected platform. - - Args: - project_root: Project root directory - - Returns: - CLIAdapter instance for detected platform - """ - platform = detect_platform(project_root) - return CLIAdapter(platform=platform) diff --git a/.trellis/scripts/common/config.py b/.trellis/scripts/common/config.py deleted file mode 100755 index 601ab320..00000000 --- a/.trellis/scripts/common/config.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -""" -Trellis configuration reader. - -Reads settings from .trellis/config.yaml with sensible defaults. -""" - -from __future__ import annotations - -from pathlib import Path - -from .paths import DIR_WORKFLOW, get_repo_root -from .worktree import parse_simple_yaml - - -# Defaults -DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal" -DEFAULT_MAX_JOURNAL_LINES = 2000 - -CONFIG_FILE = "config.yaml" - - -def _get_config_path(repo_root: Path | None = None) -> Path: - """Get path to config.yaml.""" - root = repo_root or get_repo_root() - return root / DIR_WORKFLOW / CONFIG_FILE - - -def _load_config(repo_root: Path | None = None) -> dict: - """Load and parse config.yaml. Returns empty dict on any error.""" - config_file = _get_config_path(repo_root) - try: - content = config_file.read_text(encoding="utf-8") - return parse_simple_yaml(content) - except (OSError, IOError): - return {} - - -def get_session_commit_message(repo_root: Path | None = None) -> str: - """Get the commit message for auto-committing session records.""" - config = _load_config(repo_root) - return config.get("session_commit_message", DEFAULT_SESSION_COMMIT_MESSAGE) - - -def get_max_journal_lines(repo_root: Path | None = None) -> int: - """Get the maximum lines per journal file.""" - config = _load_config(repo_root) - value = config.get("max_journal_lines", DEFAULT_MAX_JOURNAL_LINES) - try: - return int(value) - except (ValueError, TypeError): - return DEFAULT_MAX_JOURNAL_LINES - - -def get_hooks(event: str, repo_root: Path | None = None) -> list[str]: - """Get hook commands for a lifecycle event. - - Args: - event: Event name (e.g. "after_create", "after_archive"). - repo_root: Repository root path. - - Returns: - List of shell commands to execute, empty if none configured. - """ - config = _load_config(repo_root) - hooks = config.get("hooks") - if not isinstance(hooks, dict): - return [] - commands = hooks.get(event) - if isinstance(commands, list): - return [str(c) for c in commands] - return [] diff --git a/.trellis/scripts/common/developer.py b/.trellis/scripts/common/developer.py deleted file mode 100755 index 7f3cf0ce..00000000 --- a/.trellis/scripts/common/developer.py +++ /dev/null @@ -1,190 +0,0 @@ -#!/usr/bin/env python3 -""" -Developer management utilities. - -Provides: - init_developer - Initialize developer - ensure_developer - Ensure developer is initialized (exit if not) - show_developer_info - Show developer information -""" - -from __future__ import annotations - -import sys -from datetime import datetime -from pathlib import Path - -from .paths import ( - DIR_WORKFLOW, - DIR_WORKSPACE, - DIR_TASKS, - FILE_DEVELOPER, - FILE_JOURNAL_PREFIX, - get_repo_root, - get_developer, - check_developer, -) - - -# ============================================================================= -# Developer Initialization -# ============================================================================= - -def init_developer(name: str, repo_root: Path | None = None) -> bool: - """Initialize developer. - - Creates: - - .trellis/.developer file with developer info - - .trellis/workspace/<name>/ directory structure - - Initial journal file and index.md - - Args: - name: Developer name. - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - True on success, False on error. - """ - if not name: - print("Error: developer name is required", file=sys.stderr) - return False - - if repo_root is None: - repo_root = get_repo_root() - - dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER - workspace_dir = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / name - - # Create .developer file - initialized_at = datetime.now().isoformat() - try: - dev_file.write_text( - f"name={name}\ninitialized_at={initialized_at}\n", - encoding="utf-8" - ) - except (OSError, IOError) as e: - print(f"Error: Failed to create .developer file: {e}", file=sys.stderr) - return False - - # Create workspace directory structure - try: - workspace_dir.mkdir(parents=True, exist_ok=True) - except (OSError, IOError) as e: - print(f"Error: Failed to create workspace directory: {e}", file=sys.stderr) - return False - - # Create initial journal file - journal_file = workspace_dir / f"{FILE_JOURNAL_PREFIX}1.md" - if not journal_file.exists(): - today = datetime.now().strftime("%Y-%m-%d") - journal_content = f"""# Journal - {name} (Part 1) - -> AI development session journal -> Started: {today} - ---- - -""" - try: - journal_file.write_text(journal_content, encoding="utf-8") - except (OSError, IOError) as e: - print(f"Error: Failed to create journal file: {e}", file=sys.stderr) - return False - - # Create index.md with markers for auto-update - index_file = workspace_dir / "index.md" - if not index_file.exists(): - index_content = f"""# Workspace Index - {name} - -> Journal tracking for AI development sessions. - ---- - -## Current Status - -<!-- @@@auto:current-status --> -- **Active File**: `journal-1.md` -- **Total Sessions**: 0 -- **Last Active**: - -<!-- @@@/auto:current-status --> - ---- - -## Active Documents - -<!-- @@@auto:active-documents --> -| File | Lines | Status | -|------|-------|--------| -| `journal-1.md` | ~0 | Active | -<!-- @@@/auto:active-documents --> - ---- - -## Session History - -<!-- @@@auto:session-history --> -| # | Date | Title | Commits | -|---|------|-------|---------| -<!-- @@@/auto:session-history --> - ---- - -## Notes - -- Sessions are appended to journal files -- New journal file created when current exceeds 2000 lines -- Use `add_session.py` to record sessions -""" - try: - index_file.write_text(index_content, encoding="utf-8") - except (OSError, IOError) as e: - print(f"Error: Failed to create index.md: {e}", file=sys.stderr) - return False - - print(f"Developer initialized: {name}") - print(f" .developer file: {dev_file}") - print(f" Workspace dir: {workspace_dir}") - - return True - - -def ensure_developer(repo_root: Path | None = None) -> None: - """Ensure developer is initialized, exit if not. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - """ - if repo_root is None: - repo_root = get_repo_root() - - if not check_developer(repo_root): - print("Error: Developer not initialized.", file=sys.stderr) - print(f"Run: python3 ./{DIR_WORKFLOW}/scripts/init_developer.py <your-name>", file=sys.stderr) - sys.exit(1) - - -def show_developer_info(repo_root: Path | None = None) -> None: - """Show developer information. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - """ - if repo_root is None: - repo_root = get_repo_root() - - developer = get_developer(repo_root) - - if not developer: - print("Developer: (not initialized)") - else: - print(f"Developer: {developer}") - print(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/") - print(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/") - - -# ============================================================================= -# Main Entry (for testing) -# ============================================================================= - -if __name__ == "__main__": - show_developer_info() diff --git a/.trellis/scripts/common/git_context.py b/.trellis/scripts/common/git_context.py deleted file mode 100755 index 39b9ff50..00000000 --- a/.trellis/scripts/common/git_context.py +++ /dev/null @@ -1,641 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Git and Session Context utilities. - -Provides: - output_json - Output context in JSON format - output_text - Output context in text format -""" - -from __future__ import annotations - -import json -import subprocess -from pathlib import Path - -from .paths import ( - DIR_SCRIPTS, - DIR_SPEC, - DIR_TASKS, - DIR_WORKFLOW, - DIR_WORKSPACE, - FILE_TASK_JSON, - count_lines, - get_active_journal_file, - get_current_task, - get_developer, - get_repo_root, - get_tasks_dir, -) - -# ============================================================================= -# Helper Functions -# ============================================================================= - - -def _run_git_command(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]: - """Run a git command and return (returncode, stdout, stderr). - - Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure - consistent output across all platforms (Windows, macOS, Linux). - """ - try: - # Force git to output UTF-8 for consistent cross-platform behavior - git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args - result = subprocess.run( - git_args, - cwd=cwd, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - return result.returncode, result.stdout, result.stderr - except Exception as e: - return 1, "", str(e) - - -def _read_json_file(path: Path) -> dict | None: - """Read and parse a JSON file.""" - try: - return json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None - - -# ============================================================================= -# JSON Output -# ============================================================================= - - -def get_context_json(repo_root: Path | None = None) -> dict: - """Get context as a dictionary. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Context dictionary. - """ - if repo_root is None: - repo_root = get_repo_root() - - developer = get_developer(repo_root) - tasks_dir = get_tasks_dir(repo_root) - journal_file = get_active_journal_file(repo_root) - - journal_lines = 0 - journal_relative = "" - if journal_file and developer: - journal_lines = count_lines(journal_file) - journal_relative = ( - f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}" - ) - - # Git info - _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) - branch = branch_out.strip() or "unknown" - - _, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root) - git_status_count = len([line for line in status_out.splitlines() if line.strip()]) - is_clean = git_status_count == 0 - - # Recent commits - _, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root) - commits = [] - for line in log_out.splitlines(): - if line.strip(): - parts = line.split(" ", 1) - if len(parts) >= 2: - commits.append({"hash": parts[0], "message": parts[1]}) - elif len(parts) == 1: - commits.append({"hash": parts[0], "message": ""}) - - # Tasks - tasks = [] - if tasks_dir.is_dir(): - for d in tasks_dir.iterdir(): - if d.is_dir() and d.name != "archive": - task_json_path = d / FILE_TASK_JSON - if task_json_path.is_file(): - data = _read_json_file(task_json_path) - if data: - tasks.append( - { - "dir": d.name, - "name": data.get("name") or data.get("id") or "unknown", - "status": data.get("status", "unknown"), - "children": data.get("children", []), - "parent": data.get("parent"), - } - ) - - return { - "developer": developer or "", - "git": { - "branch": branch, - "isClean": is_clean, - "uncommittedChanges": git_status_count, - "recentCommits": commits, - }, - "tasks": { - "active": tasks, - "directory": f"{DIR_WORKFLOW}/{DIR_TASKS}", - }, - "journal": { - "file": journal_relative, - "lines": journal_lines, - "nearLimit": journal_lines > 1800, - }, - } - - -def output_json(repo_root: Path | None = None) -> None: - """Output context in JSON format. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - """ - context = get_context_json(repo_root) - print(json.dumps(context, indent=2, ensure_ascii=False)) - - -# ============================================================================= -# Text Output -# ============================================================================= - - -def get_context_text(repo_root: Path | None = None) -> str: - """Get context as formatted text. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Formatted text output. - """ - if repo_root is None: - repo_root = get_repo_root() - - lines = [] - lines.append("========================================") - lines.append("SESSION CONTEXT") - lines.append("========================================") - lines.append("") - - developer = get_developer(repo_root) - - # Developer section - lines.append("## DEVELOPER") - if not developer: - lines.append( - f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>" - ) - return "\n".join(lines) - - lines.append(f"Name: {developer}") - lines.append("") - - # Git status - lines.append("## GIT STATUS") - _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) - branch = branch_out.strip() or "unknown" - lines.append(f"Branch: {branch}") - - _, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root) - status_lines = [line for line in status_out.splitlines() if line.strip()] - status_count = len(status_lines) - - if status_count == 0: - lines.append("Working directory: Clean") - else: - lines.append(f"Working directory: {status_count} uncommitted change(s)") - lines.append("") - lines.append("Changes:") - _, short_out, _ = _run_git_command(["status", "--short"], cwd=repo_root) - for line in short_out.splitlines()[:10]: - lines.append(line) - lines.append("") - - # Recent commits - lines.append("## RECENT COMMITS") - _, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root) - if log_out.strip(): - for line in log_out.splitlines(): - lines.append(line) - else: - lines.append("(no commits)") - lines.append("") - - # Current task - lines.append("## CURRENT TASK") - current_task = get_current_task(repo_root) - if current_task: - current_task_dir = repo_root / current_task - task_json_path = current_task_dir / FILE_TASK_JSON - lines.append(f"Path: {current_task}") - - if task_json_path.is_file(): - data = _read_json_file(task_json_path) - if data: - t_name = data.get("name") or data.get("id") or "unknown" - t_status = data.get("status", "unknown") - t_created = data.get("createdAt", "unknown") - t_desc = data.get("description", "") - - lines.append(f"Name: {t_name}") - lines.append(f"Status: {t_status}") - lines.append(f"Created: {t_created}") - if t_desc: - lines.append(f"Description: {t_desc}") - - # Check for prd.md - prd_file = current_task_dir / "prd.md" - if prd_file.is_file(): - lines.append("") - lines.append("[!] This task has prd.md - read it for task details") - else: - lines.append("(none)") - lines.append("") - - # Active tasks - lines.append("## ACTIVE TASKS") - tasks_dir = get_tasks_dir(repo_root) - task_count = 0 - - # Collect all task data for hierarchy display - all_task_data: dict[str, dict] = {} - if tasks_dir.is_dir(): - for d in sorted(tasks_dir.iterdir()): - if d.is_dir() and d.name != "archive": - dir_name = d.name - t_json = d / FILE_TASK_JSON - status = "unknown" - assignee = "-" - children: list[str] = [] - parent: str | None = None - - if t_json.is_file(): - data = _read_json_file(t_json) - if data: - status = data.get("status", "unknown") - assignee = data.get("assignee", "-") - children = data.get("children", []) - parent = data.get("parent") - - all_task_data[dir_name] = { - "status": status, - "assignee": assignee, - "children": children, - "parent": parent, - } - - def _children_progress(children_list: list[str]) -> str: - if not children_list: - return "" - done = 0 - for c in children_list: - if c in all_task_data and all_task_data[c]["status"] in ("completed", "done"): - done += 1 - return f" [{done}/{len(children_list)} done]" - - def _print_task_tree(name: str, indent: int = 0) -> None: - nonlocal task_count - info = all_task_data[name] - progress = _children_progress(info["children"]) if info["children"] else "" - prefix = " " * indent - lines.append(f"{prefix}- {name}/ ({info['status']}){progress} @{info['assignee']}") - task_count += 1 - for child in info["children"]: - if child in all_task_data: - _print_task_tree(child, indent + 1) - - for dir_name in sorted(all_task_data.keys()): - if not all_task_data[dir_name]["parent"]: - _print_task_tree(dir_name) - - if task_count == 0: - lines.append("(no active tasks)") - lines.append(f"Total: {task_count} active task(s)") - lines.append("") - - # My tasks - lines.append("## MY TASKS (Assigned to me)") - my_task_count = 0 - - if tasks_dir.is_dir(): - for d in sorted(tasks_dir.iterdir()): - if d.is_dir() and d.name != "archive": - t_json = d / FILE_TASK_JSON - if t_json.is_file(): - data = _read_json_file(t_json) - if data: - assignee = data.get("assignee", "") - status = data.get("status", "planning") - - if assignee == developer and status != "done": - title = data.get("title") or data.get("name") or "unknown" - priority = data.get("priority", "P2") - children_list = data.get("children", []) - progress = _children_progress(children_list) if children_list else "" - lines.append(f"- [{priority}] {title} ({status}){progress}") - my_task_count += 1 - - if my_task_count == 0: - lines.append("(no tasks assigned to you)") - lines.append("") - - # Journal file - lines.append("## JOURNAL FILE") - journal_file = get_active_journal_file(repo_root) - if journal_file: - journal_lines = count_lines(journal_file) - relative = f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}" - lines.append(f"Active file: {relative}") - lines.append(f"Line count: {journal_lines} / 2000") - if journal_lines > 1800: - lines.append("[!] WARNING: Approaching 2000 line limit!") - else: - lines.append("No journal file found") - lines.append("") - - # Paths - lines.append("## PATHS") - lines.append(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/") - lines.append(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/") - lines.append(f"Spec: {DIR_WORKFLOW}/{DIR_SPEC}/") - lines.append("") - - lines.append("========================================") - - return "\n".join(lines) - - -def get_context_record_json(repo_root: Path | None = None) -> dict: - """Get record-mode context as a dictionary. - - Focused on: my active tasks, git status, current task. - """ - if repo_root is None: - repo_root = get_repo_root() - - developer = get_developer(repo_root) - tasks_dir = get_tasks_dir(repo_root) - - # Git info - _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) - branch = branch_out.strip() or "unknown" - - _, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root) - git_status_count = len([line for line in status_out.splitlines() if line.strip()]) - - _, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root) - commits = [] - for line in log_out.splitlines(): - if line.strip(): - parts = line.split(" ", 1) - if len(parts) >= 2: - commits.append({"hash": parts[0], "message": parts[1]}) - - # My tasks - my_tasks = [] - all_task_statuses: dict[str, str] = {} - if tasks_dir.is_dir(): - for d in sorted(tasks_dir.iterdir()): - if d.is_dir() and d.name != "archive": - t_json = d / FILE_TASK_JSON - if t_json.is_file(): - data = _read_json_file(t_json) - if data: - all_task_statuses[d.name] = data.get("status", "unknown") - - if tasks_dir.is_dir(): - for d in sorted(tasks_dir.iterdir()): - if d.is_dir() and d.name != "archive": - t_json = d / FILE_TASK_JSON - if t_json.is_file(): - data = _read_json_file(t_json) - if data and data.get("assignee") == developer: - children_list = data.get("children", []) - done = sum(1 for c in children_list if all_task_statuses.get(c) in ("completed", "done")) - my_tasks.append({ - "dir": d.name, - "title": data.get("title") or data.get("name") or "unknown", - "status": data.get("status", "unknown"), - "priority": data.get("priority", "P2"), - "children": children_list, - "childrenDone": done, - "parent": data.get("parent"), - "meta": data.get("meta", {}), - }) - - # Current task - current_task_info = None - current_task = get_current_task(repo_root) - if current_task: - task_json_path = (repo_root / current_task) / FILE_TASK_JSON - if task_json_path.is_file(): - data = _read_json_file(task_json_path) - if data: - current_task_info = { - "path": current_task, - "name": data.get("name") or data.get("id") or "unknown", - "status": data.get("status", "unknown"), - } - - return { - "developer": developer or "", - "git": { - "branch": branch, - "isClean": git_status_count == 0, - "uncommittedChanges": git_status_count, - "recentCommits": commits, - }, - "myTasks": my_tasks, - "currentTask": current_task_info, - } - - -def get_context_text_record(repo_root: Path | None = None) -> str: - """Get context as formatted text for record-session mode. - - Focused output: MY ACTIVE TASKS first (with [!!!] emphasis), - then GIT STATUS, RECENT COMMITS, CURRENT TASK. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Formatted text output for record-session. - """ - if repo_root is None: - repo_root = get_repo_root() - - lines: list[str] = [] - lines.append("========================================") - lines.append("SESSION CONTEXT (RECORD MODE)") - lines.append("========================================") - lines.append("") - - developer = get_developer(repo_root) - if not developer: - lines.append( - f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>" - ) - return "\n".join(lines) - - # MY ACTIVE TASKS — first and prominent - lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})") - lines.append("[!] Review whether any should be archived before recording this session.") - lines.append("") - - tasks_dir = get_tasks_dir(repo_root) - my_task_count = 0 - - # Collect task data for children progress - all_task_statuses: dict[str, str] = {} - if tasks_dir.is_dir(): - for d in sorted(tasks_dir.iterdir()): - if d.is_dir() and d.name != "archive": - t_json = d / FILE_TASK_JSON - if t_json.is_file(): - data = _read_json_file(t_json) - if data: - all_task_statuses[d.name] = data.get("status", "unknown") - - def _record_children_progress(children_list: list[str]) -> str: - if not children_list: - return "" - done = 0 - for c in children_list: - if all_task_statuses.get(c) in ("completed", "done"): - done += 1 - return f" [{done}/{len(children_list)} done]" - - if tasks_dir.is_dir(): - for d in sorted(tasks_dir.iterdir()): - if d.is_dir() and d.name != "archive": - t_json = d / FILE_TASK_JSON - if t_json.is_file(): - data = _read_json_file(t_json) - if data: - assignee = data.get("assignee", "") - status = data.get("status", "planning") - - if assignee == developer: - title = data.get("title") or data.get("name") or "unknown" - priority = data.get("priority", "P2") - children_list = data.get("children", []) - progress = _record_children_progress(children_list) if children_list else "" - lines.append(f"- [{priority}] {title} ({status}){progress} — {d.name}") - my_task_count += 1 - - if my_task_count == 0: - lines.append("(no active tasks assigned to you)") - lines.append("") - - # GIT STATUS - lines.append("## GIT STATUS") - _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) - branch = branch_out.strip() or "unknown" - lines.append(f"Branch: {branch}") - - _, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root) - status_lines = [line for line in status_out.splitlines() if line.strip()] - status_count = len(status_lines) - - if status_count == 0: - lines.append("Working directory: Clean") - else: - lines.append(f"Working directory: {status_count} uncommitted change(s)") - lines.append("") - lines.append("Changes:") - _, short_out, _ = _run_git_command(["status", "--short"], cwd=repo_root) - for line in short_out.splitlines()[:10]: - lines.append(line) - lines.append("") - - # RECENT COMMITS - lines.append("## RECENT COMMITS") - _, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root) - if log_out.strip(): - for line in log_out.splitlines(): - lines.append(line) - else: - lines.append("(no commits)") - lines.append("") - - # CURRENT TASK - lines.append("## CURRENT TASK") - current_task = get_current_task(repo_root) - if current_task: - current_task_dir = repo_root / current_task - task_json_path = current_task_dir / FILE_TASK_JSON - lines.append(f"Path: {current_task}") - - if task_json_path.is_file(): - data = _read_json_file(task_json_path) - if data: - t_name = data.get("name") or data.get("id") or "unknown" - t_status = data.get("status", "unknown") - lines.append(f"Name: {t_name}") - lines.append(f"Status: {t_status}") - else: - lines.append("(none)") - lines.append("") - - lines.append("========================================") - - return "\n".join(lines) - - -def output_text(repo_root: Path | None = None) -> None: - """Output context in text format. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - """ - print(get_context_text(repo_root)) - - -# ============================================================================= -# Main Entry -# ============================================================================= - - -def main() -> None: - """CLI entry point.""" - import argparse - - parser = argparse.ArgumentParser(description="Get Session Context for AI Agent") - parser.add_argument( - "--json", - "-j", - action="store_true", - help="Output in JSON format (works with any --mode)", - ) - parser.add_argument( - "--mode", - "-m", - choices=["default", "record"], - default="default", - help="Output mode: default (full context) or record (for record-session)", - ) - - args = parser.parse_args() - - if args.mode == "record": - if args.json: - print(json.dumps(get_context_record_json(), indent=2, ensure_ascii=False)) - else: - print(get_context_text_record()) - else: - if args.json: - output_json() - else: - output_text() - - -if __name__ == "__main__": - main() diff --git a/.trellis/scripts/common/paths.py b/.trellis/scripts/common/paths.py deleted file mode 100755 index dcbb66b4..00000000 --- a/.trellis/scripts/common/paths.py +++ /dev/null @@ -1,347 +0,0 @@ -#!/usr/bin/env python3 -""" -Common path utilities for Trellis workflow. - -Provides: - get_repo_root - Get repository root directory - get_developer - Get developer name - get_workspace_dir - Get developer workspace directory - get_tasks_dir - Get tasks directory - get_active_journal_file - Get current journal file -""" - -from __future__ import annotations - -import re -from datetime import datetime -from pathlib import Path - - -# ============================================================================= -# Path Constants (change here to rename directories) -# ============================================================================= - -# Directory names -DIR_WORKFLOW = ".trellis" -DIR_WORKSPACE = "workspace" -DIR_TASKS = "tasks" -DIR_ARCHIVE = "archive" -DIR_SPEC = "spec" -DIR_SCRIPTS = "scripts" - -# File names -FILE_DEVELOPER = ".developer" -FILE_CURRENT_TASK = ".current-task" -FILE_TASK_JSON = "task.json" -FILE_JOURNAL_PREFIX = "journal-" - - -# ============================================================================= -# Repository Root -# ============================================================================= - -def get_repo_root(start_path: Path | None = None) -> Path: - """Find the nearest directory containing .trellis/ folder. - - This handles nested git repos correctly (e.g., test project inside another repo). - - Args: - start_path: Starting directory to search from. Defaults to current directory. - - Returns: - Path to repository root, or current directory if no .trellis/ found. - """ - current = (start_path or Path.cwd()).resolve() - - while current != current.parent: - if (current / DIR_WORKFLOW).is_dir(): - return current - current = current.parent - - # Fallback to current directory if no .trellis/ found - return Path.cwd().resolve() - - -# ============================================================================= -# Developer -# ============================================================================= - -def get_developer(repo_root: Path | None = None) -> str | None: - """Get developer name from .developer file. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Developer name or None if not initialized. - """ - if repo_root is None: - repo_root = get_repo_root() - - dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER - - if not dev_file.is_file(): - return None - - try: - content = dev_file.read_text(encoding="utf-8") - for line in content.splitlines(): - if line.startswith("name="): - return line.split("=", 1)[1].strip() - except (OSError, IOError): - pass - - return None - - -def check_developer(repo_root: Path | None = None) -> bool: - """Check if developer is initialized. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - True if developer is initialized. - """ - return get_developer(repo_root) is not None - - -# ============================================================================= -# Tasks Directory -# ============================================================================= - -def get_tasks_dir(repo_root: Path | None = None) -> Path: - """Get tasks directory path. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Path to tasks directory. - """ - if repo_root is None: - repo_root = get_repo_root() - return repo_root / DIR_WORKFLOW / DIR_TASKS - - -# ============================================================================= -# Workspace Directory -# ============================================================================= - -def get_workspace_dir(repo_root: Path | None = None) -> Path | None: - """Get developer workspace directory. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Path to workspace directory or None if developer not set. - """ - if repo_root is None: - repo_root = get_repo_root() - - developer = get_developer(repo_root) - if developer: - return repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer - return None - - -# ============================================================================= -# Journal File -# ============================================================================= - -def get_active_journal_file(repo_root: Path | None = None) -> Path | None: - """Get the current active journal file. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Path to active journal file or None if not found. - """ - if repo_root is None: - repo_root = get_repo_root() - - workspace_dir = get_workspace_dir(repo_root) - if workspace_dir is None or not workspace_dir.is_dir(): - return None - - latest: Path | None = None - highest = 0 - - for f in workspace_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"): - if not f.is_file(): - continue - - # Extract number from filename - name = f.stem # e.g., "journal-1" - match = re.search(r"(\d+)$", name) - if match: - num = int(match.group(1)) - if num > highest: - highest = num - latest = f - - return latest - - -def count_lines(file_path: Path) -> int: - """Count lines in a file. - - Args: - file_path: Path to file. - - Returns: - Number of lines, or 0 if file doesn't exist. - """ - if not file_path.is_file(): - return 0 - - try: - return len(file_path.read_text(encoding="utf-8").splitlines()) - except (OSError, IOError): - return 0 - - -# ============================================================================= -# Current Task Management -# ============================================================================= - -def _get_current_task_file(repo_root: Path | None = None) -> Path: - """Get .current-task file path. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Path to .current-task file. - """ - if repo_root is None: - repo_root = get_repo_root() - return repo_root / DIR_WORKFLOW / FILE_CURRENT_TASK - - -def get_current_task(repo_root: Path | None = None) -> str | None: - """Get current task directory path (relative to repo_root). - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Relative path to current task directory or None. - """ - current_file = _get_current_task_file(repo_root) - - if not current_file.is_file(): - return None - - try: - return current_file.read_text(encoding="utf-8").strip() - except (OSError, IOError): - return None - - -def get_current_task_abs(repo_root: Path | None = None) -> Path | None: - """Get current task directory absolute path. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Absolute path to current task directory or None. - """ - if repo_root is None: - repo_root = get_repo_root() - - relative = get_current_task(repo_root) - if relative: - return repo_root / relative - return None - - -def set_current_task(task_path: str, repo_root: Path | None = None) -> bool: - """Set current task. - - Args: - task_path: Task directory path (relative to repo_root). - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - True on success, False on error. - """ - if repo_root is None: - repo_root = get_repo_root() - - if not task_path: - return False - - # Verify task directory exists - full_path = repo_root / task_path - if not full_path.is_dir(): - return False - - current_file = _get_current_task_file(repo_root) - - try: - current_file.write_text(task_path, encoding="utf-8") - return True - except (OSError, IOError): - return False - - -def clear_current_task(repo_root: Path | None = None) -> bool: - """Clear current task. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - True on success. - """ - current_file = _get_current_task_file(repo_root) - - try: - if current_file.is_file(): - current_file.unlink() - return True - except (OSError, IOError): - return False - - -def has_current_task(repo_root: Path | None = None) -> bool: - """Check if has current task. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - True if current task is set. - """ - return get_current_task(repo_root) is not None - - -# ============================================================================= -# Task ID Generation -# ============================================================================= - -def generate_task_date_prefix() -> str: - """Generate task ID based on date (MM-DD format). - - Returns: - Date prefix string (e.g., "01-21"). - """ - return datetime.now().strftime("%m-%d") - - -# ============================================================================= -# Main Entry (for testing) -# ============================================================================= - -if __name__ == "__main__": - repo = get_repo_root() - print(f"Repository root: {repo}") - print(f"Developer: {get_developer(repo)}") - print(f"Tasks dir: {get_tasks_dir(repo)}") - print(f"Workspace dir: {get_workspace_dir(repo)}") - print(f"Journal file: {get_active_journal_file(repo)}") - print(f"Current task: {get_current_task(repo)}") diff --git a/.trellis/scripts/common/phase.py b/.trellis/scripts/common/phase.py deleted file mode 100755 index c3a80394..00000000 --- a/.trellis/scripts/common/phase.py +++ /dev/null @@ -1,253 +0,0 @@ -#!/usr/bin/env python3 -""" -Phase Management Utilities. - -Centralized phase tracking for multi-agent pipeline. - -Provides: - get_current_phase - Returns current phase number - get_total_phases - Returns total phase count - get_phase_action - Returns action name for phase - get_phase_info - Returns "N/M (action)" format - set_phase - Sets current_phase - advance_phase - Advances to next phase - get_phase_for_action - Returns phase number for action - map_subagent_to_action - Map subagent type to action name - is_phase_completed - Check if phase is completed - is_current_action - Check if at specific action -""" - -from __future__ import annotations - -import json -from pathlib import Path - - -def _read_json_file(path: Path) -> dict | None: - """Read and parse a JSON file.""" - try: - return json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None - - -def _write_json_file(path: Path, data: dict) -> bool: - """Write dict to JSON file.""" - try: - path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") - return True - except (OSError, IOError): - return False - - -# ============================================================================= -# Phase Functions -# ============================================================================= - -def get_current_phase(task_json: Path) -> int: - """Get current phase number. - - Args: - task_json: Path to task.json file. - - Returns: - Current phase number, or 0 if not found. - """ - data = _read_json_file(task_json) - if not data: - return 0 - return data.get("current_phase", 0) or 0 - - -def get_total_phases(task_json: Path) -> int: - """Get total number of phases. - - Args: - task_json: Path to task.json file. - - Returns: - Total phase count, or 0 if not found. - """ - data = _read_json_file(task_json) - if not data: - return 0 - - next_action = data.get("next_action", []) - if isinstance(next_action, list): - return len(next_action) - return 0 - - -def get_phase_action(task_json: Path, phase: int) -> str: - """Get action name for a specific phase. - - Args: - task_json: Path to task.json file. - phase: Phase number. - - Returns: - Action name, or "unknown" if not found. - """ - data = _read_json_file(task_json) - if not data: - return "unknown" - - next_action = data.get("next_action", []) - if isinstance(next_action, list): - for item in next_action: - if isinstance(item, dict) and item.get("phase") == phase: - return item.get("action", "unknown") - return "unknown" - - -def get_phase_info(task_json: Path) -> str: - """Get formatted phase info: "N/M (action)". - - Args: - task_json: Path to task.json file. - - Returns: - Formatted string like "1/4 (implement)". - """ - data = _read_json_file(task_json) - if not data: - return "N/A" - - current_phase = data.get("current_phase", 0) or 0 - total_phases = get_total_phases(task_json) - action_name = get_phase_action(task_json, current_phase) - - if current_phase == 0 or current_phase is None: - return f"0/{total_phases} (pending)" - else: - return f"{current_phase}/{total_phases} ({action_name})" - - -def set_phase(task_json: Path, phase: int) -> bool: - """Set current phase to a specific value. - - Args: - task_json: Path to task.json file. - phase: Phase number to set. - - Returns: - True on success, False on error. - """ - data = _read_json_file(task_json) - if not data: - return False - - data["current_phase"] = phase - return _write_json_file(task_json, data) - - -def advance_phase(task_json: Path) -> bool: - """Advance to next phase. - - Args: - task_json: Path to task.json file. - - Returns: - True on success, False on error or at final phase. - """ - data = _read_json_file(task_json) - if not data: - return False - - current = data.get("current_phase", 0) or 0 - total = get_total_phases(task_json) - next_phase = current + 1 - - if next_phase > total: - return False # Already at final phase - - data["current_phase"] = next_phase - return _write_json_file(task_json, data) - - -def get_phase_for_action(task_json: Path, action: str) -> int: - """Get phase number for a specific action name. - - Args: - task_json: Path to task.json file. - action: Action name. - - Returns: - Phase number, or 0 if not found. - """ - data = _read_json_file(task_json) - if not data: - return 0 - - next_action = data.get("next_action", []) - if isinstance(next_action, list): - for item in next_action: - if isinstance(item, dict) and item.get("action") == action: - return item.get("phase", 0) - return 0 - - -def map_subagent_to_action(subagent_type: str) -> str: - """Map subagent type to action name. - - Used by hooks to determine which action a subagent corresponds to. - - Args: - subagent_type: Subagent type string. - - Returns: - Corresponding action name. - """ - mapping = { - "implement": "implement", - "check": "check", - "debug": "debug", - "research": "research", - } - return mapping.get(subagent_type, subagent_type) - - -def is_phase_completed(task_json: Path, phase: int) -> bool: - """Check if a phase is completed (current_phase > phase). - - Args: - task_json: Path to task.json file. - phase: Phase number to check. - - Returns: - True if phase is completed. - """ - current = get_current_phase(task_json) - return current > phase - - -def is_current_action(task_json: Path, action: str) -> bool: - """Check if we're at a specific action. - - Args: - task_json: Path to task.json file. - action: Action name to check. - - Returns: - True if current phase matches the action. - """ - current = get_current_phase(task_json) - action_phase = get_phase_for_action(task_json, action) - return current == action_phase - - -# ============================================================================= -# Main Entry (for testing) -# ============================================================================= - -if __name__ == "__main__": - import sys - - if len(sys.argv) > 1: - path = Path(sys.argv[1]) - print(f"Task JSON: {path}") - print(f"Phase info: {get_phase_info(path)}") - print(f"Current phase: {get_current_phase(path)}") - print(f"Total phases: {get_total_phases(path)}") - else: - print("Usage: python3 phase.py <task.json>") diff --git a/.trellis/scripts/common/registry.py b/.trellis/scripts/common/registry.py deleted file mode 100755 index 7f2bc6f3..00000000 --- a/.trellis/scripts/common/registry.py +++ /dev/null @@ -1,366 +0,0 @@ -#!/usr/bin/env python3 -""" -Registry utility functions for multi-agent pipeline. - -Provides: - registry_get_file - Get registry file path - registry_get_agent_by_id - Find agent by ID - registry_get_agent_by_worktree - Find agent by worktree path - registry_get_task_dir - Get task dir for a worktree - registry_remove_by_id - Remove agent by ID - registry_remove_by_worktree - Remove agent by worktree path - registry_add_agent - Add agent to registry - registry_search_agent - Search agent by ID or task_dir - registry_list_agents - List all agents -""" - -from __future__ import annotations - -import json -from datetime import datetime -from pathlib import Path - -from .paths import get_repo_root -from .worktree import get_agents_dir - - -def _read_json_file(path: Path) -> dict | None: - """Read and parse a JSON file.""" - try: - return json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None - - -def _write_json_file(path: Path, data: dict) -> bool: - """Write dict to JSON file.""" - try: - path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") - return True - except (OSError, IOError): - return False - - -# ============================================================================= -# Registry File Access -# ============================================================================= - -def registry_get_file(repo_root: Path | None = None) -> Path | None: - """Get registry file path. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Path to registry.json, or None if agents dir not found. - """ - if repo_root is None: - repo_root = get_repo_root() - - agents_dir = get_agents_dir(repo_root) - if agents_dir: - return agents_dir / "registry.json" - return None - - -def _ensure_registry(repo_root: Path | None = None) -> Path | None: - """Ensure registry file exists with valid structure. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Path to registry file, or None if cannot create. - """ - if repo_root is None: - repo_root = get_repo_root() - - registry_file = registry_get_file(repo_root) - if not registry_file: - return None - - agents_dir = registry_file.parent - - try: - agents_dir.mkdir(parents=True, exist_ok=True) - - if not registry_file.exists(): - _write_json_file(registry_file, {"agents": []}) - - return registry_file - except (OSError, IOError): - return None - - -# ============================================================================= -# Agent Lookup -# ============================================================================= - -def registry_get_agent_by_id( - agent_id: str, - repo_root: Path | None = None -) -> dict | None: - """Get agent by ID. - - Args: - agent_id: Agent ID. - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Agent dict, or None if not found. - """ - if repo_root is None: - repo_root = get_repo_root() - - registry_file = registry_get_file(repo_root) - if not registry_file or not registry_file.is_file(): - return None - - data = _read_json_file(registry_file) - if not data: - return None - - for agent in data.get("agents", []): - if agent.get("id") == agent_id: - return agent - - return None - - -def registry_get_agent_by_worktree( - worktree_path: str, - repo_root: Path | None = None -) -> dict | None: - """Get agent by worktree path. - - Args: - worktree_path: Worktree path. - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Agent dict, or None if not found. - """ - if repo_root is None: - repo_root = get_repo_root() - - registry_file = registry_get_file(repo_root) - if not registry_file or not registry_file.is_file(): - return None - - data = _read_json_file(registry_file) - if not data: - return None - - for agent in data.get("agents", []): - if agent.get("worktree_path") == worktree_path: - return agent - - return None - - -def registry_search_agent( - search: str, - repo_root: Path | None = None -) -> dict | None: - """Search agent by ID or task_dir containing search term. - - Args: - search: Search term. - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - First matching agent dict, or None if not found. - """ - if repo_root is None: - repo_root = get_repo_root() - - registry_file = registry_get_file(repo_root) - if not registry_file or not registry_file.is_file(): - return None - - data = _read_json_file(registry_file) - if not data: - return None - - for agent in data.get("agents", []): - # Exact ID match - if agent.get("id") == search: - return agent - # Partial match on task_dir - task_dir = agent.get("task_dir", "") - if search in task_dir: - return agent - - return None - - -def registry_get_task_dir( - worktree_path: str, - repo_root: Path | None = None -) -> str | None: - """Get task directory for a worktree. - - Args: - worktree_path: Worktree path. - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Task directory path, or None if not found. - """ - agent = registry_get_agent_by_worktree(worktree_path, repo_root) - if agent: - return agent.get("task_dir") - return None - - -# ============================================================================= -# Agent Modification -# ============================================================================= - -def registry_remove_by_id(agent_id: str, repo_root: Path | None = None) -> bool: - """Remove agent by ID. - - Args: - agent_id: Agent ID. - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - True on success. - """ - if repo_root is None: - repo_root = get_repo_root() - - registry_file = registry_get_file(repo_root) - if not registry_file or not registry_file.is_file(): - return True # Nothing to remove - - data = _read_json_file(registry_file) - if not data: - return True - - agents = data.get("agents", []) - data["agents"] = [a for a in agents if a.get("id") != agent_id] - - return _write_json_file(registry_file, data) - - -def registry_remove_by_worktree( - worktree_path: str, - repo_root: Path | None = None -) -> bool: - """Remove agent by worktree path. - - Args: - worktree_path: Worktree path. - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - True on success. - """ - if repo_root is None: - repo_root = get_repo_root() - - registry_file = registry_get_file(repo_root) - if not registry_file or not registry_file.is_file(): - return True # Nothing to remove - - data = _read_json_file(registry_file) - if not data: - return True - - agents = data.get("agents", []) - data["agents"] = [a for a in agents if a.get("worktree_path") != worktree_path] - - return _write_json_file(registry_file, data) - - -def registry_add_agent( - agent_id: str, - worktree_path: str, - pid: int, - task_dir: str, - repo_root: Path | None = None, - platform: str = "claude", -) -> bool: - """Add agent to registry (replaces if same ID exists). - - Args: - agent_id: Agent ID. - worktree_path: Worktree path. - pid: Process ID. - task_dir: Task directory path. - repo_root: Repository root path. Defaults to auto-detected. - platform: Platform used (e.g., 'claude', 'opencode', 'codex', 'kiro', 'antigravity'). Defaults to 'claude'. - - Returns: - True on success. - """ - if repo_root is None: - repo_root = get_repo_root() - - registry_file = _ensure_registry(repo_root) - if not registry_file: - return False - - data = _read_json_file(registry_file) - if not data: - data = {"agents": []} - - # Remove existing agent with same ID - agents = data.get("agents", []) - agents = [a for a in agents if a.get("id") != agent_id] - - # Create new agent record - started_at = datetime.now().isoformat() - new_agent = { - "id": agent_id, - "worktree_path": worktree_path, - "pid": pid, - "started_at": started_at, - "task_dir": task_dir, - "platform": platform, - } - - agents.append(new_agent) - data["agents"] = agents - - return _write_json_file(registry_file, data) - - -def registry_list_agents(repo_root: Path | None = None) -> list[dict]: - """List all agents. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - List of agent dicts. - """ - if repo_root is None: - repo_root = get_repo_root() - - registry_file = registry_get_file(repo_root) - if not registry_file or not registry_file.is_file(): - return [] - - data = _read_json_file(registry_file) - if not data: - return [] - - return data.get("agents", []) - - -# ============================================================================= -# Main Entry (for testing) -# ============================================================================= - -if __name__ == "__main__": - import json as json_mod - - repo = get_repo_root() - print(f"Repository root: {repo}") - print(f"Registry file: {registry_get_file(repo)}") - print() - print("Agents:") - agents = registry_list_agents(repo) - print(json_mod.dumps(agents, indent=2)) diff --git a/.trellis/scripts/common/task_queue.py b/.trellis/scripts/common/task_queue.py deleted file mode 100755 index 70378a1d..00000000 --- a/.trellis/scripts/common/task_queue.py +++ /dev/null @@ -1,259 +0,0 @@ -#!/usr/bin/env python3 -""" -Task queue utility functions. - -Provides: - list_tasks_by_status - List tasks by status - list_pending_tasks - List tasks with pending status - list_tasks_by_assignee - List tasks by assignee - list_my_tasks - List tasks assigned to current developer - get_task_stats - Get P0/P1/P2/P3 counts -""" - -from __future__ import annotations - -import json -from pathlib import Path - -from .paths import ( - FILE_TASK_JSON, - get_repo_root, - get_developer, - get_tasks_dir, -) - - -def _read_json_file(path: Path) -> dict | None: - """Read and parse a JSON file.""" - try: - return json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None - - -# ============================================================================= -# Public Functions -# ============================================================================= - -def list_tasks_by_status( - filter_status: str | None = None, - repo_root: Path | None = None -) -> list[dict]: - """List tasks by status. - - Args: - filter_status: Optional status filter. - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - List of task info dicts with keys: priority, id, title, status, assignee. - """ - if repo_root is None: - repo_root = get_repo_root() - - tasks_dir = get_tasks_dir(repo_root) - results = [] - - if not tasks_dir.is_dir(): - return results - - for d in tasks_dir.iterdir(): - if not d.is_dir() or d.name == "archive": - continue - - task_json = d / FILE_TASK_JSON - if not task_json.is_file(): - continue - - data = _read_json_file(task_json) - if not data: - continue - - task_id = data.get("id", "") - title = data.get("title") or data.get("name", "") - priority = data.get("priority", "P2") - status = data.get("status", "planning") - assignee = data.get("assignee", "-") - - # Apply filter - if filter_status and status != filter_status: - continue - - results.append({ - "priority": priority, - "id": task_id, - "title": title, - "status": status, - "assignee": assignee, - "dir": d.name, - "children": data.get("children", []), - "parent": data.get("parent"), - }) - - return results - - -def list_pending_tasks(repo_root: Path | None = None) -> list[dict]: - """List pending tasks. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - List of task info dicts. - """ - return list_tasks_by_status("planning", repo_root) - - -def list_tasks_by_assignee( - assignee: str, - filter_status: str | None = None, - repo_root: Path | None = None -) -> list[dict]: - """List tasks assigned to a specific developer. - - Args: - assignee: Developer name. - filter_status: Optional status filter. - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - List of task info dicts. - """ - if repo_root is None: - repo_root = get_repo_root() - - tasks_dir = get_tasks_dir(repo_root) - results = [] - - if not tasks_dir.is_dir(): - return results - - for d in tasks_dir.iterdir(): - if not d.is_dir() or d.name == "archive": - continue - - task_json = d / FILE_TASK_JSON - if not task_json.is_file(): - continue - - data = _read_json_file(task_json) - if not data: - continue - - task_assignee = data.get("assignee", "-") - - # Apply assignee filter - if task_assignee != assignee: - continue - - task_id = data.get("id", "") - title = data.get("title") or data.get("name", "") - priority = data.get("priority", "P2") - status = data.get("status", "planning") - - # Apply status filter - if filter_status and status != filter_status: - continue - - results.append({ - "priority": priority, - "id": task_id, - "title": title, - "status": status, - "assignee": task_assignee, - "dir": d.name, - "children": data.get("children", []), - "parent": data.get("parent"), - }) - - return results - - -def list_my_tasks( - filter_status: str | None = None, - repo_root: Path | None = None -) -> list[dict]: - """List tasks assigned to current developer. - - Args: - filter_status: Optional status filter. - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - List of task info dicts. - - Raises: - ValueError: If developer not set. - """ - if repo_root is None: - repo_root = get_repo_root() - - developer = get_developer(repo_root) - if not developer: - raise ValueError("Developer not set") - - return list_tasks_by_assignee(developer, filter_status, repo_root) - - -def get_task_stats(repo_root: Path | None = None) -> dict[str, int]: - """Get task statistics. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Dict with keys: P0, P1, P2, P3, Total. - """ - if repo_root is None: - repo_root = get_repo_root() - - tasks_dir = get_tasks_dir(repo_root) - stats = {"P0": 0, "P1": 0, "P2": 0, "P3": 0, "Total": 0} - - if not tasks_dir.is_dir(): - return stats - - for d in tasks_dir.iterdir(): - if not d.is_dir() or d.name == "archive": - continue - - task_json = d / FILE_TASK_JSON - if not task_json.is_file(): - continue - - data = _read_json_file(task_json) - if not data: - continue - - priority = data.get("priority", "P2") - if priority in stats: - stats[priority] += 1 - stats["Total"] += 1 - - return stats - - -def format_task_stats(stats: dict[str, int]) -> str: - """Format task stats as string. - - Args: - stats: Stats dict from get_task_stats. - - Returns: - Formatted string like "P0:0 P1:1 P2:2 P3:0 Total:3". - """ - return f"P0:{stats['P0']} P1:{stats['P1']} P2:{stats['P2']} P3:{stats['P3']} Total:{stats['Total']}" - - -# ============================================================================= -# Main Entry (for testing) -# ============================================================================= - -if __name__ == "__main__": - stats = get_task_stats() - print(format_task_stats(stats)) - print() - print("Pending tasks:") - for task in list_pending_tasks(): - print(f" {task['priority']}|{task['id']}|{task['title']}|{task['status']}|{task['assignee']}") diff --git a/.trellis/scripts/common/task_utils.py b/.trellis/scripts/common/task_utils.py deleted file mode 100755 index 84df2fab..00000000 --- a/.trellis/scripts/common/task_utils.py +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env python3 -""" -Task utility functions. - -Provides: - is_safe_task_path - Validate task path is safe to operate on - find_task_by_name - Find task directory by name - archive_task_dir - Archive task to monthly directory -""" - -from __future__ import annotations - -import shutil -import sys -from datetime import datetime -from pathlib import Path - -from .paths import get_repo_root - - -# ============================================================================= -# Path Safety -# ============================================================================= - -def is_safe_task_path(task_path: str, repo_root: Path | None = None) -> bool: - """Check if a relative task path is safe to operate on. - - Args: - task_path: Task path (relative to repo_root). - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - True if safe, False if dangerous. - """ - if repo_root is None: - repo_root = get_repo_root() - - # Check empty or null - if not task_path or task_path == "null": - print("Error: empty or null task path", file=sys.stderr) - return False - - # Reject absolute paths - if task_path.startswith("/"): - print(f"Error: absolute path not allowed: {task_path}", file=sys.stderr) - return False - - # Reject ".", "..", paths starting with "./" or "../", or containing ".." - if task_path in (".", "..") or task_path.startswith("./") or task_path.startswith("../") or ".." in task_path: - print(f"Error: path traversal not allowed: {task_path}", file=sys.stderr) - return False - - # Final check: ensure resolved path is not the repo root - abs_path = repo_root / task_path - if abs_path.exists(): - try: - resolved = abs_path.resolve() - root_resolved = repo_root.resolve() - if resolved == root_resolved: - print(f"Error: path resolves to repo root: {task_path}", file=sys.stderr) - return False - except (OSError, IOError): - pass - - return True - - -# ============================================================================= -# Task Lookup -# ============================================================================= - -def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None: - """Find task directory by name (exact or suffix match). - - Args: - task_name: Task name to find. - tasks_dir: Tasks directory path. - - Returns: - Absolute path to task directory, or None if not found. - """ - if not task_name or not tasks_dir or not tasks_dir.is_dir(): - return None - - # Try exact match first - exact_match = tasks_dir / task_name - if exact_match.is_dir(): - return exact_match - - # Try suffix match (e.g., "my-task" matches "01-21-my-task") - for d in tasks_dir.iterdir(): - if d.is_dir() and d.name.endswith(f"-{task_name}"): - return d - - return None - - -# ============================================================================= -# Archive Operations -# ============================================================================= - -def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path | None: - """Archive a task directory to archive/{YYYY-MM}/. - - Args: - task_dir_abs: Absolute path to task directory. - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Path to archived directory, or None on error. - """ - if not task_dir_abs.is_dir(): - print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr) - return None - - # Get tasks directory (parent of the task) - tasks_dir = task_dir_abs.parent - archive_dir = tasks_dir / "archive" - year_month = datetime.now().strftime("%Y-%m") - month_dir = archive_dir / year_month - - # Create archive directory - try: - month_dir.mkdir(parents=True, exist_ok=True) - except (OSError, IOError) as e: - print(f"Error: Failed to create archive directory: {e}", file=sys.stderr) - return None - - # Move task to archive - task_name = task_dir_abs.name - dest = month_dir / task_name - - try: - shutil.move(str(task_dir_abs), str(dest)) - except (OSError, IOError, shutil.Error) as e: - print(f"Error: Failed to move task to archive: {e}", file=sys.stderr) - return None - - return dest - - -def archive_task_complete( - task_dir_abs: Path, - repo_root: Path | None = None -) -> dict[str, str]: - """Complete archive workflow: archive directory. - - Args: - task_dir_abs: Absolute path to task directory. - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Dict with archive result info. - """ - if not task_dir_abs.is_dir(): - print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr) - return {} - - archive_dest = archive_task_dir(task_dir_abs, repo_root) - if archive_dest: - return {"archived_to": str(archive_dest)} - - return {} - - -# ============================================================================= -# Main Entry (for testing) -# ============================================================================= - -if __name__ == "__main__": - from .paths import get_tasks_dir - - repo = get_repo_root() - tasks = get_tasks_dir(repo) - - print(f"Tasks dir: {tasks}") - print(f"is_safe_task_path('.trellis/tasks/test'): {is_safe_task_path('.trellis/tasks/test', repo)}") - print(f"is_safe_task_path('../test'): {is_safe_task_path('../test', repo)}") diff --git a/.trellis/scripts/common/worktree.py b/.trellis/scripts/common/worktree.py deleted file mode 100755 index f9aa4baa..00000000 --- a/.trellis/scripts/common/worktree.py +++ /dev/null @@ -1,305 +0,0 @@ -#!/usr/bin/env python3 -""" -Worktree utilities for Multi-Agent Pipeline. - -Provides: - get_worktree_config - Get worktree.yaml path - get_worktree_base_dir - Get worktree storage directory - get_worktree_copy_files - Get files to copy list - get_worktree_post_create_hooks - Get post-create hooks - get_agents_dir - Get agents registry directory -""" - -from __future__ import annotations - -from pathlib import Path - -from .paths import ( - DIR_WORKFLOW, - get_repo_root, - get_workspace_dir, -) - - -# ============================================================================= -# YAML Simple Parser (no dependencies) -# ============================================================================= - - -def _unquote(s: str) -> str: - """Remove exactly one layer of matching surrounding quotes. - - Unlike str.strip('"'), this only removes the outermost pair, - preserving any nested quotes inside the value. - - Examples: - _unquote('"hello"') -> 'hello' - _unquote("'hello'") -> 'hello' - _unquote('"echo \\'hi\\'"') -> "echo 'hi'" - _unquote('hello') -> 'hello' - _unquote('"hello\\'') -> '"hello\\'' (mismatched, unchanged) - """ - if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"): - return s[1:-1] - return s - - -def parse_simple_yaml(content: str) -> dict: - """Parse simple YAML with nested dict support (no dependencies). - - Supports: - - key: value (string) - - key: (followed by list items) - - item1 - - item2 - - key: (followed by nested dict) - nested_key: value - nested_key2: - - item - - Uses indentation to detect nesting (2+ spaces deeper = child). - - Args: - content: YAML content string. - - Returns: - Parsed dict (values can be str, list[str], or dict). - """ - lines = content.splitlines() - result: dict = {} - _parse_yaml_block(lines, 0, 0, result) - return result - - -def _parse_yaml_block( - lines: list[str], start: int, min_indent: int, target: dict -) -> int: - """Parse a YAML block into target dict, returning next line index.""" - i = start - current_list: list | None = None - - while i < len(lines): - line = lines[i] - stripped = line.strip() - - # Skip empty lines and comments - if not stripped or stripped.startswith("#"): - i += 1 - continue - - # Calculate indentation - indent = len(line) - len(line.lstrip()) - - # If dedented past our block, we're done - if indent < min_indent: - break - - if stripped.startswith("- "): - if current_list is not None: - current_list.append(_unquote(stripped[2:].strip())) - i += 1 - elif ":" in stripped: - key, _, value = stripped.partition(":") - key = key.strip() - value = _unquote(value.strip()) - current_list = None - - if value: - # key: value - target[key] = value - i += 1 - else: - # key: (no value) — peek ahead to determine list vs nested dict - next_i, next_line = _next_content_line(lines, i + 1) - if next_i >= len(lines): - target[key] = {} - i = next_i - elif next_line.strip().startswith("- "): - # It's a list - current_list = [] - target[key] = current_list - i += 1 - else: - next_indent = len(next_line) - len(next_line.lstrip()) - if next_indent > indent: - # It's a nested dict - nested: dict = {} - target[key] = nested - i = _parse_yaml_block(lines, i + 1, next_indent, nested) - else: - # Empty value, same or less indent follows - target[key] = {} - i += 1 - else: - i += 1 - - return i - - -def _next_content_line(lines: list[str], start: int) -> tuple[int, str]: - """Find the next non-empty, non-comment line.""" - i = start - while i < len(lines): - stripped = lines[i].strip() - if stripped and not stripped.startswith("#"): - return i, lines[i] - i += 1 - return i, "" - - -def _yaml_get_value(config_file: Path, key: str) -> str | None: - """Read simple value from worktree.yaml. - - Args: - config_file: Path to config file. - key: Key to read. - - Returns: - Value string or None. - """ - try: - content = config_file.read_text(encoding="utf-8") - data = parse_simple_yaml(content) - value = data.get(key) - if isinstance(value, str): - return value - except (OSError, IOError): - pass - return None - - -def _yaml_get_list(config_file: Path, section: str) -> list[str]: - """Read list from worktree.yaml. - - Args: - config_file: Path to config file. - section: Section name. - - Returns: - List of items. - """ - try: - content = config_file.read_text(encoding="utf-8") - data = parse_simple_yaml(content) - value = data.get(section) - if isinstance(value, list): - return [str(item) for item in value] - except (OSError, IOError): - pass - return [] - - -# ============================================================================= -# Worktree Configuration -# ============================================================================= - -# Worktree config file relative path (relative to repo root) -WORKTREE_CONFIG_PATH = f"{DIR_WORKFLOW}/worktree.yaml" - - -def get_worktree_config(repo_root: Path | None = None) -> Path: - """Get worktree.yaml config file path. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Absolute path to config file. - """ - if repo_root is None: - repo_root = get_repo_root() - return repo_root / WORKTREE_CONFIG_PATH - - -def get_worktree_base_dir(repo_root: Path | None = None) -> Path: - """Get worktree base directory. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Absolute path to worktree base directory. - """ - if repo_root is None: - repo_root = get_repo_root() - - config = get_worktree_config(repo_root) - worktree_dir = _yaml_get_value(config, "worktree_dir") - - # Default value - if not worktree_dir: - worktree_dir = "../worktrees" - - # Handle relative path - if worktree_dir.startswith("../") or worktree_dir.startswith("./"): - # Relative to repo_root - return repo_root / worktree_dir - else: - # Absolute path - return Path(worktree_dir) - - -def get_worktree_copy_files(repo_root: Path | None = None) -> list[str]: - """Get files to copy list. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - List of file paths to copy. - """ - if repo_root is None: - repo_root = get_repo_root() - config = get_worktree_config(repo_root) - return _yaml_get_list(config, "copy") - - -def get_worktree_post_create_hooks(repo_root: Path | None = None) -> list[str]: - """Get post_create hooks. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - List of commands to run. - """ - if repo_root is None: - repo_root = get_repo_root() - config = get_worktree_config(repo_root) - return _yaml_get_list(config, "post_create") - - -# ============================================================================= -# Agents Registry -# ============================================================================= - -def get_agents_dir(repo_root: Path | None = None) -> Path | None: - """Get agents directory for current developer. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Absolute path to agents directory, or None if no workspace. - """ - if repo_root is None: - repo_root = get_repo_root() - - workspace_dir = get_workspace_dir(repo_root) - if workspace_dir: - return workspace_dir / ".agents" - return None - - -# ============================================================================= -# Main Entry (for testing) -# ============================================================================= - -if __name__ == "__main__": - repo = get_repo_root() - print(f"Repository root: {repo}") - print(f"Worktree config: {get_worktree_config(repo)}") - print(f"Worktree base dir: {get_worktree_base_dir(repo)}") - print(f"Copy files: {get_worktree_copy_files(repo)}") - print(f"Post create hooks: {get_worktree_post_create_hooks(repo)}") - print(f"Agents dir: {get_agents_dir(repo)}") diff --git a/.trellis/scripts/create_bootstrap.py b/.trellis/scripts/create_bootstrap.py deleted file mode 100755 index 201146f6..00000000 --- a/.trellis/scripts/create_bootstrap.py +++ /dev/null @@ -1,293 +0,0 @@ -#!/usr/bin/env python3 -""" -Create Bootstrap Task for First-Time Setup. - -Creates a guided task to help users fill in project guidelines -after initializing Trellis for the first time. - -Usage: - python3 create_bootstrap.py [project-type] - -Arguments: - project-type: frontend | backend | fullstack (default: fullstack) - -Prerequisites: - - .trellis/.developer must exist (run init_developer.py first) - -Creates: - .trellis/tasks/00-bootstrap-guidelines/ - - task.json # Task metadata - - prd.md # Task description and guidance -""" - -from __future__ import annotations - -import json -import sys -from datetime import datetime -from pathlib import Path - -from common.paths import ( - DIR_WORKFLOW, - DIR_SCRIPTS, - DIR_TASKS, - get_repo_root, - get_developer, - get_tasks_dir, - set_current_task, -) - - -# ============================================================================= -# Constants -# ============================================================================= - -TASK_NAME = "00-bootstrap-guidelines" - - -# ============================================================================= -# PRD Content -# ============================================================================= - -def write_prd_header() -> str: - """Write PRD header section.""" - return """# Bootstrap: Fill Project Development Guidelines - -## Purpose - -Welcome to Trellis! This is your first task. - -AI agents use `.trellis/spec/` to understand YOUR project's coding conventions. -**Empty templates = AI writes generic code that doesn't match your project style.** - -Filling these guidelines is a one-time setup that pays off for every future AI session. - ---- - -## Your Task - -Fill in the guideline files based on your **existing codebase**. -""" - - -def write_prd_backend_section() -> str: - """Write PRD backend section.""" - return """ - -### Backend Guidelines - -| File | What to Document | -|------|------------------| -| `.trellis/spec/backend/directory-structure.md` | Where different file types go (routes, services, utils) | -| `.trellis/spec/backend/database-guidelines.md` | ORM, migrations, query patterns, naming conventions | -| `.trellis/spec/backend/error-handling.md` | How errors are caught, logged, and returned | -| `.trellis/spec/backend/logging-guidelines.md` | Log levels, format, what to log | -| `.trellis/spec/backend/quality-guidelines.md` | Code review standards, testing requirements | -""" - - -def write_prd_frontend_section() -> str: - """Write PRD frontend section.""" - return """ - -### Frontend Guidelines - -| File | What to Document | -|------|------------------| -| `.trellis/spec/frontend/directory-structure.md` | Component/page/hook organization | -| `.trellis/spec/frontend/component-guidelines.md` | Component patterns, props conventions | -| `.trellis/spec/frontend/hook-guidelines.md` | Custom hook naming, patterns | -| `.trellis/spec/frontend/state-management.md` | State library, patterns, what goes where | -| `.trellis/spec/frontend/type-safety.md` | TypeScript conventions, type organization | -| `.trellis/spec/frontend/quality-guidelines.md` | Linting, testing, accessibility | -""" - - -def write_prd_footer() -> str: - """Write PRD footer section.""" - return """ - -### Thinking Guides (Optional) - -The `.trellis/spec/guides/` directory contains thinking guides that are already -filled with general best practices. You can customize them for your project if needed. - ---- - -## How to Fill Guidelines - -### Principle: Document Reality, Not Ideals - -Write what your codebase **actually does**, not what you wish it did. -AI needs to match existing patterns, not introduce new ones. - -### Steps - -1. **Look at existing code** - Find 2-3 examples of each pattern -2. **Document the pattern** - Describe what you see -3. **Include file paths** - Reference real files as examples -4. **List anti-patterns** - What does your team avoid? - ---- - -## Tips for Using AI - -Ask AI to help analyze your codebase: - -- "Look at my codebase and document the patterns you see" -- "Analyze my code structure and summarize the conventions" -- "Find error handling patterns and document them" - -The AI will read your code and help you document it. - ---- - -## Completion Checklist - -- [ ] Guidelines filled for your project type -- [ ] At least 2-3 real code examples in each guideline -- [ ] Anti-patterns documented - -When done: - -```bash -python3 ./.trellis/scripts/task.py finish -python3 ./.trellis/scripts/task.py archive 00-bootstrap-guidelines -``` - ---- - -## Why This Matters - -After completing this task: - -1. AI will write code that matches your project style -2. Relevant `/trellis:before-*-dev` commands will inject real context -3. `/trellis:check-*` commands will validate against your actual standards -4. Future developers (human or AI) will onboard faster -""" - - -def write_prd(task_dir: Path, project_type: str) -> None: - """Write prd.md file.""" - content = write_prd_header() - - if project_type == "frontend": - content += write_prd_frontend_section() - elif project_type == "backend": - content += write_prd_backend_section() - else: # fullstack - content += write_prd_backend_section() - content += write_prd_frontend_section() - - content += write_prd_footer() - - prd_file = task_dir / "prd.md" - prd_file.write_text(content, encoding="utf-8") - - -# ============================================================================= -# Task JSON -# ============================================================================= - -def write_task_json(task_dir: Path, developer: str, project_type: str) -> None: - """Write task.json file.""" - today = datetime.now().strftime("%Y-%m-%d") - - # Generate subtasks and related files based on project type - if project_type == "frontend": - subtasks = [ - {"name": "Fill frontend guidelines", "status": "pending"}, - {"name": "Add code examples", "status": "pending"}, - ] - related_files = [".trellis/spec/frontend/"] - elif project_type == "backend": - subtasks = [ - {"name": "Fill backend guidelines", "status": "pending"}, - {"name": "Add code examples", "status": "pending"}, - ] - related_files = [".trellis/spec/backend/"] - else: # fullstack - subtasks = [ - {"name": "Fill backend guidelines", "status": "pending"}, - {"name": "Fill frontend guidelines", "status": "pending"}, - {"name": "Add code examples", "status": "pending"}, - ] - related_files = [".trellis/spec/backend/", ".trellis/spec/frontend/"] - - task_data = { - "id": TASK_NAME, - "name": "Bootstrap Guidelines", - "description": "Fill in project development guidelines for AI agents", - "status": "in_progress", - "dev_type": "docs", - "priority": "P1", - "creator": developer, - "assignee": developer, - "createdAt": today, - "completedAt": None, - "commit": None, - "subtasks": subtasks, - "children": [], - "parent": None, - "relatedFiles": related_files, - "notes": f"First-time setup task created by trellis init ({project_type} project)", - "meta": {}, - } - - task_json = task_dir / "task.json" - task_json.write_text(json.dumps(task_data, indent=2, ensure_ascii=False), encoding="utf-8") - - -# ============================================================================= -# Main -# ============================================================================= - -def main() -> int: - """Main entry point.""" - # Parse project type argument - project_type = "fullstack" - if len(sys.argv) > 1: - project_type = sys.argv[1] - - # Validate project type - if project_type not in ("frontend", "backend", "fullstack"): - print(f"Unknown project type: {project_type}, defaulting to fullstack") - project_type = "fullstack" - - repo_root = get_repo_root() - developer = get_developer(repo_root) - - # Check developer initialized - if not developer: - print("Error: Developer not initialized") - print(f"Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <your-name>") - return 1 - - tasks_dir = get_tasks_dir(repo_root) - task_dir = tasks_dir / TASK_NAME - relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{TASK_NAME}" - - # Check if already exists - if task_dir.exists(): - print(f"Bootstrap task already exists: {relative_path}") - return 0 - - # Create task directory - task_dir.mkdir(parents=True, exist_ok=True) - - # Write files - write_task_json(task_dir, developer, project_type) - write_prd(task_dir, project_type) - - # Set as current task - set_current_task(relative_path, repo_root) - - # Silent output - init command handles user-facing messages - # Only output the task path for programmatic use - print(relative_path) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/scripts/get_context.py b/.trellis/scripts/get_context.py deleted file mode 100755 index bc634631..00000000 --- a/.trellis/scripts/get_context.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python3 -""" -Get Session Context for AI Agent. - -Usage: - python3 get_context.py Output context in text format - python3 get_context.py --json Output context in JSON format -""" - -from __future__ import annotations - -from common.git_context import main - - -if __name__ == "__main__": - main() diff --git a/.trellis/scripts/get_developer.py b/.trellis/scripts/get_developer.py deleted file mode 100755 index f8a89ebf..00000000 --- a/.trellis/scripts/get_developer.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python3 -""" -Get current developer name. - -This is a wrapper that uses common/paths.py -""" - -from __future__ import annotations - -import sys - -from common.paths import get_developer - - -def main() -> None: - """CLI entry point.""" - developer = get_developer() - if developer: - print(developer) - else: - print("Developer not initialized", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/.trellis/scripts/init_developer.py b/.trellis/scripts/init_developer.py deleted file mode 100755 index 9fb53f5c..00000000 --- a/.trellis/scripts/init_developer.py +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env python3 -""" -Initialize developer for workflow. - -Usage: - python3 init_developer.py <developer-name> - -This creates: - - .trellis/.developer file with developer info - - .trellis/workspace/<name>/ directory structure -""" - -from __future__ import annotations - -import sys - -from common.paths import ( - DIR_WORKFLOW, - FILE_DEVELOPER, - get_developer, -) -from common.developer import init_developer - - -def main() -> None: - """CLI entry point.""" - if len(sys.argv) < 2: - print(f"Usage: {sys.argv[0]} <developer-name>") - print() - print("Example:") - print(f" {sys.argv[0]} john") - sys.exit(1) - - name = sys.argv[1] - - # Check if already initialized - existing = get_developer() - if existing: - print(f"Developer already initialized: {existing}") - print() - print(f"To reinitialize, remove {DIR_WORKFLOW}/{FILE_DEVELOPER} first") - sys.exit(0) - - if init_developer(name): - sys.exit(0) - else: - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/.trellis/scripts/multi_agent/__init__.py b/.trellis/scripts/multi_agent/__init__.py deleted file mode 100755 index c7c7e7dd..00000000 --- a/.trellis/scripts/multi_agent/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -""" -Multi-Agent Pipeline Scripts. - -This module provides orchestration for multi-agent workflows. -""" diff --git a/.trellis/scripts/multi_agent/cleanup.py b/.trellis/scripts/multi_agent/cleanup.py deleted file mode 100755 index f81e3704..00000000 --- a/.trellis/scripts/multi_agent/cleanup.py +++ /dev/null @@ -1,403 +0,0 @@ -#!/usr/bin/env python3 -""" -Multi-Agent Pipeline: Cleanup Worktree. - -Usage: - python3 cleanup.py <branch-name> Remove specific worktree - python3 cleanup.py --list List all worktrees - python3 cleanup.py --merged Remove merged worktrees - python3 cleanup.py --all Remove all worktrees (with confirmation) - -Options: - -y, --yes Skip confirmation prompts - --keep-branch Don't delete the git branch - -This script: -1. Archives task directory to archive/{YYYY-MM}/ -2. Removes agent from registry -3. Removes git worktree -4. Optionally deletes git branch -""" - -from __future__ import annotations - -import argparse -import shutil -import subprocess -import sys -from pathlib import Path - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from common.git_context import _run_git_command -from common.paths import get_repo_root -from common.registry import ( - registry_get_file, - registry_get_task_dir, - registry_remove_by_id, - registry_remove_by_worktree, - registry_search_agent, -) -from common.task_utils import ( - archive_task_complete, - is_safe_task_path, -) - -# ============================================================================= -# Colors -# ============================================================================= - - -class Colors: - RED = "\033[0;31m" - GREEN = "\033[0;32m" - YELLOW = "\033[1;33m" - BLUE = "\033[0;34m" - NC = "\033[0m" - - -def log_info(msg: str) -> None: - print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") - - -def log_success(msg: str) -> None: - print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") - - -def log_warn(msg: str) -> None: - print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}") - - -def log_error(msg: str) -> None: - print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") - - -# ============================================================================= -# Helper Functions -# ============================================================================= - - -def confirm(prompt: str, skip_confirm: bool) -> bool: - """Ask for confirmation.""" - if skip_confirm: - return True - - if not sys.stdin.isatty(): - log_error("Non-interactive mode detected. Use -y to skip confirmation.") - return False - - response = input(f"{prompt} [y/N] ") - return response.lower() in ("y", "yes") - - -# ============================================================================= -# Commands -# ============================================================================= - - -def cmd_list(repo_root: Path) -> int: - """List worktrees.""" - print(f"{Colors.BLUE}=== Git Worktrees ==={Colors.NC}") - print() - - subprocess.run(["git", "worktree", "list"], cwd=repo_root) - print() - - # Show registry info - registry_file = registry_get_file(repo_root) - if registry_file and registry_file.is_file(): - print(f"{Colors.BLUE}=== Registered Agents ==={Colors.NC}") - print() - - import json - - data = json.loads(registry_file.read_text(encoding="utf-8")) - agents = data.get("agents", []) - - if agents: - for agent in agents: - print( - f" {agent.get('id', '?')}: PID={agent.get('pid', '?')} [{agent.get('worktree_path', '?')}]" - ) - else: - print(" (none)") - print() - - return 0 - - -def archive_task(worktree_path: str, repo_root: Path) -> None: - """Archive task directory.""" - task_dir = registry_get_task_dir(worktree_path, repo_root) - - if not task_dir or not is_safe_task_path(task_dir, repo_root): - return - - task_dir_abs = repo_root / task_dir - if not task_dir_abs.is_dir(): - return - - result = archive_task_complete(task_dir_abs, repo_root) - if "archived_to" in result: - dest = Path(result["archived_to"]) - log_success(f"Archived task: {dest.name} -> archive/{dest.parent.name}/") - - -def cleanup_registry_only(search: str, repo_root: Path, skip_confirm: bool) -> int: - """Cleanup from registry only (no worktree).""" - agent_info = registry_search_agent(search, repo_root) - - if not agent_info: - log_error(f"No agent found in registry matching: {search}") - return 1 - - agent_id = agent_info.get("id", "?") - task_dir = agent_info.get("task_dir", "?") - - print() - print(f"{Colors.BLUE}=== Cleanup Agent (no worktree) ==={Colors.NC}") - print(f" Agent ID: {agent_id}") - print(f" Task Dir: {task_dir}") - print() - - if not confirm("Archive task and remove from registry?", skip_confirm): - log_info("Aborted") - return 0 - - # Archive task directory if exists - if task_dir and is_safe_task_path(task_dir, repo_root): - task_dir_abs = repo_root / task_dir - if task_dir_abs.is_dir(): - result = archive_task_complete(task_dir_abs, repo_root) - if "archived_to" in result: - dest = Path(result["archived_to"]) - log_success( - f"Archived task: {dest.name} -> archive/{dest.parent.name}/" - ) - else: - log_warn("Invalid task_dir in registry, skipping archive") - - # Remove from registry - registry_remove_by_id(agent_id, repo_root) - log_success(f"Removed from registry: {agent_id}") - - log_success("Cleanup complete") - return 0 - - -def cleanup_worktree( - branch: str, repo_root: Path, skip_confirm: bool, keep_branch: bool -) -> int: - """Cleanup single worktree.""" - # Find worktree path for branch - _, worktree_list, _ = _run_git_command( - ["worktree", "list", "--porcelain"], cwd=repo_root - ) - - worktree_path = None - current_worktree = None - - for line in worktree_list.splitlines(): - if line.startswith("worktree "): - current_worktree = line[9:] # Remove "worktree " prefix - elif line.startswith("branch refs/heads/"): - current_branch = line[18:] # Remove "branch refs/heads/" prefix - if current_branch == branch: - worktree_path = current_worktree - break - - if not worktree_path: - # No worktree found, try to cleanup from registry only - log_warn(f"No worktree found for: {branch}") - log_info("Trying to cleanup from registry...") - return cleanup_registry_only(branch, repo_root, skip_confirm) - - print() - print(f"{Colors.BLUE}=== Cleanup Worktree ==={Colors.NC}") - print(f" Branch: {branch}") - print(f" Worktree: {worktree_path}") - print() - - if not confirm("Remove this worktree?", skip_confirm): - log_info("Aborted") - return 0 - - # 1. Archive task - archive_task(worktree_path, repo_root) - - # 2. Remove from registry - registry_remove_by_worktree(worktree_path, repo_root) - log_info("Removed from registry") - - # 3. Remove worktree - log_info("Removing worktree...") - ret, _, _ = _run_git_command( - ["worktree", "remove", worktree_path, "--force"], cwd=repo_root - ) - if ret != 0: - # Try removing directory manually - try: - shutil.rmtree(worktree_path) - except Exception as e: - log_error(f"Failed to remove worktree: {e}") - - log_success("Worktree removed") - - # 4. Delete branch (optional) - if not keep_branch: - log_info("Deleting branch...") - ret, _, _ = _run_git_command(["branch", "-D", branch], cwd=repo_root) - if ret != 0: - log_warn("Could not delete branch (may be checked out elsewhere)") - - log_success(f"Cleanup complete for: {branch}") - return 0 - - -def cmd_merged(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int: - """Cleanup merged worktrees.""" - # Get main branch - _, head_out, _ = _run_git_command( - ["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=repo_root - ) - main_branch = head_out.strip().replace("refs/remotes/origin/", "") or "main" - - print(f"{Colors.BLUE}=== Finding Merged Worktrees ==={Colors.NC}") - print() - - # Get merged branches - _, merged_out, _ = _run_git_command( - ["branch", "--merged", main_branch], cwd=repo_root - ) - merged_branches = [] - for line in merged_out.splitlines(): - branch = line.strip().lstrip("* ") - if branch and branch != main_branch: - merged_branches.append(branch) - - if not merged_branches: - log_info("No merged branches found") - return 0 - - # Get worktree list - _, worktree_list, _ = _run_git_command(["worktree", "list"], cwd=repo_root) - - worktree_branches = [] - for branch in merged_branches: - if f"[{branch}]" in worktree_list: - worktree_branches.append(branch) - print(f" - {branch}") - - if not worktree_branches: - log_info("No merged worktrees found") - return 0 - - print() - if not confirm("Remove these merged worktrees?", skip_confirm): - log_info("Aborted") - return 0 - - for branch in worktree_branches: - cleanup_worktree(branch, repo_root, True, keep_branch) - - return 0 - - -def cmd_all(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int: - """Cleanup all worktrees.""" - print(f"{Colors.BLUE}=== All Worktrees ==={Colors.NC}") - print() - - # Get worktree list - _, worktree_list, _ = _run_git_command( - ["worktree", "list", "--porcelain"], cwd=repo_root - ) - - worktrees = [] - main_worktree = str(repo_root.resolve()) - - for line in worktree_list.splitlines(): - if line.startswith("worktree "): - wt = line[9:] - if wt != main_worktree: - worktrees.append(wt) - - if not worktrees: - log_info("No worktrees to remove") - return 0 - - for wt in worktrees: - print(f" - {wt}") - - print() - print(f"{Colors.RED}WARNING: This will remove ALL worktrees!{Colors.NC}") - - if not confirm("Are you sure?", skip_confirm): - log_info("Aborted") - return 0 - - # Get branch for each worktree - for wt in worktrees: - # Find branch name from worktree list - _, wt_list, _ = _run_git_command(["worktree", "list"], cwd=repo_root) - for line in wt_list.splitlines(): - if wt in line: - # Extract branch from [branch] format - import re - - match = re.search(r"\[([^\]]+)\]", line) - if match: - branch = match.group(1) - cleanup_worktree(branch, repo_root, True, keep_branch) - break - - return 0 - - -# ============================================================================= -# Main -# ============================================================================= - - -def main() -> int: - """Main entry point.""" - parser = argparse.ArgumentParser( - description="Multi-Agent Pipeline: Cleanup Worktree" - ) - parser.add_argument("branch", nargs="?", help="Branch name to cleanup") - parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmation") - parser.add_argument( - "--keep-branch", action="store_true", help="Don't delete git branch" - ) - parser.add_argument("--list", action="store_true", help="List all worktrees") - parser.add_argument("--merged", action="store_true", help="Remove merged worktrees") - parser.add_argument("--all", action="store_true", help="Remove all worktrees") - - args = parser.parse_args() - repo_root = get_repo_root() - - if args.list: - return cmd_list(repo_root) - elif args.merged: - return cmd_merged(repo_root, args.yes, args.keep_branch) - elif args.all: - return cmd_all(repo_root, args.yes, args.keep_branch) - elif args.branch: - return cleanup_worktree(args.branch, repo_root, args.yes, args.keep_branch) - else: - print("""Usage: - python3 cleanup.py <branch-name> Remove specific worktree - python3 cleanup.py --list List all worktrees - python3 cleanup.py --merged Remove merged worktrees - python3 cleanup.py --all Remove all worktrees - -Options: - -y, --yes Skip confirmation - --keep-branch Don't delete git branch -""") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/scripts/multi_agent/create_pr.py b/.trellis/scripts/multi_agent/create_pr.py deleted file mode 100755 index 54df3db6..00000000 --- a/.trellis/scripts/multi_agent/create_pr.py +++ /dev/null @@ -1,329 +0,0 @@ -#!/usr/bin/env python3 -""" -Multi-Agent Pipeline: Create PR. - -Usage: - python3 create_pr.py [task-dir] [--dry-run] - -This script: -1. Stages and commits all changes (excluding workspace/) -2. Pushes to origin -3. Creates a Draft PR using `gh pr create` -4. Updates task.json with status="completed", pr_url, and current_phase - -Note: This is the only action that performs git commit, as it's the final -step after all implementation and checks are complete. -""" - -from __future__ import annotations - -import argparse -import json -import subprocess -import sys -from pathlib import Path - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from common.git_context import _run_git_command -from common.paths import ( - DIR_WORKFLOW, - FILE_TASK_JSON, - get_current_task, - get_repo_root, -) -from common.phase import get_phase_for_action - -# ============================================================================= -# Colors -# ============================================================================= - - -class Colors: - RED = "\033[0;31m" - GREEN = "\033[0;32m" - YELLOW = "\033[1;33m" - BLUE = "\033[0;34m" - NC = "\033[0m" - - -# ============================================================================= -# Helper Functions -# ============================================================================= - - -def _read_json_file(path: Path) -> dict | None: - """Read and parse a JSON file.""" - try: - return json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None - - -def _write_json_file(path: Path, data: dict) -> bool: - """Write dict to JSON file.""" - try: - path.write_text( - json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8" - ) - return True - except (OSError, IOError): - return False - - -# ============================================================================= -# Main -# ============================================================================= - - -def main() -> int: - """Main entry point.""" - parser = argparse.ArgumentParser(description="Multi-Agent Pipeline: Create PR") - parser.add_argument("dir", nargs="?", help="Task directory") - parser.add_argument( - "--dry-run", action="store_true", help="Show what would be done" - ) - - args = parser.parse_args() - repo_root = get_repo_root() - - # ============================================================================= - # Get Task Directory - # ============================================================================= - target_dir = args.dir - if not target_dir: - # Try to get from .current-task - current_task = get_current_task(repo_root) - if current_task: - target_dir = current_task - - if not target_dir: - print( - f"{Colors.RED}Error: No task directory specified and no current task set{Colors.NC}" - ) - print("Usage: python3 create_pr.py [task-dir] [--dry-run]") - return 1 - - # Support relative paths - if not target_dir.startswith("/"): - target_dir_path = repo_root / target_dir - else: - target_dir_path = Path(target_dir) - - task_json = target_dir_path / FILE_TASK_JSON - if not task_json.is_file(): - print(f"{Colors.RED}Error: task.json not found at {target_dir_path}{Colors.NC}") - return 1 - - # ============================================================================= - # Main - # ============================================================================= - print(f"{Colors.BLUE}=== Create PR ==={Colors.NC}") - if args.dry_run: - print( - f"{Colors.YELLOW}[DRY-RUN MODE] No actual changes will be made{Colors.NC}" - ) - print() - - # Read task config - task_data = _read_json_file(task_json) - if not task_data: - print(f"{Colors.RED}Error: Failed to read task.json{Colors.NC}") - return 1 - - task_name = task_data.get("name", "") - base_branch = task_data.get("base_branch", "main") - scope = task_data.get("scope", "core") - dev_type = task_data.get("dev_type", "feature") - - # Map dev_type to commit prefix - prefix_map = { - "feature": "feat", - "frontend": "feat", - "backend": "feat", - "fullstack": "feat", - "bugfix": "fix", - "fix": "fix", - "refactor": "refactor", - "docs": "docs", - "test": "test", - } - commit_prefix = prefix_map.get(dev_type, "feat") - - print(f"Task: {task_name}") - print(f"Base branch: {base_branch}") - print(f"Scope: {scope}") - print(f"Commit prefix: {commit_prefix}") - print() - - # Get current branch - _, branch_out, _ = _run_git_command(["branch", "--show-current"]) - current_branch = branch_out.strip() - print(f"Current branch: {current_branch}") - - # Check for changes - print(f"{Colors.YELLOW}Checking for changes...{Colors.NC}") - - # Stage changes - _run_git_command(["add", "-A"]) - - # Exclude workspace and temp files - _run_git_command(["reset", f"{DIR_WORKFLOW}/workspace/"]) - _run_git_command(["reset", ".agent-log", ".session-id"]) - - # Check if there are staged changes - ret, _, _ = _run_git_command(["diff", "--cached", "--quiet"]) - has_staged_changes = ret != 0 - - if not has_staged_changes: - print(f"{Colors.YELLOW}No staged changes to commit{Colors.NC}") - - # Check for unpushed commits - ret, log_out, _ = _run_git_command( - ["log", f"origin/{current_branch}..HEAD", "--oneline"] - ) - unpushed = len([line for line in log_out.splitlines() if line.strip()]) - - if unpushed == 0: - if args.dry_run: - _run_git_command(["reset", "HEAD"]) - print(f"{Colors.RED}No changes to create PR{Colors.NC}") - return 1 - - print(f"Found {unpushed} unpushed commit(s)") - else: - # Commit changes - print(f"{Colors.YELLOW}Committing changes...{Colors.NC}") - commit_msg = f"{commit_prefix}({scope}): {task_name}" - - if args.dry_run: - print(f"[DRY-RUN] Would commit with message: {commit_msg}") - print("[DRY-RUN] Staged files:") - _, staged_out, _ = _run_git_command(["diff", "--cached", "--name-only"]) - for line in staged_out.splitlines(): - print(f" - {line}") - else: - _run_git_command(["commit", "-m", commit_msg]) - print(f"{Colors.GREEN}Committed: {commit_msg}{Colors.NC}") - - # Push to remote - print(f"{Colors.YELLOW}Pushing to remote...{Colors.NC}") - if args.dry_run: - print(f"[DRY-RUN] Would push to: origin/{current_branch}") - else: - ret, _, err = _run_git_command(["push", "-u", "origin", current_branch]) - if ret != 0: - print(f"{Colors.RED}Failed to push: {err}{Colors.NC}") - return 1 - print(f"{Colors.GREEN}Pushed to origin/{current_branch}{Colors.NC}") - - # Create PR - print(f"{Colors.YELLOW}Creating PR...{Colors.NC}") - pr_title = f"{commit_prefix}({scope}): {task_name}" - pr_url = "" - - if args.dry_run: - print("[DRY-RUN] Would create PR:") - print(f" Title: {pr_title}") - print(f" Base: {base_branch}") - print(f" Head: {current_branch}") - prd_file = target_dir_path / "prd.md" - if prd_file.is_file(): - print(" Body: (from prd.md)") - pr_url = "https://github.com/example/repo/pull/DRY-RUN" - else: - # Check if PR already exists - result = subprocess.run( - [ - "gh", - "pr", - "list", - "--head", - current_branch, - "--base", - base_branch, - "--json", - "url", - "--jq", - ".[0].url", - ], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - existing_pr = result.stdout.strip() - - if existing_pr: - print(f"{Colors.YELLOW}PR already exists: {existing_pr}{Colors.NC}") - pr_url = existing_pr - else: - # Read PRD as PR body - pr_body = "" - prd_file = target_dir_path / "prd.md" - if prd_file.is_file(): - pr_body = prd_file.read_text(encoding="utf-8") - - # Create PR - result = subprocess.run( - [ - "gh", - "pr", - "create", - "--draft", - "--base", - base_branch, - "--title", - pr_title, - "--body", - pr_body, - ], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - - if result.returncode != 0: - print(f"{Colors.RED}Failed to create PR: {result.stderr}{Colors.NC}") - return 1 - - pr_url = result.stdout.strip() - print(f"{Colors.GREEN}PR created: {pr_url}{Colors.NC}") - - # Update task.json - print(f"{Colors.YELLOW}Updating task status...{Colors.NC}") - if args.dry_run: - print("[DRY-RUN] Would update task.json:") - print(" status: completed") - print(f" pr_url: {pr_url}") - print(" current_phase: (set to create-pr phase)") - else: - # Get the phase number for create-pr action - create_pr_phase = get_phase_for_action(task_json, "create-pr") - if not create_pr_phase: - create_pr_phase = 4 # Default fallback - - task_data["status"] = "completed" - task_data["pr_url"] = pr_url - task_data["current_phase"] = create_pr_phase - - _write_json_file(task_json, task_data) - print( - f"{Colors.GREEN}Task status updated to 'completed', phase {create_pr_phase}{Colors.NC}" - ) - - # In dry-run, reset the staging area - if args.dry_run: - _run_git_command(["reset", "HEAD"]) - - print() - print(f"{Colors.GREEN}=== PR Created Successfully ==={Colors.NC}") - print(f"PR URL: {pr_url}") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/scripts/multi_agent/plan.py b/.trellis/scripts/multi_agent/plan.py deleted file mode 100755 index 7ce5e6f3..00000000 --- a/.trellis/scripts/multi_agent/plan.py +++ /dev/null @@ -1,236 +0,0 @@ -#!/usr/bin/env python3 -""" -Multi-Agent Pipeline: Plan Agent Launcher. - -Usage: python3 plan.py --name <task-name> --type <dev-type> --requirement "<requirement>" - -This script: -1. Creates task directory -2. Starts Plan Agent in background -3. Plan Agent produces fully configured task directory - -After completion, use start.py to launch the Dispatch Agent. - -Prerequisites: - - agents/plan.md must exist (in .claude/, .cursor/, .iflow/, or .opencode/) - - Developer must be initialized -""" - -from __future__ import annotations - -import argparse -import os -import subprocess -import sys -from pathlib import Path - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from common.cli_adapter import get_cli_adapter -from common.paths import get_repo_root -from common.developer import ensure_developer - - -# ============================================================================= -# Colors -# ============================================================================= - -class Colors: - RED = "\033[0;31m" - GREEN = "\033[0;32m" - YELLOW = "\033[1;33m" - BLUE = "\033[0;34m" - NC = "\033[0m" - - -def log_info(msg: str) -> None: - print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") - - -def log_success(msg: str) -> None: - print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") - - -def log_error(msg: str) -> None: - print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") - - -# ============================================================================= -# Constants -# ============================================================================= - -DEFAULT_PLATFORM = "claude" - - -# ============================================================================= -# Main -# ============================================================================= - -def main() -> int: - """Main entry point.""" - parser = argparse.ArgumentParser( - description="Multi-Agent Pipeline: Plan Agent Launcher" - ) - parser.add_argument("--name", "-n", required=True, help="Task name (e.g., user-auth)") - parser.add_argument("--type", "-t", required=True, help="Dev type: backend|frontend|fullstack") - parser.add_argument("--requirement", "-r", required=True, help="Requirement description") - parser.add_argument( - "--platform", "-p", - choices=["claude", "cursor", "iflow", "opencode", "qoder"], - default=DEFAULT_PLATFORM, - help="Platform to use (default: claude)" - ) - - args = parser.parse_args() - - task_name = args.name - dev_type = args.type - requirement = args.requirement - platform = args.platform - - # Initialize CLI adapter - adapter = get_cli_adapter(platform) - - # Validate dev type - if dev_type not in ("backend", "frontend", "fullstack"): - log_error(f"Invalid dev type: {dev_type} (must be: backend, frontend, fullstack)") - return 1 - - project_root = get_repo_root() - - # Check plan agent exists (path varies by platform) - plan_md = adapter.get_agent_path("plan", project_root) - if not plan_md.is_file(): - log_error(f"plan agent not found at {plan_md}") - log_info(f"Platform: {platform}") - return 1 - - ensure_developer(project_root) - - # ============================================================================= - # Step 1: Create Task Directory - # ============================================================================= - print() - print(f"{Colors.BLUE}=== Multi-Agent Pipeline: Plan ==={Colors.NC}") - log_info(f"Task: {task_name}") - log_info(f"Type: {dev_type}") - log_info(f"Requirement: {requirement}") - print() - - log_info("Step 1: Creating task directory...") - - # Import task module to create task - from task import cmd_create - import argparse as ap - - # Create task using task.py's create command - create_args = ap.Namespace( - title=requirement, - slug=task_name, - assignee=None, - priority="P2", - description="" - ) - - # Capture stdout to get task dir - import io - from contextlib import redirect_stdout - - stdout_capture = io.StringIO() - with redirect_stdout(stdout_capture): - ret = cmd_create(create_args) - - if ret != 0: - log_error("Failed to create task directory") - return 1 - - task_dir = stdout_capture.getvalue().strip().split("\n")[-1] - task_dir_abs = project_root / task_dir - - log_success(f"Task directory: {task_dir}") - - # ============================================================================= - # Step 2: Prepare and Start Plan Agent - # ============================================================================= - log_info("Step 2: Starting Plan Agent in background...") - - log_file = task_dir_abs / ".plan-log" - log_file.touch() - - # Get proxy environment variables - https_proxy = os.environ.get("https_proxy", "") - http_proxy = os.environ.get("http_proxy", "") - all_proxy = os.environ.get("all_proxy", "") - - # Start agent in background (cross-platform, no shell script needed) - env = os.environ.copy() - env["PLAN_TASK_NAME"] = task_name - env["PLAN_DEV_TYPE"] = dev_type - env["PLAN_TASK_DIR"] = task_dir - env["PLAN_REQUIREMENT"] = requirement - env["https_proxy"] = https_proxy - env["http_proxy"] = http_proxy - env["all_proxy"] = all_proxy - - # Clear nested-session detection so the new CLI process can start - env.pop("CLAUDECODE", None) - - # Set non-interactive env var based on platform - env.update(adapter.get_non_interactive_env()) - - # Build CLI command using adapter - cli_cmd = adapter.build_run_command( - agent="plan", # Will be mapped to "trellis-plan" for OpenCode - prompt=f"Start planning for task: {task_name}", - skip_permissions=True, - verbose=True, - json_output=True, - ) - - with log_file.open("w") as log_f: - # Use shell=False for cross-platform compatibility - # creationflags for Windows, start_new_session for Unix - popen_kwargs = { - "stdout": log_f, - "stderr": subprocess.STDOUT, - "cwd": str(project_root), - "env": env, - } - if sys.platform == "win32": - popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP - else: - popen_kwargs["start_new_session"] = True - - process = subprocess.Popen(cli_cmd, **popen_kwargs) - agent_pid = process.pid - - log_success(f"Plan Agent started (PID: {agent_pid})") - - # ============================================================================= - # Summary - # ============================================================================= - print() - print(f"{Colors.GREEN}=== Plan Agent Running ==={Colors.NC}") - print() - print(f" Task: {task_name}") - print(f" Type: {dev_type}") - print(f" Dir: {task_dir}") - print(f" Log: {log_file}") - print(f" PID: {agent_pid}") - print() - print(f"{Colors.YELLOW}To monitor:{Colors.NC}") - print(f" tail -f {log_file}") - print() - print(f"{Colors.YELLOW}To check status:{Colors.NC}") - print(f" ps -p {agent_pid}") - print(f" ls -la {task_dir}") - print() - print(f"{Colors.YELLOW}After completion, run:{Colors.NC}") - print(f" python3 ./.trellis/scripts/multi_agent/start.py {task_dir}") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/scripts/multi_agent/start.py b/.trellis/scripts/multi_agent/start.py deleted file mode 100755 index 40c2747e..00000000 --- a/.trellis/scripts/multi_agent/start.py +++ /dev/null @@ -1,465 +0,0 @@ -#!/usr/bin/env python3 -""" -Multi-Agent Pipeline: Start Worktree Agent. - -Usage: python3 start.py <task-dir> -Example: python3 start.py .trellis/tasks/01-21-my-task - -This script: -1. Creates worktree (if not exists) with dependency install -2. Copies environment files (from worktree.yaml config) -3. Sets .current-task in worktree -4. Starts claude agent in background -5. Registers agent to registry.json - -Prerequisites: - - task.json must exist with 'branch' field - - agents/dispatch.md must exist (in .claude/, .cursor/, .iflow/, or .opencode/) - -Configuration: .trellis/worktree.yaml -""" - -from __future__ import annotations - -import json -import os -import shutil -import subprocess -import sys -import uuid -from pathlib import Path - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from common.cli_adapter import CLIAdapter, get_cli_adapter -from common.git_context import _run_git_command -from common.paths import ( - DIR_WORKFLOW, - FILE_CURRENT_TASK, - FILE_TASK_JSON, - get_repo_root, -) -from common.registry import ( - registry_add_agent, - registry_get_file, -) -from common.worktree import ( - get_worktree_base_dir, - get_worktree_config, - get_worktree_copy_files, - get_worktree_post_create_hooks, -) - -# ============================================================================= -# Colors -# ============================================================================= - - -class Colors: - RED = "\033[0;31m" - GREEN = "\033[0;32m" - YELLOW = "\033[1;33m" - BLUE = "\033[0;34m" - NC = "\033[0m" - - -def log_info(msg: str) -> None: - print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") - - -def log_success(msg: str) -> None: - print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") - - -def log_warn(msg: str) -> None: - print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}") - - -def log_error(msg: str) -> None: - print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") - - -# ============================================================================= -# Helper Functions -# ============================================================================= - - -def _read_json_file(path: Path) -> dict | None: - """Read and parse a JSON file.""" - try: - return json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None - - -def _write_json_file(path: Path, data: dict) -> bool: - """Write dict to JSON file.""" - try: - path.write_text( - json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8" - ) - return True - except (OSError, IOError): - return False - - -# ============================================================================= -# Constants -# ============================================================================= - -DEFAULT_PLATFORM = "claude" - - -# ============================================================================= -# Main -# ============================================================================= - - -def main() -> int: - """Main entry point.""" - import argparse - - parser = argparse.ArgumentParser(description="Multi-Agent Pipeline: Start Worktree Agent") - parser.add_argument("task_dir", help="Task directory path") - parser.add_argument( - "--platform", "-p", - choices=["claude", "cursor", "iflow", "opencode", "qoder"], - default=DEFAULT_PLATFORM, - help="Platform to use (default: claude)" - ) - - args = parser.parse_args() - task_dir_arg = args.task_dir - platform = args.platform - - # Initialize CLI adapter - adapter = get_cli_adapter(platform) - - project_root = get_repo_root() - - # Normalize paths - if task_dir_arg.startswith("/"): - task_dir_relative = task_dir_arg[len(str(project_root)) + 1 :] - task_dir_abs = Path(task_dir_arg) - else: - task_dir_relative = task_dir_arg - task_dir_abs = project_root / task_dir_arg - - task_json_path = task_dir_abs / FILE_TASK_JSON - - # ============================================================================= - # Validation - # ============================================================================= - if not task_json_path.is_file(): - log_error(f"task.json not found at {task_json_path}") - return 1 - - dispatch_md = adapter.get_agent_path("dispatch", project_root) - if not dispatch_md.is_file(): - log_error(f"dispatch.md not found at {dispatch_md}") - log_info(f"Platform: {platform}") - return 1 - - config_file = get_worktree_config(project_root) - if not config_file.is_file(): - log_error(f"worktree.yaml not found at {config_file}") - return 1 - - # ============================================================================= - # Read Task Config - # ============================================================================= - print() - print(f"{Colors.BLUE}=== Multi-Agent Pipeline: Start ==={Colors.NC}") - log_info(f"Task: {task_dir_abs}") - - task_data = _read_json_file(task_json_path) - if not task_data: - log_error("Failed to read task.json") - return 1 - - branch = task_data.get("branch") - task_name = task_data.get("name") - task_status = task_data.get("status") - worktree_path = task_data.get("worktree_path") - - # Check if task was rejected - if task_status == "rejected": - log_error("Task was rejected by Plan Agent") - rejected_file = task_dir_abs / "REJECTED.md" - if rejected_file.is_file(): - print() - print(f"{Colors.YELLOW}Rejection reason:{Colors.NC}") - print(rejected_file.read_text(encoding="utf-8")) - print() - log_info( - "To retry, delete this directory and run plan.py again with revised requirements" - ) - return 1 - - # Check if prd.md exists (plan completed successfully) - prd_file = task_dir_abs / "prd.md" - if not prd_file.is_file(): - log_error("prd.md not found - Plan Agent may not have completed") - log_info(f"Check plan log: {task_dir_abs}/.plan-log") - return 1 - - if not branch: - log_error("branch field not set in task.json") - log_info("Please set branch field first, e.g.:") - log_info( - " jq '.branch = \"task/my-task\"' task.json > tmp && mv tmp task.json" - ) - return 1 - - log_info(f"Branch: {branch}") - log_info(f"Name: {task_name}") - - # ============================================================================= - # Step 1: Create Worktree (if not exists) - # ============================================================================= - if not worktree_path or not Path(worktree_path).is_dir(): - log_info("Step 1: Creating worktree...") - - # Record current branch as base_branch (PR target) - _, base_branch_out, _ = _run_git_command( - ["branch", "--show-current"], cwd=project_root - ) - base_branch = base_branch_out.strip() - log_info(f"Base branch (PR target): {base_branch}") - - # Calculate worktree path - worktree_base = get_worktree_base_dir(project_root) - worktree_base.mkdir(parents=True, exist_ok=True) - worktree_base = worktree_base.resolve() - worktree_path_obj = worktree_base / branch - worktree_path = str(worktree_path_obj) - - # Create parent directory - worktree_path_obj.parent.mkdir(parents=True, exist_ok=True) - - # Create branch if not exists - ret, _, _ = _run_git_command( - ["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"], - cwd=project_root, - ) - if ret == 0: - log_info("Branch exists, checking out...") - ret, _, err = _run_git_command( - ["worktree", "add", worktree_path, branch], cwd=project_root - ) - else: - log_info(f"Creating new branch: {branch}") - ret, _, err = _run_git_command( - ["worktree", "add", "-b", branch, worktree_path], cwd=project_root - ) - - if ret != 0: - log_error(f"Failed to create worktree: {err}") - return 1 - - log_success(f"Worktree created: {worktree_path}") - - # Update task.json with worktree_path and base_branch - task_data["worktree_path"] = worktree_path - task_data["base_branch"] = base_branch - _write_json_file(task_json_path, task_data) - - # ----- Copy environment files ----- - log_info("Copying environment files...") - copy_list = get_worktree_copy_files(project_root) - copy_count = 0 - - for item in copy_list: - if not item: - continue - - source = project_root / item - target = Path(worktree_path) / item - - if source.is_file(): - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(str(source), str(target)) - copy_count += 1 - - if copy_count > 0: - log_success(f"Copied {copy_count} file(s)") - - # ----- Copy task directory (may not be committed yet) ----- - log_info("Copying task directory...") - task_target_dir = Path(worktree_path) / task_dir_relative - task_target_dir.parent.mkdir(parents=True, exist_ok=True) - if task_target_dir.exists(): - shutil.rmtree(str(task_target_dir)) - shutil.copytree(str(task_dir_abs), str(task_target_dir)) - log_success("Task directory copied to worktree") - - # ----- Run post_create hooks ----- - log_info("Running post_create hooks...") - post_create = get_worktree_post_create_hooks(project_root) - hook_count = 0 - - for cmd in post_create: - if not cmd: - continue - - log_info(f" Running: {cmd}") - ret = subprocess.run(cmd, shell=True, cwd=worktree_path) - if ret.returncode != 0: - log_error(f"Hook failed: {cmd}") - return 1 - hook_count += 1 - - if hook_count > 0: - log_success(f"Ran {hook_count} hook(s)") - else: - log_info(f"Step 1: Using existing worktree: {worktree_path}") - - # ============================================================================= - # Step 2: Set .current-task in Worktree - # ============================================================================= - log_info("Step 2: Setting current task in worktree...") - - worktree_workflow_dir = Path(worktree_path) / DIR_WORKFLOW - worktree_workflow_dir.mkdir(parents=True, exist_ok=True) - - current_task_file = worktree_workflow_dir / FILE_CURRENT_TASK - current_task_file.write_text(task_dir_relative, encoding="utf-8") - log_success(f"Current task set: {task_dir_relative}") - - # ============================================================================= - # Step 3: Prepare and Start Claude Agent - # ============================================================================= - log_info(f"Step 3: Starting {adapter.cli_name} agent...") - - # Update task status - task_data["status"] = "in_progress" - _write_json_file(task_json_path, task_data) - - log_file = Path(worktree_path) / ".agent-log" - session_id_file = Path(worktree_path) / ".session-id" - - log_file.touch() - - # Generate session ID for resume support (Claude Code only) - # OpenCode generates its own session ID, we'll extract it from logs later - if adapter.supports_session_id_on_create: - session_id = str(uuid.uuid4()).lower() - session_id_file.write_text(session_id, encoding="utf-8") - log_info(f"Session ID: {session_id}") - else: - session_id = None # Will be extracted from logs after startup - log_info("Session ID will be extracted from logs after startup") - - # Get proxy environment variables - https_proxy = os.environ.get("https_proxy", "") - http_proxy = os.environ.get("http_proxy", "") - all_proxy = os.environ.get("all_proxy", "") - - # Start agent in background (cross-platform, no shell script needed) - env = os.environ.copy() - env["https_proxy"] = https_proxy - env["http_proxy"] = http_proxy - env["all_proxy"] = all_proxy - - # Clear nested-session detection so the new CLI process can start - # (when this script runs inside a Claude Code session, CLAUDECODE=1 is inherited) - env.pop("CLAUDECODE", None) - - # Set non-interactive env var based on platform - env.update(adapter.get_non_interactive_env()) - - # Build CLI command using adapter - # Note: Use explicit prompt to avoid confusion with CI/CD pipelines - # Also remind the model to follow its agent definition for better cross-model compatibility - cli_cmd = adapter.build_run_command( - agent="dispatch", - prompt="Follow your agent instructions to execute the task workflow. Start by reading .trellis/.current-task to get the task directory, then execute each action in task.json next_action array in order.", - session_id=session_id if adapter.supports_session_id_on_create else None, - skip_permissions=True, - verbose=True, - json_output=True, - ) - - with log_file.open("w") as log_f: - # Use shell=False for cross-platform compatibility - # creationflags for Windows, start_new_session for Unix - popen_kwargs = { - "stdout": log_f, - "stderr": subprocess.STDOUT, - "cwd": worktree_path, - "env": env, - } - if sys.platform == "win32": - popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP - else: - popen_kwargs["start_new_session"] = True - - process = subprocess.Popen(cli_cmd, **popen_kwargs) - agent_pid = process.pid - - log_success(f"Agent started with PID: {agent_pid}") - - # For OpenCode: extract session ID from logs after startup - if not adapter.supports_session_id_on_create: - import time - log_info("Waiting for session ID from logs...") - # Wait a bit for the log to have session ID - for _ in range(10): # Try for up to 5 seconds - time.sleep(0.5) - try: - log_content = log_file.read_text(encoding="utf-8", errors="replace") - session_id = adapter.extract_session_id_from_log(log_content) - if session_id: - session_id_file.write_text(session_id, encoding="utf-8") - log_success(f"Session ID extracted: {session_id}") - break - except Exception: - pass - else: - log_warn("Could not extract session ID from logs") - session_id = "unknown" - - # ============================================================================= - # Step 4: Register to Registry (in main repo, not worktree) - # ============================================================================= - log_info("Step 4: Registering agent to registry...") - - # Generate agent ID - task_id = task_data.get("id") - if not task_id: - task_id = branch.replace("/", "-") - - registry_add_agent( - task_id, worktree_path, agent_pid, task_dir_relative, project_root, platform - ) - - log_success(f"Agent registered: {task_id}") - - # ============================================================================= - # Summary - # ============================================================================= - print() - print(f"{Colors.GREEN}=== Agent Started ==={Colors.NC}") - print() - print(f" ID: {task_id}") - print(f" PID: {agent_pid}") - print(f" Session: {session_id}") - print(f" Worktree: {worktree_path}") - print(f" Task: {task_dir_relative}") - print(f" Log: {log_file}") - print(f" Registry: {registry_get_file(project_root)}") - print() - print(f"{Colors.YELLOW}To monitor:{Colors.NC} tail -f {log_file}") - print(f"{Colors.YELLOW}To stop:{Colors.NC} kill {agent_pid}") - if session_id and session_id != "unknown": - resume_cmd = adapter.get_resume_command_str(session_id, cwd=worktree_path) - print(f"{Colors.YELLOW}To resume:{Colors.NC} {resume_cmd}") - else: - print(f"{Colors.YELLOW}To resume:{Colors.NC} (session ID not available)") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/scripts/multi_agent/status.py b/.trellis/scripts/multi_agent/status.py deleted file mode 100755 index e83ac60a..00000000 --- a/.trellis/scripts/multi_agent/status.py +++ /dev/null @@ -1,817 +0,0 @@ -#!/usr/bin/env python3 -""" -Multi-Agent Pipeline: Status Monitor. - -Usage: - python3 status.py Show summary of all tasks (default) - python3 status.py -a <assignee> Filter tasks by assignee - python3 status.py --list List all worktrees and agents - python3 status.py --detail <task> Detailed task status - python3 status.py --watch <task> Watch agent log in real-time - python3 status.py --log <task> Show recent log entries - python3 status.py --registry Show agent registry -""" - -from __future__ import annotations - -import argparse -import json -import os -import subprocess -import sys -import time -from datetime import datetime -from pathlib import Path - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from common.cli_adapter import get_cli_adapter -from common.developer import ensure_developer -from common.paths import ( - FILE_TASK_JSON, - get_repo_root, - get_tasks_dir, -) -from common.phase import get_phase_info -from common.task_queue import format_task_stats, get_task_stats -from common.worktree import get_agents_dir - -# ============================================================================= -# Colors -# ============================================================================= - - -class Colors: - RED = "\033[0;31m" - GREEN = "\033[0;32m" - YELLOW = "\033[1;33m" - BLUE = "\033[0;34m" - CYAN = "\033[0;36m" - DIM = "\033[2m" - NC = "\033[0m" - - -# ============================================================================= -# Helper Functions -# ============================================================================= - - -def _read_json_file(path: Path) -> dict | None: - """Read and parse a JSON file.""" - try: - return json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None - - -def is_running(pid: int | str | None) -> bool: - """Check if PID is running.""" - if not pid: - return False - try: - pid_int = int(pid) - os.kill(pid_int, 0) - return True - except (ProcessLookupError, ValueError, PermissionError, TypeError): - return False - - -def status_color(status: str) -> str: - """Get status color.""" - colors = { - "completed": Colors.GREEN, - "in_progress": Colors.BLUE, - "planning": Colors.YELLOW, - } - return colors.get(status, Colors.DIM) - - -def get_registry_file(repo_root: Path) -> Path | None: - """Get registry file path.""" - agents_dir = get_agents_dir(repo_root) - if agents_dir: - return agents_dir / "registry.json" - return None - - -def find_agent(search: str, repo_root: Path) -> dict | None: - """Find agent by task name or ID.""" - registry_file = get_registry_file(repo_root) - if not registry_file or not registry_file.is_file(): - return None - - data = _read_json_file(registry_file) - if not data: - return None - - for agent in data.get("agents", []): - # Exact ID match - if agent.get("id") == search: - return agent - # Partial match on task_dir - task_dir = agent.get("task_dir", "") - if search in task_dir: - return agent - - return None - - -def calc_elapsed(started: str | None) -> str: - """Calculate elapsed time from ISO timestamp.""" - if not started: - return "N/A" - - try: - # Parse ISO format - if "+" in started: - started = started.split("+")[0] - if "T" in started: - start_dt = datetime.fromisoformat(started) - else: - return "N/A" - - now = datetime.now() - elapsed = (now - start_dt).total_seconds() - - if elapsed < 60: - return f"{int(elapsed)}s" - elif elapsed < 3600: - mins = int(elapsed // 60) - secs = int(elapsed % 60) - return f"{mins}m {secs}s" - else: - hours = int(elapsed // 3600) - mins = int((elapsed % 3600) // 60) - return f"{hours}h {mins}m" - except (ValueError, TypeError): - return "N/A" - - -def count_modified_files(worktree: str) -> int: - """Count modified files in worktree.""" - if not Path(worktree).is_dir(): - return 0 - - try: - result = subprocess.run( - ["git", "status", "--short"], - cwd=worktree, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - return len([line for line in result.stdout.splitlines() if line.strip()]) - except Exception: - return 0 - - -def tail_follow(file_path: Path) -> None: - """Follow a file like 'tail -f', cross-platform compatible.""" - with open(file_path, "r", encoding="utf-8", errors="replace") as f: - # Seek to end of file - f.seek(0, 2) - - while True: - line = f.readline() - if line: - print(line, end="", flush=True) - else: - time.sleep(0.1) - - -def get_last_tool(log_file: Path, platform: str = "claude") -> str | None: - """Get the last tool call from agent log. - - Supports both Claude Code and OpenCode log formats. - - Claude Code format: - {"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Read"}]}} - - OpenCode format: - {"type": "tool_use", "tool": "bash", "state": {"status": "completed"}} - """ - if not log_file.is_file(): - return None - - try: - lines = log_file.read_text(encoding="utf-8").splitlines() - for line in reversed(lines[-100:]): - try: - data = json.loads(line) - - if platform == "opencode": - # OpenCode format: {"type": "tool_use", "tool": "bash", ...} - if data.get("type") == "tool_use": - return data.get("tool") - else: - # Claude Code format: {"type": "assistant", "message": {"content": [...]}} - if data.get("type") == "assistant": - content = data.get("message", {}).get("content", []) - for item in content: - if item.get("type") == "tool_use": - return item.get("name") - except json.JSONDecodeError: - continue - except Exception: - pass - return None - - -def get_last_message(log_file: Path, max_len: int = 100, platform: str = "claude") -> str | None: - """Get the last assistant text from agent log. - - Supports both Claude Code and OpenCode log formats. - - Claude Code format: - {"type": "assistant", "message": {"content": [{"type": "text", "text": "..."}]}} - - OpenCode format: - {"type": "text", "text": "..."} - """ - if not log_file.is_file(): - return None - - try: - lines = log_file.read_text(encoding="utf-8").splitlines() - for line in reversed(lines[-100:]): - try: - data = json.loads(line) - - if platform == "opencode": - # OpenCode format: {"type": "text", "text": "..."} - if data.get("type") == "text": - text = data.get("text", "") - if text: - return text[:max_len] - else: - # Claude Code format: {"type": "assistant", "message": {"content": [...]}} - if data.get("type") == "assistant": - content = data.get("message", {}).get("content", []) - for item in content: - if item.get("type") == "text": - text = item.get("text", "") - if text: - return text[:max_len] - except json.JSONDecodeError: - continue - except Exception: - pass - return None - - -# ============================================================================= -# Commands -# ============================================================================= - - -def cmd_help() -> int: - """Show help.""" - print("""Multi-Agent Pipeline: Status Monitor - -Usage: - python3 status.py Show summary of all tasks - python3 status.py -a <assignee> Filter tasks by assignee - python3 status.py --list List all worktrees and agents - python3 status.py --detail <task> Detailed task status - python3 status.py --progress <task> Quick progress view with recent activity - python3 status.py --watch <task> Watch agent log in real-time - python3 status.py --log <task> Show recent log entries - python3 status.py --registry Show agent registry - -Examples: - python3 status.py -a taosu - python3 status.py --detail my-task - python3 status.py --progress my-task - python3 status.py --watch 01-16-worktree-support - python3 status.py --log worktree-support -""") - return 0 - - -def cmd_list(repo_root: Path) -> int: - """List worktrees and agents.""" - print(f"{Colors.BLUE}=== Git Worktrees ==={Colors.NC}") - print() - - subprocess.run(["git", "worktree", "list"], cwd=repo_root) - print() - - print(f"{Colors.BLUE}=== Registered Agents ==={Colors.NC}") - print() - - registry_file = get_registry_file(repo_root) - if not registry_file or not registry_file.is_file(): - print(" (no registry found)") - return 0 - - data = _read_json_file(registry_file) - if not data or not data.get("agents"): - print(" (no agents registered)") - return 0 - - for agent in data["agents"]: - agent_id = agent.get("id", "?") - pid = agent.get("pid") - wt = agent.get("worktree_path", "?") - started = agent.get("started_at", "?") - - if is_running(pid): - status_icon = f"{Colors.GREEN}●{Colors.NC}" - else: - status_icon = f"{Colors.RED}○{Colors.NC}" - - print(f" {status_icon} {agent_id} (PID: {pid})") - print(f" {Colors.DIM}Worktree: {wt}{Colors.NC}") - print(f" {Colors.DIM}Started: {started}{Colors.NC}") - print() - - return 0 - - -def cmd_summary(repo_root: Path, filter_assignee: str | None = None) -> int: - """Show summary of all tasks.""" - ensure_developer(repo_root) - - tasks_dir = get_tasks_dir(repo_root) - if not tasks_dir.is_dir(): - print("No tasks directory found") - return 0 - - registry_file = get_registry_file(repo_root) - - # Count running agents - running_count = 0 - total_agents = 0 - - if registry_file and registry_file.is_file(): - data = _read_json_file(registry_file) - if data: - agents = data.get("agents", []) - total_agents = len(agents) - for agent in agents: - if is_running(agent.get("pid")): - running_count += 1 - - # Task queue stats - task_stats = get_task_stats(repo_root) - - print(f"{Colors.BLUE}=== Multi-Agent Status ==={Colors.NC}") - print( - f" Agents: {Colors.GREEN}{running_count}{Colors.NC} running / {total_agents} registered" - ) - print(f" Tasks: {format_task_stats(task_stats)}") - print() - - # Process tasks - running_tasks = [] - stopped_tasks = [] - regular_tasks = [] - - registry_data = ( - _read_json_file(registry_file) - if registry_file and registry_file.is_file() - else None - ) - - for d in sorted(tasks_dir.iterdir()): - if not d.is_dir() or d.name == "archive": - continue - - name = d.name - task_json = d / FILE_TASK_JSON - status = "unknown" - assignee = "unassigned" - priority = "P2" - - if task_json.is_file(): - data = _read_json_file(task_json) - if data: - status = data.get("status", "unknown") - assignee = data.get("assignee", "unassigned") - priority = data.get("priority", "P2") - - # Filter by assignee - if filter_assignee and assignee != filter_assignee: - continue - - # Check agent status - agent_info = None - if registry_data: - for agent in registry_data.get("agents", []): - if name in agent.get("task_dir", ""): - agent_info = agent - break - - if agent_info: - pid = agent_info.get("pid") - worktree = agent_info.get("worktree_path", "") - started = agent_info.get("started_at") - agent_platform = agent_info.get("platform", "claude") - - if is_running(pid): - # Running agent - task_dir_rel = agent_info.get("task_dir", "") - worktree_task_json = Path(worktree) / task_dir_rel / "task.json" - phase_source = task_json - if worktree_task_json.is_file(): - phase_source = worktree_task_json - - phase_info_str = get_phase_info(phase_source) - elapsed = calc_elapsed(started) - modified = count_modified_files(worktree) - - worktree_data = _read_json_file(phase_source) - branch = worktree_data.get("branch", "N/A") if worktree_data else "N/A" - - log_file = Path(worktree) / ".agent-log" - last_tool = get_last_tool(log_file, platform=agent_platform) - - running_tasks.append( - { - "name": name, - "priority": priority, - "assignee": assignee, - "phase_info": phase_info_str, - "elapsed": elapsed, - "branch": branch, - "modified": modified, - "last_tool": last_tool, - "pid": pid, - } - ) - else: - # Stopped agent - task_dir_rel = agent_info.get("task_dir", "") - worktree_task_json = Path(worktree) / task_dir_rel / "task.json" - worktree_status = "unknown" - - if worktree_task_json.is_file(): - wt_data = _read_json_file(worktree_task_json) - if wt_data: - worktree_status = wt_data.get("status", "unknown") - - session_id_file = Path(worktree) / ".session-id" - log_file = Path(worktree) / ".agent-log" - - stopped_tasks.append( - { - "name": name, - "worktree": worktree, - "status": worktree_status, - "session_id_file": session_id_file, - "log_file": log_file, - "platform": agent_info.get("platform", "claude"), - } - ) - else: - # Regular task - regular_tasks.append( - { - "name": name, - "status": status, - "priority": priority, - "assignee": assignee, - } - ) - - # Output running agents - if running_tasks: - print(f"{Colors.CYAN}Running Agents:{Colors.NC}") - for t in running_tasks: - priority_color = ( - Colors.RED - if t["priority"] == "P0" - else (Colors.YELLOW if t["priority"] == "P1" else Colors.BLUE) - ) - print( - f"{Colors.GREEN}▶{Colors.NC} {Colors.CYAN}{t['name']}{Colors.NC} {Colors.GREEN}[running]{Colors.NC} {priority_color}[{t['priority']}]{Colors.NC} @{t['assignee']}" - ) - print(f" Phase: {t['phase_info']}") - print(f" Elapsed: {t['elapsed']}") - print(f" Branch: {Colors.DIM}{t['branch']}{Colors.NC}") - print(f" Modified: {t['modified']} file(s)") - if t["last_tool"]: - print(f" Activity: {Colors.YELLOW}{t['last_tool']}{Colors.NC}") - print(f" PID: {Colors.DIM}{t['pid']}{Colors.NC}") - print() - - # Output stopped agents - if stopped_tasks: - print(f"{Colors.RED}Stopped Agents:{Colors.NC}") - for t in stopped_tasks: - if t["status"] == "completed": - print( - f"{Colors.GREEN}✓{Colors.NC} {t['name']} {Colors.GREEN}[completed]{Colors.NC}" - ) - else: - if t["session_id_file"].is_file(): - session_id = ( - t["session_id_file"].read_text(encoding="utf-8").strip() - ) - last_msg = get_last_message(t["log_file"], 150, platform=t.get("platform", "claude")) - print( - f"{Colors.RED}○{Colors.NC} {t['name']} {Colors.RED}[stopped]{Colors.NC}" - ) - if last_msg: - print(f'{Colors.DIM}"{last_msg}"{Colors.NC}') - # Use CLI adapter for platform-specific resume command - adapter = get_cli_adapter(t.get("platform", "claude")) - resume_cmd = adapter.get_resume_command_str(session_id, cwd=t["worktree"]) - print(f"{Colors.YELLOW}{resume_cmd}{Colors.NC}") - else: - print( - f"{Colors.RED}○{Colors.NC} {t['name']} {Colors.RED}[stopped]{Colors.NC} {Colors.DIM}(no session-id){Colors.NC}" - ) - print() - - # Separator - if (running_tasks or stopped_tasks) and regular_tasks: - print(f"{Colors.DIM}───────────────────────────────────────{Colors.NC}") - print() - - # Output regular tasks grouped by assignee - if regular_tasks: - # Sort by assignee, priority, status - regular_tasks.sort( - key=lambda x: ( - x["assignee"], - {"P0": 0, "P1": 1, "P2": 2, "P3": 3}.get(x["priority"], 2), - {"in_progress": 0, "planning": 1, "completed": 2}.get(x["status"], 1), - ) - ) - - current_assignee = None - for t in regular_tasks: - if t["assignee"] != current_assignee: - if current_assignee is not None: - print() - print(f"{Colors.CYAN}@{t['assignee']}:{Colors.NC}") - current_assignee = t["assignee"] - - color = status_color(t["status"]) - priority_color = ( - Colors.RED - if t["priority"] == "P0" - else (Colors.YELLOW if t["priority"] == "P1" else Colors.BLUE) - ) - print( - f" {color}●{Colors.NC} {t['name']} ({t['status']}) {priority_color}[{t['priority']}]{Colors.NC}" - ) - - if running_tasks: - print() - print(f"{Colors.DIM}─────────────────────────────────────{Colors.NC}") - print(f"{Colors.DIM}Use --progress <name> for quick activity view{Colors.NC}") - print(f"{Colors.DIM}Use --detail <name> for more info{Colors.NC}") - - print() - return 0 - - -def cmd_detail(target: str, repo_root: Path) -> int: - """Show detailed task status.""" - agent = find_agent(target, repo_root) - if not agent: - print(f"Agent not found: {target}") - return 1 - - agent_id = agent.get("id", "?") - pid = agent.get("pid") - worktree = agent.get("worktree_path", "?") - task_dir = agent.get("task_dir", "?") - started = agent.get("started_at", "?") - platform = agent.get("platform", "claude") - - # Check for session-id - session_id = "" - session_id_file = Path(worktree) / ".session-id" - if session_id_file.is_file(): - session_id = session_id_file.read_text(encoding="utf-8").strip() - - print(f"{Colors.BLUE}=== Agent Detail: {agent_id} ==={Colors.NC}") - print() - print(f" ID: {agent_id}") - print(f" PID: {pid}") - print(f" Session: {session_id or 'N/A'}") - print(f" Worktree: {worktree}") - print(f" Task Dir: {task_dir}") - print(f" Started: {started}") - print() - - # Status - if is_running(pid): - print(f" Status: {Colors.GREEN}Running{Colors.NC}") - else: - print(f" Status: {Colors.RED}Stopped{Colors.NC}") - if session_id: - print() - # Use CLI adapter for platform-specific resume command - adapter = get_cli_adapter(platform) - resume_cmd = adapter.get_resume_command_str(session_id, cwd=worktree) - print(f" {Colors.YELLOW}Resume:{Colors.NC} {resume_cmd}") - - # Task info - task_json = repo_root / task_dir / "task.json" - if task_json.is_file(): - print() - print(f"{Colors.BLUE}=== Task Info ==={Colors.NC}") - print() - data = _read_json_file(task_json) - if data: - print(f" Status: {data.get('status', 'unknown')}") - print(f" Branch: {data.get('branch', 'N/A')}") - print(f" Base Branch: {data.get('base_branch', 'N/A')}") - - # Git changes - if Path(worktree).is_dir(): - print() - print(f"{Colors.BLUE}=== Git Changes ==={Colors.NC}") - print() - - result = subprocess.run( - ["git", "status", "--short"], - cwd=worktree, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - changes = result.stdout.strip() - if changes: - for line in changes.splitlines()[:10]: - print(f" {line}") - total = len(changes.splitlines()) - if total > 10: - print(f" ... and {total - 10} more") - else: - print(" (no changes)") - - print() - return 0 - - -def cmd_watch(target: str, repo_root: Path) -> int: - """Watch agent log in real-time.""" - agent = find_agent(target, repo_root) - if not agent: - print(f"Agent not found: {target}") - return 1 - - worktree = agent.get("worktree_path", "") - log_file = Path(worktree) / ".agent-log" - - if not log_file.is_file(): - print(f"Log file not found: {log_file}") - return 1 - - print(f"{Colors.BLUE}Watching:{Colors.NC} {log_file}") - print(f"{Colors.DIM}Press Ctrl+C to stop{Colors.NC}") - print() - - try: - tail_follow(log_file) - except KeyboardInterrupt: - print() # Clean newline after Ctrl+C - return 0 - - -def cmd_log(target: str, repo_root: Path) -> int: - """Show recent log entries.""" - agent = find_agent(target, repo_root) - if not agent: - print(f"Agent not found: {target}") - return 1 - - worktree = agent.get("worktree_path", "") - platform = agent.get("platform", "claude") - log_file = Path(worktree) / ".agent-log" - - if not log_file.is_file(): - print(f"Log file not found: {log_file}") - return 1 - - print(f"{Colors.BLUE}=== Recent Log: {target} ==={Colors.NC}") - print(f"{Colors.DIM}Platform: {platform}{Colors.NC}") - print() - - lines = log_file.read_text(encoding="utf-8").splitlines() - for line in lines[-50:]: - try: - data = json.loads(line) - msg_type = data.get("type", "") - - if platform == "opencode": - # OpenCode format - if msg_type == "text": - text = data.get("text", "") - if text: - display = text[:300] - if len(text) > 300: - display += "..." - print(f"{Colors.BLUE}[TEXT]{Colors.NC} {display}") - elif msg_type == "tool_use": - tool_name = data.get("tool", "unknown") - status = data.get("state", {}).get("status", "") - print(f"{Colors.YELLOW}[TOOL]{Colors.NC} {tool_name} ({status})") - elif msg_type == "step_start": - print(f"{Colors.CYAN}[STEP]{Colors.NC} Start") - elif msg_type == "step_finish": - reason = data.get("reason", "") - print(f"{Colors.CYAN}[STEP]{Colors.NC} Finish ({reason})") - elif msg_type == "error": - error_msg = data.get("message", "") - print(f"{Colors.RED}[ERROR]{Colors.NC} {error_msg}") - else: - # Claude Code format - if msg_type == "system": - subtype = data.get("subtype", "") - print(f"{Colors.CYAN}[SYSTEM]{Colors.NC} {subtype}") - elif msg_type == "user": - content = data.get("message", {}).get("content", "") - if content: - print(f"{Colors.GREEN}[USER]{Colors.NC} {content[:200]}") - elif msg_type == "assistant": - content = data.get("message", {}).get("content", []) - if content: - item = content[0] - text = item.get("text") - tool = item.get("name") - if text: - display = text[:300] - if len(text) > 300: - display += "..." - print(f"{Colors.BLUE}[ASSISTANT]{Colors.NC} {display}") - elif tool: - print(f"{Colors.YELLOW}[TOOL]{Colors.NC} {tool}") - elif msg_type == "result": - tool_name = data.get("tool", "unknown") - print(f"{Colors.DIM}[RESULT]{Colors.NC} {tool_name} completed") - except json.JSONDecodeError: - continue - - return 0 - - -def cmd_registry(repo_root: Path) -> int: - """Show agent registry.""" - registry_file = get_registry_file(repo_root) - - print(f"{Colors.BLUE}=== Agent Registry ==={Colors.NC}") - print() - print(f"File: {registry_file}") - print() - - if registry_file and registry_file.is_file(): - data = _read_json_file(registry_file) - if data: - print(json.dumps(data, indent=2)) - else: - print("(registry not found)") - - return 0 - - -# ============================================================================= -# Main -# ============================================================================= - - -def main() -> int: - """Main entry point.""" - parser = argparse.ArgumentParser(description="Multi-Agent Pipeline: Status Monitor") - parser.add_argument("-a", "--assignee", help="Filter by assignee") - parser.add_argument( - "--list", action="store_true", help="List all worktrees and agents" - ) - parser.add_argument("--detail", metavar="TASK", help="Detailed task status") - parser.add_argument("--progress", metavar="TASK", help="Quick progress view") - parser.add_argument("--watch", metavar="TASK", help="Watch agent log") - parser.add_argument("--log", metavar="TASK", help="Show recent log entries") - parser.add_argument("--registry", action="store_true", help="Show agent registry") - parser.add_argument("target", nargs="?", help="Target task") - - args = parser.parse_args() - repo_root = get_repo_root() - - if args.list: - return cmd_list(repo_root) - elif args.detail: - return cmd_detail(args.detail, repo_root) - elif args.progress: - return cmd_detail(args.progress, repo_root) # Similar to detail - elif args.watch: - return cmd_watch(args.watch, repo_root) - elif args.log: - return cmd_log(args.log, repo_root) - elif args.registry: - return cmd_registry(repo_root) - elif args.target: - return cmd_detail(args.target, repo_root) - else: - return cmd_summary(repo_root, args.assignee) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/scripts/task.py b/.trellis/scripts/task.py deleted file mode 100755 index 29f614ca..00000000 --- a/.trellis/scripts/task.py +++ /dev/null @@ -1,1370 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Task Management Script for Multi-Agent Pipeline. - -Usage: - python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] - python3 task.py init-context <dir> <type> # Initialize jsonl files - python3 task.py add-context <dir> <file> <path> [reason] # Add jsonl entry - python3 task.py validate <dir> # Validate jsonl files - python3 task.py list-context <dir> # List jsonl entries - python3 task.py start <dir> # Set as current task - python3 task.py finish # Clear current task - python3 task.py set-branch <dir> <branch> # Set git branch - python3 task.py set-base-branch <dir> <branch> # Set PR target branch - python3 task.py set-scope <dir> <scope> # Set scope for PR title - python3 task.py create-pr [dir] [--dry-run] # Create PR from task - python3 task.py archive <task-name> # Archive completed task - python3 task.py list # List active tasks - python3 task.py list-archive [month] # List archived tasks - python3 task.py add-subtask <parent-dir> <child-dir> # Link child to parent - python3 task.py remove-subtask <parent-dir> <child-dir> # Unlink child from parent -""" - -from __future__ import annotations - -import sys - -# IMPORTANT: Force stdout to use UTF-8 on Windows -# This fixes UnicodeEncodeError when outputting non-ASCII characters -if sys.platform == "win32": - import io as _io - if hasattr(sys.stdout, "reconfigure"): - sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] - elif hasattr(sys.stdout, "detach"): - sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr] - -import argparse -import json -import re -import sys -from datetime import datetime -from pathlib import Path - -from common.cli_adapter import get_cli_adapter_auto -from common.git_context import _run_git_command -from common.paths import ( - DIR_WORKFLOW, - DIR_TASKS, - DIR_SPEC, - DIR_ARCHIVE, - FILE_TASK_JSON, - get_repo_root, - get_developer, - get_tasks_dir, - get_current_task, - set_current_task, - clear_current_task, - generate_task_date_prefix, -) -from common.task_utils import ( - find_task_by_name, - archive_task_complete, -) -from common.config import get_hooks - - -# ============================================================================= -# Colors -# ============================================================================= - -class Colors: - RED = "\033[0;31m" - GREEN = "\033[0;32m" - YELLOW = "\033[1;33m" - BLUE = "\033[0;34m" - CYAN = "\033[0;36m" - NC = "\033[0m" - - -def colored(text: str, color: str) -> str: - """Apply color to text.""" - return f"{color}{text}{Colors.NC}" - - -# ============================================================================= -# Lifecycle Hooks -# ============================================================================= - -def _run_hooks(event: str, task_json_path: Path, repo_root: Path) -> None: - """Run lifecycle hooks for an event. - - Args: - event: Event name (e.g. "after_create"). - task_json_path: Absolute path to the task's task.json. - repo_root: Repository root for cwd and config lookup. - """ - import os - import subprocess - - commands = get_hooks(event, repo_root) - if not commands: - return - - env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)} - - for cmd in commands: - try: - result = subprocess.run( - cmd, - shell=True, - cwd=repo_root, - env=env, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - if result.returncode != 0: - print( - colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW), - file=sys.stderr, - ) - if result.stderr.strip(): - print(f" {result.stderr.strip()}", file=sys.stderr) - except Exception as e: - print( - colored(f"[WARN] Hook error ({event}): {cmd} — {e}", Colors.YELLOW), - file=sys.stderr, - ) - - -# ============================================================================= -# Helper Functions -# ============================================================================= - -def _read_json_file(path: Path) -> dict | None: - """Read and parse a JSON file.""" - try: - return json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None - - -def _write_json_file(path: Path, data: dict) -> bool: - """Write dict to JSON file.""" - try: - path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") - return True - except (OSError, IOError): - return False - - -def _slugify(title: str) -> str: - """Convert title to slug (only works with ASCII).""" - result = title.lower() - result = re.sub(r"[^a-z0-9]", "-", result) - result = re.sub(r"-+", "-", result) - result = result.strip("-") - return result - - -def _resolve_task_dir(target_dir: str, repo_root: Path) -> Path: - """Resolve task directory to absolute path. - - Supports: - - Absolute path: /path/to/task - - Relative path: .trellis/tasks/01-31-my-task - - Task name: my-task (uses find_task_by_name for lookup) - """ - if not target_dir: - return Path() - - # Absolute path - if target_dir.startswith("/"): - return Path(target_dir) - - # Relative path (contains path separator or starts with .trellis) - if "/" in target_dir or target_dir.startswith(".trellis"): - return repo_root / target_dir - - # Task name - try to find in tasks directory - tasks_dir = get_tasks_dir(repo_root) - found = find_task_by_name(target_dir, tasks_dir) - if found: - return found - - # Fallback to treating as relative path - return repo_root / target_dir - - -# ============================================================================= -# JSONL Default Content Generators -# ============================================================================= - -def get_implement_base() -> list[dict]: - """Get base implement context entries.""" - return [ - {"file": f"{DIR_WORKFLOW}/workflow.md", "reason": "Project workflow and conventions"}, - ] - - -def get_implement_backend() -> list[dict]: - """Get backend implement context entries.""" - return [ - {"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/backend/index.md", "reason": "Backend development guide"}, - ] - - -def get_implement_frontend() -> list[dict]: - """Get frontend implement context entries.""" - return [ - {"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/frontend/index.md", "reason": "Frontend development guide"}, - ] - - -def get_check_context(dev_type: str, repo_root: Path) -> list[dict]: - """Get check context entries.""" - adapter = get_cli_adapter_auto(repo_root) - - entries = [ - {"file": adapter.get_trellis_command_path("finish-work"), "reason": "Finish work checklist"}, - ] - - if dev_type in ("backend", "fullstack"): - entries.append({"file": adapter.get_trellis_command_path("check-backend"), "reason": "Backend check spec"}) - if dev_type in ("frontend", "fullstack"): - entries.append({"file": adapter.get_trellis_command_path("check-frontend"), "reason": "Frontend check spec"}) - - return entries - - -def get_debug_context(dev_type: str, repo_root: Path) -> list[dict]: - """Get debug context entries.""" - adapter = get_cli_adapter_auto(repo_root) - - entries: list[dict] = [] - - if dev_type in ("backend", "fullstack"): - entries.append({"file": adapter.get_trellis_command_path("check-backend"), "reason": "Backend check spec"}) - if dev_type in ("frontend", "fullstack"): - entries.append({"file": adapter.get_trellis_command_path("check-frontend"), "reason": "Frontend check spec"}) - - return entries - - -def _write_jsonl(path: Path, entries: list[dict]) -> None: - """Write entries to JSONL file.""" - lines = [json.dumps(entry, ensure_ascii=False) for entry in entries] - path.write_text("\n".join(lines) + "\n", encoding="utf-8") - - -# ============================================================================= -# Task Operations -# ============================================================================= - -def ensure_tasks_dir(repo_root: Path) -> Path: - """Ensure tasks directory exists.""" - tasks_dir = get_tasks_dir(repo_root) - archive_dir = tasks_dir / "archive" - - if not tasks_dir.exists(): - tasks_dir.mkdir(parents=True) - print(colored(f"Created tasks directory: {tasks_dir}", Colors.GREEN), file=sys.stderr) - - if not archive_dir.exists(): - archive_dir.mkdir(parents=True) - - return tasks_dir - - -# ============================================================================= -# Command: create -# ============================================================================= - -def cmd_create(args: argparse.Namespace) -> int: - """Create a new task.""" - repo_root = get_repo_root() - - if not args.title: - print(colored("Error: title is required", Colors.RED), file=sys.stderr) - return 1 - - # Default assignee to current developer - assignee = args.assignee - if not assignee: - assignee = get_developer(repo_root) - if not assignee: - print(colored("Error: No developer set. Run init_developer.py first or use --assignee", Colors.RED), file=sys.stderr) - return 1 - - ensure_tasks_dir(repo_root) - - # Get current developer as creator - creator = get_developer(repo_root) or assignee - - # Generate slug if not provided - slug = args.slug or _slugify(args.title) - if not slug: - print(colored("Error: could not generate slug from title", Colors.RED), file=sys.stderr) - return 1 - - # Create task directory with MM-DD-slug format - tasks_dir = get_tasks_dir(repo_root) - date_prefix = generate_task_date_prefix() - dir_name = f"{date_prefix}-{slug}" - task_dir = tasks_dir / dir_name - task_json_path = task_dir / FILE_TASK_JSON - - if task_dir.exists(): - print(colored(f"Warning: Task directory already exists: {dir_name}", Colors.YELLOW), file=sys.stderr) - else: - task_dir.mkdir(parents=True) - - today = datetime.now().strftime("%Y-%m-%d") - - # Record current branch as base_branch (PR target) - _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) - current_branch = branch_out.strip() or "main" - - task_data = { - "id": slug, - "name": slug, - "title": args.title, - "description": args.description or "", - "status": "planning", - "dev_type": None, - "scope": None, - "priority": args.priority, - "creator": creator, - "assignee": assignee, - "createdAt": today, - "completedAt": None, - "branch": None, - "base_branch": current_branch, - "worktree_path": None, - "current_phase": 0, - "next_action": [ - {"phase": 1, "action": "implement"}, - {"phase": 2, "action": "check"}, - {"phase": 3, "action": "finish"}, - {"phase": 4, "action": "create-pr"}, - ], - "commit": None, - "pr_url": None, - "subtasks": [], - "children": [], - "parent": None, - "relatedFiles": [], - "notes": "", - "meta": {}, - } - - _write_json_file(task_json_path, task_data) - - # Handle --parent: establish bidirectional link - if args.parent: - parent_dir = _resolve_task_dir(args.parent, repo_root) - parent_json_path = parent_dir / FILE_TASK_JSON - if not parent_json_path.is_file(): - print(colored(f"Warning: Parent task.json not found: {args.parent}", Colors.YELLOW), file=sys.stderr) - else: - parent_data = _read_json_file(parent_json_path) - if parent_data: - # Add child to parent's children list - parent_children = parent_data.get("children", []) - if dir_name not in parent_children: - parent_children.append(dir_name) - parent_data["children"] = parent_children - _write_json_file(parent_json_path, parent_data) - - # Set parent in child's task.json - task_data["parent"] = parent_dir.name - _write_json_file(task_json_path, task_data) - - print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr) - - print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr) - print("", file=sys.stderr) - print(colored("Next steps:", Colors.BLUE), file=sys.stderr) - print(" 1. Create prd.md with requirements", file=sys.stderr) - print(" 2. Run: python3 task.py init-context <dir> <dev_type>", file=sys.stderr) - print(" 3. Run: python3 task.py start <dir>", file=sys.stderr) - print("", file=sys.stderr) - - # Output relative path for script chaining - print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}") - - _run_hooks("after_create", task_json_path, repo_root) - return 0 - - -# ============================================================================= -# Command: init-context -# ============================================================================= - -def cmd_init_context(args: argparse.Namespace) -> int: - """Initialize JSONL context files for a task.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - dev_type = args.type - - if not dev_type: - print(colored("Error: Missing arguments", Colors.RED)) - print("Usage: python3 task.py init-context <task-dir> <dev_type>") - print(" dev_type: backend | frontend | fullstack | test | docs") - return 1 - - if not target_dir.is_dir(): - print(colored(f"Error: Directory not found: {target_dir}", Colors.RED)) - return 1 - - print(colored("=== Initializing Agent Context Files ===", Colors.BLUE)) - print(f"Target dir: {target_dir}") - print(f"Dev type: {dev_type}") - print() - - # implement.jsonl - print(colored("Creating implement.jsonl...", Colors.CYAN)) - implement_entries = get_implement_base() - if dev_type in ("backend", "test"): - implement_entries.extend(get_implement_backend()) - elif dev_type == "frontend": - implement_entries.extend(get_implement_frontend()) - elif dev_type == "fullstack": - implement_entries.extend(get_implement_backend()) - implement_entries.extend(get_implement_frontend()) - - implement_file = target_dir / "implement.jsonl" - _write_jsonl(implement_file, implement_entries) - print(f" {colored('✓', Colors.GREEN)} {len(implement_entries)} entries") - - # check.jsonl - print(colored("Creating check.jsonl...", Colors.CYAN)) - check_entries = get_check_context(dev_type, repo_root) - check_file = target_dir / "check.jsonl" - _write_jsonl(check_file, check_entries) - print(f" {colored('✓', Colors.GREEN)} {len(check_entries)} entries") - - # debug.jsonl - print(colored("Creating debug.jsonl...", Colors.CYAN)) - debug_entries = get_debug_context(dev_type, repo_root) - debug_file = target_dir / "debug.jsonl" - _write_jsonl(debug_file, debug_entries) - print(f" {colored('✓', Colors.GREEN)} {len(debug_entries)} entries") - - print() - print(colored("✓ All context files created", Colors.GREEN)) - print() - print(colored("Next steps:", Colors.BLUE)) - print(" 1. Add task-specific specs: python3 task.py add-context <dir> <jsonl> <path>") - print(" 2. Set as current: python3 task.py start <dir>") - - return 0 - - -# ============================================================================= -# Command: add-context -# ============================================================================= - -def cmd_add_context(args: argparse.Namespace) -> int: - """Add entry to JSONL context file.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - - jsonl_name = args.file - path = args.path - reason = args.reason or "Added manually" - - if not target_dir.is_dir(): - print(colored(f"Error: Directory not found: {target_dir}", Colors.RED)) - return 1 - - # Support shorthand - if not jsonl_name.endswith(".jsonl"): - jsonl_name = f"{jsonl_name}.jsonl" - - jsonl_file = target_dir / jsonl_name - full_path = repo_root / path - - entry_type = "file" - if full_path.is_dir(): - entry_type = "directory" - if not path.endswith("/"): - path = f"{path}/" - elif not full_path.is_file(): - print(colored(f"Error: Path not found: {path}", Colors.RED)) - return 1 - - # Check if already exists - if jsonl_file.is_file(): - content = jsonl_file.read_text(encoding="utf-8") - if f'"{path}"' in content: - print(colored(f"Warning: Entry already exists for {path}", Colors.YELLOW)) - return 0 - - # Add entry - entry: dict - if entry_type == "directory": - entry = {"file": path, "type": "directory", "reason": reason} - else: - entry = {"file": path, "reason": reason} - - with jsonl_file.open("a", encoding="utf-8") as f: - f.write(json.dumps(entry, ensure_ascii=False) + "\n") - - print(colored(f"Added {entry_type}: {path}", Colors.GREEN)) - return 0 - - -# ============================================================================= -# Command: validate -# ============================================================================= - -def cmd_validate(args: argparse.Namespace) -> int: - """Validate JSONL context files.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - - if not target_dir.is_dir(): - print(colored("Error: task directory required", Colors.RED)) - return 1 - - print(colored("=== Validating Context Files ===", Colors.BLUE)) - print(f"Target dir: {target_dir}") - print() - - total_errors = 0 - for jsonl_name in ["implement.jsonl", "check.jsonl", "debug.jsonl"]: - jsonl_file = target_dir / jsonl_name - errors = _validate_jsonl(jsonl_file, repo_root) - total_errors += errors - - print() - if total_errors == 0: - print(colored("✓ All validations passed", Colors.GREEN)) - return 0 - else: - print(colored(f"✗ Validation failed ({total_errors} errors)", Colors.RED)) - return 1 - - -def _validate_jsonl(jsonl_file: Path, repo_root: Path) -> int: - """Validate a single JSONL file.""" - file_name = jsonl_file.name - errors = 0 - - if not jsonl_file.is_file(): - print(f" {colored(f'{file_name}: not found (skipped)', Colors.YELLOW)}") - return 0 - - line_num = 0 - for line in jsonl_file.read_text(encoding="utf-8").splitlines(): - line_num += 1 - if not line.strip(): - continue - - try: - data = json.loads(line) - except json.JSONDecodeError: - print(f" {colored(f'{file_name}:{line_num}: Invalid JSON', Colors.RED)}") - errors += 1 - continue - - file_path = data.get("file") - entry_type = data.get("type", "file") - - if not file_path: - print(f" {colored(f'{file_name}:{line_num}: Missing file field', Colors.RED)}") - errors += 1 - continue - - full_path = repo_root / file_path - if entry_type == "directory": - if not full_path.is_dir(): - print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}") - errors += 1 - else: - if not full_path.is_file(): - print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}") - errors += 1 - - if errors == 0: - print(f" {colored(f'{file_name}: ✓ ({line_num} entries)', Colors.GREEN)}") - else: - print(f" {colored(f'{file_name}: ✗ ({errors} errors)', Colors.RED)}") - - return errors - - -# ============================================================================= -# Command: list-context -# ============================================================================= - -def cmd_list_context(args: argparse.Namespace) -> int: - """List JSONL context entries.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - - if not target_dir.is_dir(): - print(colored("Error: task directory required", Colors.RED)) - return 1 - - print(colored("=== Context Files ===", Colors.BLUE)) - print() - - for jsonl_name in ["implement.jsonl", "check.jsonl", "debug.jsonl"]: - jsonl_file = target_dir / jsonl_name - if not jsonl_file.is_file(): - continue - - print(colored(f"[{jsonl_name}]", Colors.CYAN)) - - count = 0 - for line in jsonl_file.read_text(encoding="utf-8").splitlines(): - if not line.strip(): - continue - - try: - data = json.loads(line) - except json.JSONDecodeError: - continue - - count += 1 - file_path = data.get("file", "?") - entry_type = data.get("type", "file") - reason = data.get("reason", "-") - - if entry_type == "directory": - print(f" {colored(f'{count}.', Colors.GREEN)} [DIR] {file_path}") - else: - print(f" {colored(f'{count}.', Colors.GREEN)} {file_path}") - print(f" {colored('→', Colors.YELLOW)} {reason}") - - print() - - return 0 - - -# ============================================================================= -# Command: start / finish -# ============================================================================= - -def cmd_start(args: argparse.Namespace) -> int: - """Set current task.""" - repo_root = get_repo_root() - task_input = args.dir - - if not task_input: - print(colored("Error: task directory or name required", Colors.RED)) - return 1 - - # Resolve task directory (supports task name, relative path, or absolute path) - full_path = _resolve_task_dir(task_input, repo_root) - - if not full_path.is_dir(): - print(colored(f"Error: Task not found: {task_input}", Colors.RED)) - print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.trellis/tasks/01-31-my-task')") - return 1 - - # Convert to relative path for storage - try: - task_dir = str(full_path.relative_to(repo_root)) - except ValueError: - task_dir = str(full_path) - - if set_current_task(task_dir, repo_root): - print(colored(f"✓ Current task set to: {task_dir}", Colors.GREEN)) - print() - print(colored("The hook will now inject context from this task's jsonl files.", Colors.BLUE)) - - task_json_path = full_path / FILE_TASK_JSON - _run_hooks("after_start", task_json_path, repo_root) - return 0 - else: - print(colored("Error: Failed to set current task", Colors.RED)) - return 1 - - -def cmd_finish(args: argparse.Namespace) -> int: - """Clear current task.""" - repo_root = get_repo_root() - current = get_current_task(repo_root) - - if not current: - print(colored("No current task set", Colors.YELLOW)) - return 0 - - # Resolve task.json path before clearing - task_json_path = repo_root / current / FILE_TASK_JSON - - clear_current_task(repo_root) - print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN)) - - if task_json_path.is_file(): - _run_hooks("after_finish", task_json_path, repo_root) - return 0 - - -# ============================================================================= -# Command: archive -# ============================================================================= - -def cmd_archive(args: argparse.Namespace) -> int: - """Archive completed task.""" - repo_root = get_repo_root() - task_name = args.name - - if not task_name: - print(colored("Error: Task name is required", Colors.RED), file=sys.stderr) - return 1 - - tasks_dir = get_tasks_dir(repo_root) - - # Find task directory - task_dir = find_task_by_name(task_name, tasks_dir) - - if not task_dir or not task_dir.is_dir(): - print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr) - print("Active tasks:", file=sys.stderr) - cmd_list(argparse.Namespace(mine=False, status=None)) - return 1 - - dir_name = task_dir.name - task_json_path = task_dir / FILE_TASK_JSON - - # Update status before archiving - today = datetime.now().strftime("%Y-%m-%d") - if task_json_path.is_file(): - data = _read_json_file(task_json_path) - if data: - data["status"] = "completed" - data["completedAt"] = today - _write_json_file(task_json_path, data) - - # Handle subtask relationships on archive - task_parent = data.get("parent") - task_children = data.get("children", []) - - # If this is a child, remove from parent's children list - if task_parent: - parent_dir = find_task_by_name(task_parent, tasks_dir) - if parent_dir: - parent_json = parent_dir / FILE_TASK_JSON - if parent_json.is_file(): - parent_data = _read_json_file(parent_json) - if parent_data: - parent_children = parent_data.get("children", []) - if dir_name in parent_children: - parent_children.remove(dir_name) - parent_data["children"] = parent_children - _write_json_file(parent_json, parent_data) - - # If this is a parent, clear parent field in all children - if task_children: - for child_name in task_children: - child_dir_path = find_task_by_name(child_name, tasks_dir) - if child_dir_path: - child_json = child_dir_path / FILE_TASK_JSON - if child_json.is_file(): - child_data = _read_json_file(child_json) - if child_data: - child_data["parent"] = None - _write_json_file(child_json, child_data) - - # Clear if current task - current = get_current_task(repo_root) - if current and dir_name in current: - clear_current_task(repo_root) - - # Archive - result = archive_task_complete(task_dir, repo_root) - if "archived_to" in result: - archive_dest = Path(result["archived_to"]) - year_month = archive_dest.parent.name - print(colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN), file=sys.stderr) - - # Auto-commit unless --no-commit - if not getattr(args, "no_commit", False): - _auto_commit_archive(dir_name, repo_root) - - # Return the archive path - print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}") - - # Run hooks with the archived path - archived_json = archive_dest / FILE_TASK_JSON - _run_hooks("after_archive", archived_json, repo_root) - return 0 - - return 1 - - -def _auto_commit_archive(task_name: str, repo_root: Path) -> None: - """Stage .trellis/tasks/ changes and commit after archive.""" - tasks_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}" - _run_git_command(["add", "-A", tasks_rel], cwd=repo_root) - - # Check if there are staged changes - rc, _, _ = _run_git_command( - ["diff", "--cached", "--quiet", "--", tasks_rel], cwd=repo_root - ) - if rc == 0: - print("[OK] No task changes to commit.", file=sys.stderr) - return - - commit_msg = f"chore(task): archive {task_name}" - rc, _, err = _run_git_command(["commit", "-m", commit_msg], cwd=repo_root) - if rc == 0: - print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr) - else: - print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr) - - -# ============================================================================= -# Command: add-subtask -# ============================================================================= - -def cmd_add_subtask(args: argparse.Namespace) -> int: - """Link a child task to a parent task.""" - repo_root = get_repo_root() - - parent_dir = _resolve_task_dir(args.parent_dir, repo_root) - child_dir = _resolve_task_dir(args.child_dir, repo_root) - - parent_json_path = parent_dir / FILE_TASK_JSON - child_json_path = child_dir / FILE_TASK_JSON - - if not parent_json_path.is_file(): - print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) - return 1 - - if not child_json_path.is_file(): - print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) - return 1 - - parent_data = _read_json_file(parent_json_path) - child_data = _read_json_file(child_json_path) - - if not parent_data or not child_data: - print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) - return 1 - - # Check if child already has a parent - existing_parent = child_data.get("parent") - if existing_parent: - print(colored(f"Error: Child task already has a parent: {existing_parent}", Colors.RED), file=sys.stderr) - return 1 - - # Add child to parent's children list - parent_children = parent_data.get("children", []) - child_dir_name = child_dir.name - if child_dir_name not in parent_children: - parent_children.append(child_dir_name) - parent_data["children"] = parent_children - - # Set parent in child's task.json - child_data["parent"] = parent_dir.name - - # Write both - _write_json_file(parent_json_path, parent_data) - _write_json_file(child_json_path, child_data) - - print(colored(f"Linked: {child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr) - return 0 - - -# ============================================================================= -# Command: remove-subtask -# ============================================================================= - -def cmd_remove_subtask(args: argparse.Namespace) -> int: - """Unlink a child task from a parent task.""" - repo_root = get_repo_root() - - parent_dir = _resolve_task_dir(args.parent_dir, repo_root) - child_dir = _resolve_task_dir(args.child_dir, repo_root) - - parent_json_path = parent_dir / FILE_TASK_JSON - child_json_path = child_dir / FILE_TASK_JSON - - if not parent_json_path.is_file(): - print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) - return 1 - - if not child_json_path.is_file(): - print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) - return 1 - - parent_data = _read_json_file(parent_json_path) - child_data = _read_json_file(child_json_path) - - if not parent_data or not child_data: - print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) - return 1 - - # Remove child from parent's children list - parent_children = parent_data.get("children", []) - child_dir_name = child_dir.name - if child_dir_name in parent_children: - parent_children.remove(child_dir_name) - parent_data["children"] = parent_children - - # Clear parent in child's task.json - child_data["parent"] = None - - # Write both - _write_json_file(parent_json_path, parent_data) - _write_json_file(child_json_path, child_data) - - print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr) - return 0 - - -# ============================================================================= -# Command: list -# ============================================================================= - -def _get_children_progress(children: list[str], tasks_dir: Path) -> str: - """Get children progress summary like '[2/3 done]'.""" - if not children: - return "" - done_count = 0 - total = len(children) - for child_name in children: - child_dir = tasks_dir / child_name - child_json = child_dir / FILE_TASK_JSON - if child_json.is_file(): - data = _read_json_file(child_json) - if data: - status = data.get("status", "") - if status in ("completed", "done"): - done_count += 1 - return f" [{done_count}/{total} done]" - - -def cmd_list(args: argparse.Namespace) -> int: - """List active tasks.""" - repo_root = get_repo_root() - tasks_dir = get_tasks_dir(repo_root) - current_task = get_current_task(repo_root) - developer = get_developer(repo_root) - filter_mine = args.mine - filter_status = args.status - - if filter_mine: - if not developer: - print(colored("Error: No developer set. Run init_developer.py first", Colors.RED), file=sys.stderr) - return 1 - print(colored(f"My tasks (assignee: {developer}):", Colors.BLUE)) - else: - print(colored("All active tasks:", Colors.BLUE)) - print() - - # First pass: collect all task data and identify parent/child relationships - all_tasks: dict[str, dict] = {} - if tasks_dir.is_dir(): - for d in sorted(tasks_dir.iterdir()): - if not d.is_dir() or d.name == "archive": - continue - - dir_name = d.name - task_json = d / FILE_TASK_JSON - status = "unknown" - assignee = "-" - children: list[str] = [] - parent: str | None = None - - if task_json.is_file(): - data = _read_json_file(task_json) - if data: - status = data.get("status", "unknown") - assignee = data.get("assignee", "-") - children = data.get("children", []) - parent = data.get("parent") - - all_tasks[dir_name] = { - "status": status, - "assignee": assignee, - "children": children, - "parent": parent, - } - - # Second pass: display tasks hierarchically - count = 0 - - def _print_task(dir_name: str, indent: int = 0) -> None: - nonlocal count - info = all_tasks[dir_name] - status = info["status"] - assignee = info["assignee"] - children = info["children"] - - # Apply --mine filter - if filter_mine and assignee != developer: - return - - # Apply --status filter - if filter_status and status != filter_status: - return - - relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}" - marker = "" - if relative_path == current_task: - marker = f" {colored('<- current', Colors.GREEN)}" - - # Children progress - progress = _get_children_progress(children, tasks_dir) if children else "" - - prefix = " " * indent + " - " - - if filter_mine: - print(f"{prefix}{dir_name}/ ({status}){progress}{marker}") - else: - print(f"{prefix}{dir_name}/ ({status}){progress} [{colored(assignee, Colors.CYAN)}]{marker}") - count += 1 - - # Print children indented - for child_name in children: - if child_name in all_tasks: - _print_task(child_name, indent + 1) - - # Display only top-level tasks (those without a parent) - for dir_name in sorted(all_tasks.keys()): - info = all_tasks[dir_name] - if not info["parent"]: - _print_task(dir_name) - - if count == 0: - if filter_mine: - print(" (no tasks assigned to you)") - else: - print(" (no active tasks)") - - print() - print(f"Total: {count} task(s)") - return 0 - - -# ============================================================================= -# Command: list-archive -# ============================================================================= - -def cmd_list_archive(args: argparse.Namespace) -> int: - """List archived tasks.""" - repo_root = get_repo_root() - tasks_dir = get_tasks_dir(repo_root) - archive_dir = tasks_dir / "archive" - month = args.month - - print(colored("Archived tasks:", Colors.BLUE)) - print() - - if month: - month_dir = archive_dir / month - if month_dir.is_dir(): - print(f"[{month}]") - for d in sorted(month_dir.iterdir()): - if d.is_dir(): - print(f" - {d.name}/") - else: - print(f" No archives for {month}") - else: - if archive_dir.is_dir(): - for month_dir in sorted(archive_dir.iterdir()): - if month_dir.is_dir(): - month_name = month_dir.name - count = sum(1 for d in month_dir.iterdir() if d.is_dir()) - print(f"[{month_name}] - {count} task(s)") - - return 0 - - -# ============================================================================= -# Command: set-branch -# ============================================================================= - -def cmd_set_branch(args: argparse.Namespace) -> int: - """Set git branch for task.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - branch = args.branch - - if not branch: - print(colored("Error: Missing arguments", Colors.RED)) - print("Usage: python3 task.py set-branch <task-dir> <branch-name>") - return 1 - - task_json = target_dir / FILE_TASK_JSON - if not task_json.is_file(): - print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) - return 1 - - data = _read_json_file(task_json) - if not data: - return 1 - - data["branch"] = branch - _write_json_file(task_json, data) - - print(colored(f"✓ Branch set to: {branch}", Colors.GREEN)) - print() - print(colored("Now you can start the multi-agent pipeline:", Colors.BLUE)) - print(f" python3 ./.trellis/scripts/multi_agent/start.py {args.dir}") - return 0 - - -# ============================================================================= -# Command: set-base-branch -# ============================================================================= - -def cmd_set_base_branch(args: argparse.Namespace) -> int: - """Set the base branch (PR target) for task.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - base_branch = args.base_branch - - if not base_branch: - print(colored("Error: Missing arguments", Colors.RED)) - print("Usage: python3 task.py set-base-branch <task-dir> <base-branch>") - print("Example: python3 task.py set-base-branch <dir> develop") - print() - print("This sets the target branch for PR (the branch your feature will merge into).") - return 1 - - task_json = target_dir / FILE_TASK_JSON - if not task_json.is_file(): - print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) - return 1 - - data = _read_json_file(task_json) - if not data: - return 1 - - data["base_branch"] = base_branch - _write_json_file(task_json, data) - - print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN)) - print(f" PR will target: {base_branch}") - return 0 - - -# ============================================================================= -# Command: set-scope -# ============================================================================= - -def cmd_set_scope(args: argparse.Namespace) -> int: - """Set scope for PR title.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - scope = args.scope - - if not scope: - print(colored("Error: Missing arguments", Colors.RED)) - print("Usage: python3 task.py set-scope <task-dir> <scope>") - return 1 - - task_json = target_dir / FILE_TASK_JSON - if not task_json.is_file(): - print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) - return 1 - - data = _read_json_file(task_json) - if not data: - return 1 - - data["scope"] = scope - _write_json_file(task_json, data) - - print(colored(f"✓ Scope set to: {scope}", Colors.GREEN)) - return 0 - - -# ============================================================================= -# Command: create-pr (delegates to multi-agent script) -# ============================================================================= - -def cmd_create_pr(args: argparse.Namespace) -> int: - """Create PR from task - delegates to multi_agent/create_pr.py.""" - import subprocess - script_dir = Path(__file__).parent - create_pr_script = script_dir / "multi_agent" / "create_pr.py" - - cmd = [sys.executable, str(create_pr_script)] - if args.dir: - cmd.append(args.dir) - if args.dry_run: - cmd.append("--dry-run") - - result = subprocess.run(cmd) - return result.returncode - - -# ============================================================================= -# Help -# ============================================================================= - -def show_usage() -> None: - """Show usage help.""" - print("""Task Management Script for Multi-Agent Pipeline - -Usage: - python3 task.py create <title> Create new task directory - python3 task.py create <title> --parent <dir> Create task as child of parent - python3 task.py init-context <dir> <dev_type> Initialize jsonl files - python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl - python3 task.py validate <dir> Validate jsonl files - python3 task.py list-context <dir> List jsonl entries - python3 task.py start <dir> Set as current task - python3 task.py finish Clear current task - python3 task.py set-branch <dir> <branch> Set git branch for multi-agent - python3 task.py set-scope <dir> <scope> Set scope for PR title - python3 task.py create-pr [dir] [--dry-run] Create PR from task - python3 task.py archive <task-name> Archive completed task - python3 task.py add-subtask <parent> <child> Link child task to parent - python3 task.py remove-subtask <parent> <child> Unlink child from parent - python3 task.py list [--mine] [--status <status>] List tasks - python3 task.py list-archive [YYYY-MM] List archived tasks - -Arguments: - dev_type: backend | frontend | fullstack | test | docs - -List options: - --mine, -m Show only tasks assigned to current developer - --status, -s <s> Filter by status (planning, in_progress, review, completed) - -Examples: - python3 task.py create "Add login feature" --slug add-login - python3 task.py create "Child task" --slug child --parent .trellis/tasks/01-21-parent - python3 task.py init-context .trellis/tasks/01-21-add-login backend - python3 task.py add-context <dir> implement .trellis/spec/backend/auth.md "Auth guidelines" - python3 task.py set-branch <dir> task/add-login - python3 task.py start .trellis/tasks/01-21-add-login - python3 task.py create-pr # Uses current task - python3 task.py create-pr <dir> --dry-run # Preview without changes - python3 task.py finish - python3 task.py archive add-login - python3 task.py add-subtask parent-task child-task # Link existing tasks - python3 task.py remove-subtask parent-task child-task - python3 task.py list # List all active tasks - python3 task.py list --mine # List my tasks only - python3 task.py list --mine --status in_progress # List my in-progress tasks -""") - - -# ============================================================================= -# Main Entry -# ============================================================================= - -def main() -> int: - """CLI entry point.""" - parser = argparse.ArgumentParser( - description="Task Management Script for Multi-Agent Pipeline", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - subparsers = parser.add_subparsers(dest="command", help="Commands") - - # create - p_create = subparsers.add_parser("create", help="Create new task") - p_create.add_argument("title", help="Task title") - p_create.add_argument("--slug", "-s", help="Task slug") - p_create.add_argument("--assignee", "-a", help="Assignee developer") - p_create.add_argument("--priority", "-p", default="P2", help="Priority (P0-P3)") - p_create.add_argument("--description", "-d", help="Task description") - p_create.add_argument("--parent", help="Parent task directory (establishes subtask link)") - - # init-context - p_init = subparsers.add_parser("init-context", help="Initialize context files") - p_init.add_argument("dir", help="Task directory") - p_init.add_argument("type", help="Dev type: backend|frontend|fullstack|test|docs") - - # add-context - p_add = subparsers.add_parser("add-context", help="Add context entry") - p_add.add_argument("dir", help="Task directory") - p_add.add_argument("file", help="JSONL file (implement|check|debug)") - p_add.add_argument("path", help="File path to add") - p_add.add_argument("reason", nargs="?", help="Reason for adding") - - # validate - p_validate = subparsers.add_parser("validate", help="Validate context files") - p_validate.add_argument("dir", help="Task directory") - - # list-context - p_listctx = subparsers.add_parser("list-context", help="List context entries") - p_listctx.add_argument("dir", help="Task directory") - - # start - p_start = subparsers.add_parser("start", help="Set current task") - p_start.add_argument("dir", help="Task directory") - - # finish - subparsers.add_parser("finish", help="Clear current task") - - # set-branch - p_branch = subparsers.add_parser("set-branch", help="Set git branch") - p_branch.add_argument("dir", help="Task directory") - p_branch.add_argument("branch", help="Branch name") - - # set-base-branch - p_base = subparsers.add_parser("set-base-branch", help="Set PR target branch") - p_base.add_argument("dir", help="Task directory") - p_base.add_argument("base_branch", help="Base branch name (PR target)") - - # set-scope - p_scope = subparsers.add_parser("set-scope", help="Set scope") - p_scope.add_argument("dir", help="Task directory") - p_scope.add_argument("scope", help="Scope name") - - # create-pr - p_pr = subparsers.add_parser("create-pr", help="Create PR") - p_pr.add_argument("dir", nargs="?", help="Task directory") - p_pr.add_argument("--dry-run", action="store_true", help="Dry run mode") - - # archive - p_archive = subparsers.add_parser("archive", help="Archive task") - p_archive.add_argument("name", help="Task name") - p_archive.add_argument("--no-commit", action="store_true", help="Skip auto git commit after archive") - - # list - p_list = subparsers.add_parser("list", help="List tasks") - p_list.add_argument("--mine", "-m", action="store_true", help="My tasks only") - p_list.add_argument("--status", "-s", help="Filter by status") - - # add-subtask - p_addsub = subparsers.add_parser("add-subtask", help="Link child task to parent") - p_addsub.add_argument("parent_dir", help="Parent task directory") - p_addsub.add_argument("child_dir", help="Child task directory") - - # remove-subtask - p_rmsub = subparsers.add_parser("remove-subtask", help="Unlink child task from parent") - p_rmsub.add_argument("parent_dir", help="Parent task directory") - p_rmsub.add_argument("child_dir", help="Child task directory") - - # list-archive - p_listarch = subparsers.add_parser("list-archive", help="List archived tasks") - p_listarch.add_argument("month", nargs="?", help="Month (YYYY-MM)") - - args = parser.parse_args() - - if not args.command: - show_usage() - return 1 - - commands = { - "create": cmd_create, - "init-context": cmd_init_context, - "add-context": cmd_add_context, - "validate": cmd_validate, - "list-context": cmd_list_context, - "start": cmd_start, - "finish": cmd_finish, - "set-branch": cmd_set_branch, - "set-base-branch": cmd_set_base_branch, - "set-scope": cmd_set_scope, - "create-pr": cmd_create_pr, - "archive": cmd_archive, - "add-subtask": cmd_add_subtask, - "remove-subtask": cmd_remove_subtask, - "list": cmd_list, - "list-archive": cmd_list_archive, - } - - if args.command in commands: - return commands[args.command](args) - else: - show_usage() - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/spec/README.md b/.trellis/spec/README.md deleted file mode 100644 index d9788f93..00000000 --- a/.trellis/spec/README.md +++ /dev/null @@ -1,111 +0,0 @@ -# util-dfm 开发规范 - -> util-dfm 是 Deepin 文件管理器的工具包,包含四个独立的库模块。 - -**项目**: util-dfm (libdfm-*) -**版本**: Qt5+Qt6 双版本支持 -**语言**: C++17 - ---- - -## 库模块 - -util-dfm 包含以下四个独立库,每个库有不同的技术栈和编码规范: - -| 库 | 技术栈 | 错误处理 | 目录 | -|-----|-------|---------|------| -| **dfm-io** | GIO/GLib + Qt | GError + DFMIOErrorCode | [dfm-io/](dfm-io/) | -| **dfm-mount** | UDisks2 + GDBus + GIO | DeviceError (多来源转换) | [dfm-mount/](dfm-mount/) | -| **dfm-search** | Lucene++ + Qt | std::error_code + ErrorCategory | [dfm-search/](dfm-search/) | -| **dfm-burn** | xorriso + Qt | lastError() + errorMsg 字符串 | [dfm-burn/](dfm-burn/) | - ---- - -## 规范索引 - -### 库特定规范 - -| 层级 | 文档 | 描述 | -|------|------|------| -| **dfm-io** | [error-handling.md](dfm-io/error-handling.md) | GError 转换、DFMIOErrorCode | -| **dfm-io** | [naming-conventions.md](dfm-io/naming-conventions.md) | 命名约定 | -| **dfm-io** | [memory-management.md](dfm-io/memory-management.md) | P-impl、智能指针、g_autoptr | -| **dfm-mount** | [error-handling.md](dfm-mount/error-handling.md) | UDisks2/ GIO/GDBus 错误转换 | -| **dfm-mount** | [naming-conventions.md](dfm-mount/naming-conventions.md) | 命名约定 | -| **dfm-mount** | [memory-management.md](dfm-mount/memory-management.md) | GLib autoptr 管理模式 | -| **dfm-search** | [error-handling.md](dfm-search/error-handling.md) | std::error_code 使用指南 | -| **dfm-search** | [naming-conventions.md](dfm-search/naming-conventions.md) | m_ 前缀命名规范 | -| **dfm-search** | [memory-management.md](dfm-search/memory-management.md) | std::unique_ptr、PIMPL | -| **dfm-burn** | [error-handling.md](dfm-burn/error-handling.md) | lastError() 模式 | -| **dfm-burn** | [naming-conventions.md](dfm-burn/naming-conventions.md) | 命名约定 | -| **dfm-burn** | [memory-management.md](dfm-burn/memory-management.md) | QScopedPointer、QSharedData | - -### 通用共享规范 - -| 层级 | 文档 | 描述 | -|------|------|------| -| **shared** | [cpp-conventions.md](shared/cpp-conventions.md) | C++17 编码约定、RAII | -| **shared** | [git-conventions.md](shared/git-conventions.md) | Git 提交约定 | -| **shared** | [internationalization.md](shared/internationalization.md) | 国际化规范 | -| **shared** | [deepin-terminology.md](shared/deepin-terminology.md) | 品牌术语规范 | -| **shared** | [dbus-conventions.md](shared/dbus-conventions.md) | DBus 接口规范 | - -### 思考指南 - -| 层级 | 文档 | 描述 | -|------|------|------| -| **guides** | [root-cause-analysis.md](guides/root-cause-analysis.md) | 5-Why 根因分析 | - -### 常见问题 - -| 层级 | 文档 | 描述 | -|------|------|------| -| **big-question** | [gthread-ui-thread-safety.md](big-question/gthread-ui-thread-safety.md) | 跨线程更新 UI | -| **big-question** | [gvfs-mount-path-issues.md](big-question/gvfs-mount-path-issues.md) | GVfs 挂载路径问题 | -| **big-question** | [dbus-async-vs-sync.md](big-question/dbus-async-vs-sync.md) | DBus 异步/同步选择 | - -### 代码审查 - -| 层级 | 文档 | 描述 | -|------|------|------| -| **review** | [code-review-standards.md](review/code-review-standards.md) | 代码审查标准 | -| **review** | [reference/](review/reference/) | 架构、安全、性能审查 | - ---- - -## 快速开始 - -### 选择库开发 - -**开发前必须确认**:你在为哪个库开发? - -| 如果你在开发... | 请阅读... | -|----------------|----------| -| dfm-io (文件 I/O) | [dfm-io/index.md](dfm-io/index.md) | -| dfm-mount (设备挂载) | [dfm-mount/index.md](dfm-mount/index.md) | -| dfm-search (文件搜索) | [dfm-search/index.md](dfm-search/index.md) | -| dfm-burn (光盘刻录) | [dfm-burn/index.md](dfm-burn/index.md) | - -### 重要注意事项 - -1. **每个库的规范不同**:不要假设所有库使用相同的模式 - - dfm-io 使用 `GError` 和 `g_autoptr` - - dfm-search 使用 `std::error_code` - - dfm-burn 使用简单的 `lastError()` 模式 - -2. **禁用 signals/slots 关键字**:所有库使用 `Q_SIGNALS`/`Q_SLOTS` 宏 - -3. **Qt5/Qt6 双版本**:代码必须同时支持 - ---- - -## 核心规则摘要 - -| 规则 | 说明 | -|------|------| -| **库特定规范优先** | 遵循具体库的开发规范,而非通用规范 | -| **禁用 signals/slots** | 使用 `Q_SIGNALS`/`Q_SLOTS` 宏 | -| **错误处理** | 按库约定:GError / std::error_code / lastError() | -| **内存管理** | 按库约定:g_autoptr / std::unique_ptr / QScopedPointer | -| **国际化** | 用户文本使用 `tr()` | -| **线程安全** | 跨线程用 `QueuedConnection`,同步方法检查主线程 | diff --git a/.trellis/spec/backend/architecture-decisions.md b/.trellis/spec/backend/architecture-decisions.md deleted file mode 100644 index fae9a850..00000000 --- a/.trellis/spec/backend/architecture-decisions.md +++ /dev/null @@ -1,195 +0,0 @@ -# DDE 架构决策 - -> 基于 DDE 系统集成的架构决策和核心规则。 - ---- - -## 概览 - -本指南定义 DDE 桌面应用开发中的关键架构决策和核心规则。 - ---- - -## 核心原则 - -1. **DTK 优先于 Qt 原生** -2. **GIO 优先于 QFile(网络协议)** -3. **DBus 事件驱动优先于轮询** -4. **Polkit 鉴权优先于 sudo** -5. **Qt6 优先,V20 回退到 Qt5** - ---- - -## 决策表 - -| 场景 | 决策 | 参考文档 | -|------|------|----------| -| 显示主窗口 | `DMainWindow` 而非 `QMainWindow` | [DTK 组件指南](reference/dtk-widgets-guide.md) | -| 访问 `smb://` 路径 | GIO `GFile` 而非 `QFile` | [GVfs/GIO 集成](reference/gvfs-gio-integration.md) | -| 获取电池/网络状态 | DBus 信号监听而非轮询 | [DBus 服务使用](reference/dbus-service-usage.md) | -| 修改 `/etc` 配置 | Polkit + Helper 而非 sudo | [Polkit 认证流程](reference/polkit-auth-workflow.md) | -| 深色/浅色主题适配 | `applicationPalette()` 而非硬编码 | [DTK 组件指南](reference/dtk-widgets-guide.md) | -| 大文件 I/O | QtConcurrent 后台线程 | [GVfs/GIO 集成](reference/gvfs-gio-integration.md) | - ---- - -## 核心规则 - -### 规则 1: DTK 组件(强制性) - -| 推荐使用 | 禁止使用 | -|---------|---------| -| `DMainWindow` | `QMainWindow` | -| `DDialog` | `QDialog` | -| `DMessageBox` | `QMessageBox` | -| `DWidget` | `QWidget` | - -```cpp -// ✅ 正确 -#include <DMainWindow> -#include <DMessageBox> -DWIDGET_USE_NAMESPACE - -DMainWindow *window = new DMainWindow(); -DMessageBox::information(this, "Title", "Message"); - -// ❌ 禁止 -QMainWindow *window = new QMainWindow(); -``` - -**主题**: 使用 `DGuiApplicationHelper::instance()->applicationPalette()`,从不硬编码颜色。 - -📖 **详情**: [reference/dtk-widgets-guide.md](reference/dtk-widgets-guide.md) - -### 规则 2: GVfs/GIO for 网络协议 - -```cpp -// ✅ 正确: smb://, mtp://, dav:// -#include <gio/gio.h> -GFile *file = g_file_new_for_uri("smb://server/share/file.pdf"); -GFileInputStream *input = g_file_read(file, nullptr, &error); - -// ❌ 禁止 -QFile file("smb://server/share/file.pdf"); // 失败 -``` - -**异步 I/O**: 始终对文件操作使用 `QtConcurrent::run()`。 - -📖 **详情**: [reference/gvfs-gio-integration.md](reference/gvfs-gio-integration.md) - -### 规则 3: DBus 事件驱动 - -```cpp -// ✅ 正确: 监听信号 -QDBusConnection::systemBus().connect( - "org.freedesktop.UPower", - "/org/freedesktop/UPower", - "org.freedesktop.DBus.Properties", - "PropertiesChanged", - this, SLOT(handleEvent(QString, QVariantMap))); - -// ❌ 禁止: 轮询 -QTimer *timer = new QTimer(); -connect(timer, &QTimer::timeout, this, [](){ - QFile::read("/sys/class/power_supply/BAT0/capacity"); -}); -``` - -📖 **详情**: [reference/dbus-service-usage.md](reference/dbus-service-usage.md) - -### 规则 4: Polkit 认证 - -```cpp -// ✅ 正确 -PolkitQt1::Authority::instance()->checkAuthorizationSync( - "org.deepin.dde.policy.authentication", - PolkitQt1::UnixProcessSubject(QCoreApplication::applicationPid()), - PolkitQt1::Authority::AllowUserInteraction); - -// ❌ 禁止 -sudo ./application // 安全风险 -``` - -📖 **详情**: [reference/polkit-auth-workflow.md](reference/polkit-auth-workflow.md) - ---- - -## CMake 配置 - -参考 `cmake-conventions.md` 获取完整的 Qt6/Qt5 双版本支持。 - -```cmake -find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core) -if(QT_VERSION_MAJOR EQUAL 6) - set(DTK_VERSION_MAJOR 6) -else() - set(DTK_VERSION_MAJOR "") -endif() - -find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Gui Widgets) -find_package(Dtk${DTK_VERSION_MAJOR}Widget REQUIRED) - -target_link_libraries(app PRIVATE - Qt${QT_VERSION_MAJOR}::Widgets - Dtk${DTK_VERSION_MAJOR}::Widget -) -``` - ---- - -## Debian Control 文件 - -参考 `git-conventions.md` 获取文件约定。 - -| 文件 | Deepin | Qt | -|------|--------|-----| -| `debian/control` | V25 | Qt6 | -| `debian/control.1` | V20 | Qt5 | - -**依赖 (V25/Qt6)**: -``` -Build-Depends: - qt6-base-dev | qtbase5-dev, - libdtk6widget-dev | libdtkwidget-dev, - libpolkit-qt6-1-dev | libpolkit-qt5-1-dev -``` - ---- - -## 常见陷阱 - -| 陷阱 | 后果 | 修复 | -|------|------|------| -| GVfs 挂载点 `/run/user/UID/gvfs/` | 路径随会话变化 | → 使用 GIO URI API | -| DBus 总线类型错误 | 连接失败 | → System vs Session | -| Polkit helper 未验证输入 | 命令注入风险 | → 严格验证路径 | -| 非主线程更新 UI | 崩溃 | → 信号槽 (QueuedConnection) | -| 忽略 `GError` | 无法诊断问题 | → 检查 error 参数 | - ---- - -## 代码审查清单 - -📖 **完整清单**: [reference/code-review-checklist.md](reference/code-review-checklist.md) - -**快速检查**: -```bash -# 检查 DTK 组件使用 -grep -r "QMainWindow\|QDialog\|QMessageBox" src/ - -# 检查硬编码颜色 -grep -rE "#[0-9a-fA-F]{6}" src/ - -# 检查 GIO 使用 -grep -r "g_file_new_for_uri" src/ -``` - ---- - -## 参考文档 - -- [DTK 组件指南](reference/dtk-widgets-guide.md) - 控件使用、主题适配 -- [GVfs/GIO 集成](reference/gvfs-gio-integration.md) - 文件操作、异步 I/O -- [DBus 服务使用](reference/dbus-service-usage.md) - 系统服务集成 -- [Polkit 认证流程](reference/polkit-auth-workflow.md) - 权限控制、Helper 模式 -- [代码审查清单](reference/code-review-checklist.md) - 审查清单 diff --git a/.trellis/spec/backend/architecture-design.md b/.trellis/spec/backend/architecture-design.md deleted file mode 100644 index 08418cfc..00000000 --- a/.trellis/spec/backend/architecture-design.md +++ /dev/null @@ -1,293 +0,0 @@ -# 架构设计规范 - -> DDE 应用架构设计的规范和约束。 - ---- - -## 概览 - -本指南定义功能开发阶段的架构分析要求,遵循 DDE 约定和最佳实践。 - ---- - -## 架构设计原则 - -### 1. 分层架构 - -``` -┌─────────────────────────────────────┐ -│ UI Layer (DTK Widgets) │ -│ - DMainWindow, DDialog, DListView │ -└─────────────────────────────────────┘ - ↕ Signals/Slots -┌─────────────────────────────────────┐ -│ Business Logic Layer │ -│ - Controllers, Services, Models │ -└─────────────────────────────────────┘ - ↕ -┌─────────────────────────────────────┐ -│ Data Access Layer │ -│ - GIO/GVfs, DBus, QSettings │ -└─────────────────────────────────────┘ -``` - -### 2. 命名空间约定 - -```cpp -// 基础层 - dfmbase -namespace dfmbase { - namespace interfaces { ... } - namespace widgets { ... } - namespace utils { ... } -} - -// 框架层 - dpf (Deepin Plugin Framework) -namespace dpf { - namespace event { ... } - namespace lifecycle { ... } -} - -// 扩展层 - Extension -namespace Extension { - namespace menu { ... } - namespace emblemicon { ... } -} -``` - -### 3. 插件化架构 - -参考 `plugin-architecture.md` 获取完整的插件系统规范。 - ---- - -## DDE 约定遵循 - -### 必须使用的组件 - -| 场景 | 必须使用 | 禁止使用 | -|------|---------|---------| -| 主窗口 | `DMainWindow` | `QMainWindow` | -| 对话框 | `DDialog` | `QDialog` | -| 消息框 | `DMessageBox` | `QMessageBox` | -| 基础组件 | `DWidget` | `QWidget` | -| 列表视图 | `DListView` | `QListView` | -| 输入框 | `DLineEdit` | `QLineEdit` | - -### 主题适配 - -```cpp -// ✅ 正确 -QPalette palette = DGuiApplicationHelper::instance()->applicationPalette(); -widget->setPalette(palette); - -// ❌ 错误 -widget->setStyleSheet("color: #333333;"); -``` - -### 国际化 - -```cpp -// 所有面向用户的文本必须使用 tr() -button->setText(tr("Save")); -dialog->setTitle(tr("Confirm")); - -// CMake 添加 TS 文件 -find_package(Qt6LinguistTools REQUIRED) -qt_add_translations(app TS_FILES translations/app_zh_CN.ts) -``` - ---- - -## 接口定义 - -### 模型接口 - -```cpp -// 优先使用 QAbstractListModel/QAbstractTableModel -class FileListModel : public QAbstractListModel -{ - Q_OBJECT -public: - enum Roles { - FilePathRole = Qt::UserRole + 1, - FileNameRole, - FileSizeRole - }; - - explicit FileListModel(QObject *parent = nullptr); - - // 必须实现的虚函数 - int rowCount(const QModelIndex &parent = QModelIndex()) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - QHash<int, QByteArray> roleNames() const override; -}; -``` - -### 服务接口 - -```cpp -// 定义清晰的服务接口 -class IFileService { -public: - virtual ~IFileService() = default; - - virtual Result copyFile(const QString &source, const QString &dest) = 0; - virtual Result moveFile(const QString &source, const QString &dest) = 0; - virtual Result deleteFile(const QString &path) = 0; -}; -``` - ---- - -## 模块结构 - -### 标准模块目录 - -``` -src/ -├── widgets/ # UI 组件 -├── controllers/ # 控制器 -├── models/ # 数据模型 -├── services/ # 服务 -├── utils/ # 工具函数 -└── interfaces/ # 接口定义 -``` - -### Pimpl 模式实现 - -```cpp -// interface.h -class MyWidget : public DWidget { - Q_OBJECT -public: - explicit MyWidget(QWidget *parent = nullptr); - ~MyWidget() override; - - void setValue(int value); - int value() const; - -private: - class Private; - QScopedPointer<Private> d; -}; - -// impl.cpp -class MyWidget::Private { -public: - int value = 0; - DLabel *label = nullptr; - DLineEdit *lineEdit = nullptr; -}; - -MyWidget::MyWidget(QWidget *parent) - : DWidget(parent) - , d(new Private()) -{ - d->label = new DLabel(this); - d->lineEdit = new DLineEdit(this); -} -``` - ---- - -## 并发和线程 - -### 后台任务模式 - -```cpp -// 使用 QtConcurrent 执行耗时操作 -void Controller::loadLargeFile(const QString &path) { - QFuture<Result> future = QtConcurrent::run([path]() { - return FileService::loadFile(path); - }); - - QFutureWatcher<Result> *watcher = new QFutureWatcher<Result>(this); - connect(watcher, &QFutureWatcher<Result>::finished, this, [this, watcher]() { - Result result = watcher->result(); - handleLoadComplete(result); - watcher->deleteLater(); - }); - watcher->setFuture(future); -} -``` - -### 线程安全信号 - -```cpp -// 跨线程通信必须使用 QueuedConnection -connect(worker, &Worker::progress, ui, &UI::updateProgress, - Qt::QueuedConnection); -``` - ---- - -## 错误处理 - -### Result 模式 - -```cpp -struct Result { - bool success = false; - QString error; - QVariant data; - - static Result ok(const QVariant &data = {}) { - return {true, QString(), data}; - } - - static Result fail(const QString &error) { - return {false, error, QVariant()}; - } -}; - -// 使用 -Result result = service.copyFile(source, dest); -if (!result.success) { - DDialog dialog; - dialog.setTitle(tr("Error")); - dialog.setMessage(result.error); - dialog.exec(); -} -``` - -### 异常处理 - -```cpp -// DDE 应用优先使用错误码而非异常 -// 如果使用异常,确保捕获所有路径 -try { - // 操作 -} catch (const std::exception &e) { - qWarning() << "Exception:" << e.what(); - // 显示错误对话框 -} -``` - ---- - -## 实施计划 (WBS) - -### 1. 需求分析 -- [ ] 收集功能需求 -- [ ] 识别 DDE 相关约束 -- [ ] 评估技术可行性 - -### 2. 架构设计 -- [ ] 设计模块结构 -- [ ] 定义接口契约 -- [ ] 规划数据流 - -### 3. 原型设计 -- [ ] 创建 UI 原型 -- [ ] 验证 DTK 组件选择 -- [ ] 用户确认 - -### 4. 详细设计 -- [ ] 设计数据库/存储 -- [ ] 设计 API/DBus 接口 -- [ ] 设计测试策略 - -### 5. 实施计划 -- [ ] 任务分解 -- [ ] 依赖关系 -- [ ] 时间估算 diff --git a/.trellis/spec/backend/bug-fix-standards.md b/.trellis/spec/backend/bug-fix-standards.md deleted file mode 100644 index 865e9b68..00000000 --- a/.trellis/spec/backend/bug-fix-standards.md +++ /dev/null @@ -1,223 +0,0 @@ -# Bug 修复规范 - -> DDE 应用 Bug 修复的规范和约束。 - ---- - -## 概览 - -本指南定义 Bug 修复的标准流程和约束,遵循最小修改原则。 - ---- - -## 核心原则 - -### 最小修改原则 - -Bug 修复必须遵循最小修改范围: -- 只修复问题本身,不重构周边代码 -- 不添加新功能 -- 保持现有代码风格 -- 添加注释说明修复原因 - -```cpp -// ❌ 错误: 重构整段代码 -void processFile() { - // 完全重写的代码... -} - -// ✅ 正确: 只修复 Bug -void processFile() { - // ... 原有代码 ... - if (file.isOpen()) { // 添加缺失的检查 - file.write(data); - } - // ... 原有代码 ... -} -``` - -### 根因分析优先 - -在修复之前必须找到根本原因。使用 5-Why 方法系统性分析。 - ---- - - -## 修复流程 - -### 1. 信息收集 - -- 收集 Bug 描述、复现步骤、期望结果 -- 获取日志、截图和环境信息 -- 确认影响范围和优先级 - -### 2. 根因分析 - -使用 `guides/root-cause-analysis.md` 中的 5-Why 方法: - -| Level | Question | Answer | Evidence | -|-------|----------|--------|----------| -| Why 1 | 为什么会出现这个现象? | [直接原因] | [证据] | -| Why 2 | 为什么会有这个直接原因? | [深层原因] | [证据] | -| Why 3 | 为什么允许这个条件? | [系统原因] | [证据] | -| Why 4 | 为什么设计允许这个问题? | [设计原因] | [证据] | -| Why 5 | 根本原因是什么? | [根本原因] | [证据] | - -### 3. 设计修复方案 - -- 针对根因设计最小修复 -- 评估修复的影响范围 -- 用户确认方案 - -### 4. 实施修改 - -- 在最小范围内修改代码 -- 添加注释说明修复原因 -- 确保不引入新问题 - -### 5. 验证 - -- 编译通过,无警告 -- 运行相关测试 -- 手动验证修复效果 - ---- - -## 修复合约 - -### 必须做 - -- [ ] 找到根本原因(5-Why 分析) -- [ ] 设计最小修复方案 -- [ ] 添加修复注释 -- [ ] 验证编译和测试 -- [ ] 手动验证修复效果 - -### 禁止做 - -- [ ] 重构周边代码 -- [ ] 添加新功能 -- [ ] 修改不相关的逻辑 -- [ ] 改变现有接口签名 -- [ ] 跳过根因分析 - ---- - -## 常见修复模式 - -### 边界条件修复 - -```cpp -// ❌ 错误: 没有检查边界 -QString getFileContent(const QString &path) { - QFile file(path); - return file.readAll(); // 如果文件不存在会失败 -} - -// ✅ 正确: 添加边界检查 -QString getFileContent(const QString &path) { - QFile file(path); - if (!file.exists()) { - qWarning() << "File not found:" << path; - return QString(); - } - if (!file.open(QIODevice::ReadOnly)) { - qWarning() << "Failed to open file:" << path; - return QString(); - } - return file.readAll(); -} -``` - -### 空指针修复 - -```cpp -// ❌ 错误: 可能空指针 -void processItem(Item *item) { - qDebug() << item->name(); // 如果 item 为空会崩溃 -} - -// ✅ 正确: 添加空指针检查 -void processItem(Item *item) { - if (!item) { - qWarning() << "Item is null"; - return; - } - qDebug() << item->name(); -} -``` - -### 信号槽连接修复 - -```cpp -// ❌ 错误: 跨线程没有指定连接类型 -connect(worker, &Worker::signal, uiWidget, &Widget::slot); - -// ✅ 正确: 使用 QueuedConnection -connect(worker, &Worker::signal, uiWidget, &Widget::slot, - Qt::QueuedConnection); -``` - -### 内存泄漏修复 - -```cpp -// ❌ 错误: 没有父对象 -DWidget *widget = new DWidget(); // 泄漏 - -// ✅ 正确: 设置父对象 -DWidget *widget = new DWidget(parentWidget); -``` - ---- - -## 注释规范 - -修复必须添加注释说明原因: - -```cpp -// Fix: 添加文件存在性检查,防止空指针崩溃 -// Bug: #12345 - 当文件不存在时崩溃 -if (!file.exists()) { - return false; -} - -// Fix: 使用 QueuedConnection 跨线程通信 -// Bug: #12346 - Worker 线程直接更新 UI 导致崩溃 -connect(worker, &Worker::progress, ui, &UI::updateProgress, - Qt::QueuedConnection); -``` - ---- - -## 测试验证 - -### 单元测试 - -为修复添加回归测试: - -```cpp -void TestBugFix::testFileNotExistence() { - // Bug: #12345 - 文件不存在时崩溃 - QString content = FileService::getContent("/nonexistent/file"); - QCOMPARE(content, QString()); -} -``` - -### 手动验证 - -- [ ] 按照 Bug 复现步骤验证 -- [ ] 检查修复不引入新问题 -- [ ] 验证相关功能正常 - ---- - -## 修复完成标准 - -- [ ] 根因分析完成(5-Why 表格) -- [ ] 修复方案用户确认 -- [ ] 代码修改最小化 -- [ ] 添加修复注释 -- [ ] 编译通过,无警告 -- [ ] 单元测试通过 -- [ ] 手动验证修复效果 -- [ ] 相关人员审查通过 diff --git a/.trellis/spec/backend/cmake-conventions.md b/.trellis/spec/backend/cmake-conventions.md deleted file mode 100644 index 0ae1ab15..00000000 --- a/.trellis/spec/backend/cmake-conventions.md +++ /dev/null @@ -1,289 +0,0 @@ -# CMake 约定 - -> DDE 应用的 CMake 构建约定和 Qt6/Qt5 双版本支持。 - ---- - -## 概览 - -本指南定义 DDE 应用 CMake 构建的标准约定,支持 Qt6/Qt5 双版本。 - ---- - -## Qt6/Qt5 双版本支持 - -### 基础配置 - -```cmake -# 最低 CMake 版本 -cmake_minimum_required(VERSION 3.10) - -# 项目信息 -project(MyApp VERSION 1.0.0) - -# 启用 C++17 -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_CXX_EXTENSIONS OFF) - -# Qt 版本检测 -find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core) -if(QT_VERSION_MAJOR EQUAL 6) - set(DTK_VERSION_MAJOR 6) - set(QT_VERSION_MAJOR 6) -else() - set(DTK_VERSION_MAJOR "") - set(QT_VERSION_MAJOR 5) -endif() - -message(STATUS "Qt version: ${QT_VERSION_MAJOR}") -message(STATUS "DTK version: ${DTK_VERSION_MAJOR}") - -# 查找 Qt 和 DTK -find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS - Core - Gui - Widgets - Concurrent - DBus - Sql - Network -) - -find_package(Dtk${DTK_VERSION_MAJOR}Widget REQUIRED) -``` - ---- - -## 目标配置 - -### 可执行文件 - -```cmake -# 主可执行文件 -add_executable(myapp - src/main.cpp - src/mainwindow.cpp - src/mainwindow.h -) - -target_link_libraries(myapp PRIVATE - Qt${QT_VERSION_MAJOR}::Core - Qt${QT_VERSION_MAJOR}::Gui - Qt${QT_VERSION_MAJOR}::Widgets - Qt${QT_VERSION_MAJOR}::Concurrent - Qt${QT_VERSION_MAJOR}::DBus - Qt${QT_VERSION_MAJOR}::Sql - Qt${QT_VERSION_MAJOR}::Network - Dtk${DTK_VERSION_MAJOR}::Widget -) - -# 编译定义 -target_compile_definitions(myapp PRIVATE - QT_DISABLE_DEPRECATED_BEFORE=0x050F00 -) - -# 使用 DTK 命名空间 -target_compile_definitions(myapp PRIVATE - DWIDGET_USE_NAMESPACE -) -``` - -### 库 - -```cmake -# 共享库 -add_library(mylib SHARED - src/myclass.cpp - src/myclass.h -) - -# 创建别名便于引用 -add_library(MyLib::mylib ALIAS mylib) - -target_link_libraries(mylib PUBLIC - Qt${QT_VERSION_MAJOR}::Core - Qt${QT_VERSION_MAJOR}::Widgets - Dtk${DTK_VERSION_MAJOR}::Widget -) - -# 导出符号 -set_target_properties(mylib PROPERTIES - VERSION ${PROJECT_VERSION} - SOVERSION ${PROJECT_VERSION_MAJOR} - PUBLIC_HEADER "${CMAKE_CURRENT_SOURCE_DIR}/include/*.h" -) -``` - ---- - -## 编译选项 - -### Debug 分支默认启用测试 - -```cmake -# Debug 模式启用测试选项 -option(OPT_ENABLE_BUILD_UT "Enable unit tests" ON) -option(OPT_ENABLE_BUILD_TESTS "Enable build tests" OFF) -option(OPT_DISABLE_QDEBUG "Disable qDebug macro" OFF) - -if(OPT_ENABLE_BUILD_UT) - enable_testing() - add_subdirectory(tests) -endif() - -if(OPT_DISABLE_QDEBUG) - add_definitions(-DQT_NO_DEBUG_OUTPUT) -endif() -``` - -### 警告级别 - -```cmake -if(MSVC) - add_compile_options(/W4) -else() - add_compile_options(-Wall -Wextra -Wpedantic) -endif() -``` - ---- - -## 目录约定 - -### 标准布局 - -``` -project/ -├── CMakeLists.txt -├── src/ -│ ├── main.cpp -│ ├── mainwindow.cpp -│ └── mainwindow.h -├── include/ -│ └── myapp/ -│ └── myclass.h -├── tests/ -│ └── CMakeLists.txt -├── assets/ -│ ├── icons/ -│ └── translations/ -└── cmake/ - └── DFMCommon.cmake -``` - -### CMake 模块 - -```cmake -# cmake/DFMCommon.cmake -function(dtk_add_executable name) - add_executable(${name} ${ARGN}) - target_link_libraries(${name} PRIVATE - Qt${QT_VERSION_MAJOR}::Widgets - Dtk${DTK_VERSION_MAJOR}::Widget - ) - target_compile_definitions(${name} PRIVATE DWIDGET_USE_NAMESPACE) -endfunction() -``` - ---- - -## Debian Control 约定 - -### Qt6 (V25) - -``` -Source: myapp -Maintainer: Deepin Developers <deepin@uniontech.com> - -Build-Depends: - debhelper-compat (=13), - qt6-base-dev, - qt6-tools-dev, - libdtk6widget-dev (>= 6.0.0), - libdtk6core-dev (>= 6.0.0), - libpolkit-qt6-1-dev, - libglib2.0-dev -``` - -### Qt5 (V20) - -``` -Source: myapp -Maintainer: Deepin Developers <deepin@uniontech.com> - -Build-Depends: - debhelper-compat (=12), - qtbase5-dev, - qttools5-dev, - libdtkwidget-dev (>= 2.0.0), - libdtkcore-dev (>= 2.0.0), - libpolkit-qt5-1-dev, - libglib2.0-dev -``` - ---- - -## MOC/UIC/RCC 处理 - -CMake 自动处理 Qt 元对象编译: - -```cmake -# CMake 会自动处理 -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - -# 如果需要自定义选项 -set_property(SOURCE myclass.h PROPERTY AUTOMOC_OPTIONS "-MURI=my.uri") -``` - ---- - -## 安装规则 - -```cmake -# 安装可执行文件 -install(TARGETS myapp - RUNTIME DESTINATION bin -) - -# 安装库 -install(TARGETS mylib - LIBRARY DESTINATION lib - ARCHIVE DESTINATION lib -) - -# 安装头文件 -install(DIRECTORY include/myapp - DESTINATION include -) - -# 安装翻译文件 -install(FILES assets/translations/*.qm - DESTINATION share/${PROJECT_NAME}/translations -) - -# 安装桌面文件 -install(FILES assets/myapp.desktop - DESTINATION share/applications -) - -# 安装图标 -install(FILES assets/icons/myapp.png - DESTINATION share/icons/hicolor/48x48/apps -) -``` - ---- - -## 快速参考 - -| 任务 | 命令 | -|------|------| -| Qt6/Qt5 双版本 | `find_package(QT NAMES Qt6 Qt5 REQUIRED)` | -| DTK Widget | `find_package(Dtk${DTK_VERSION_MAJOR}Widget REQUIRED)` | -| DTK 命名空间 | `target_compile_definitions(... DWIDGET_USE_NAMESPACE)` | -| 测试 | `enable_testing()` + `add_subdirectory(tests)` | -| 禁用 qDebug | `-DQT_NO_DEBUG_OUTPUT` | -| 安装到 bin | `install(TARGETS myapp RUNTIME DESTINATION bin)` | diff --git a/.trellis/spec/backend/index.md b/.trellis/spec/backend/index.md deleted file mode 100644 index a6517e5a..00000000 --- a/.trellis/spec/backend/index.md +++ /dev/null @@ -1,89 +0,0 @@ -# 后端编码层 - -> DDE 应用后端开发规范。 - ---- - -## 技术栈 - -- Qt6 / Qt5 (CMake 双版本支持) -- DTK Widget 6 / DTK Widget (对应) -- C++17 -- CMake 3.10+ - ---- - -## 规范索引 - -| 文档 | 描述 | 何时阅读 | -|------|------|----------| -| [qt-dtk-guide.md](qt-dtk-guide.md) | Qt/DTK 编码模式、信号槽、DTK 组件 | 所有后端开发 | -| [architecture-decisions.md](architecture-decisions.md) | DDE 架构决策、核心规则 | 架构设计 | -| [architecture-design.md](architecture-design.md) | 架构设计规范、模块结构 | 功能开发 | -| [test-standards.md](test-standards.md) | Qt Test 测试规范 | 编写测试 | -| [bug-fix-standards.md](bug-fix-standards.md) | Bug 修复规范、最小修改原则 | 修复 Bug | -| [cmake-conventions.md](cmake-conventions.md) | CMake 约定、Qt6/Qt5 双版本 | 配置构建 | -| [plugin-architecture.md](plugin-architecture.md) | 插件架构、元数据格式 | 插件开发 | - ---- - -## 开发前检查清单 - -在编写后端代码前: - -- [x] 阅读 [qt-dtk-guide.md](qt-dtk-guide.md) 了解 Qt/DTK 编码模式 -- [x] 阅读 [architecture-decisions.md](architecture-decisions.md) 了解 DDE 核心规则 -- [ ] 确定使用的组件优先级(DTK 优先于 Qt 原生) -- [ ] 检查是否涉及数据库 → 参考 DB Schema 变更流程 -- [ ] 确定是否需要插件化 → 参考 [plugin-architecture.md](plugin-architecture.md) -- [ ] 检查是否涉及系统集成 → 参考 reference/ 文档 - ---- - -## 质量检查 - -代码完成后,验证: - -- [ ] 运行 `cpplint` 和 `clang-tidy` 检查 -- [ ] 构建 CMake 无警告 -- [ ] DTK 组件正确使用(非 Qt 原生) -- [ ] 内存管理正确(无泄漏) -- [ ] 线程安全(UI 更新在主线程) -- [ ] 国际化(用户文本使用 tr()) -- [ ] 单元测试覆盖率 >80% - ---- - -## 技术参考 - -- [reference/dtk-widgets-guide.md](reference/dtk-widgets-guide.md) - DTK 组件使用指南 -- [reference/gvfs-gio-integration.md](reference/gvfs-gio-integration.md) - GVfs/GIO 文件操作 -- [reference/dbus-service-usage.md](reference/dbus-service-usage.md) - DBus 服务集成 -- [reference/polkit-auth-workflow.md](reference/polkit-auth-workflow.md) - Polkit 权限控制 - ---- - -## 核心规则摘要 - -| 规则 | 要求 | -|------|------| -| DTK 组件 | 禁止 QMainWindow、QDialog、QMessageBox | -| 主题适配 | 禁止硬编码颜色 | -| 国际化 | 用户文本必须用 tr() | -| 线程安全 | 跨线程用 QueuedConnection | -| 内存管理 | 对象必须有父对象或所有者 | - ---- - -## 快速导航 - -### 按任务 - -| 任务 | 文档 | -|------|------| -| 创建主窗口 | [qt-dtk-guide.md](qt-dtk-guide.md#DMainWindow) | -| 使用对话框 | [qt-dtk-guide.md](qt-dtk-guide.md#DDialog) | -| 信号槽连接 | [qt-dtk-guide.md](qt-dtk-guide.md#信号槽模式) | -| 内存管理 | [qt-dtk-guide.md](qt-dtk-guide.md#内存管理模式) | -| 测试 | [test-standards.md](test-standards.md) | -| Bug 修复 | [bug-fix-standards.md](bug-fix-standards.md) | diff --git a/.trellis/spec/backend/plugin-architecture.md b/.trellis/spec/backend/plugin-architecture.md deleted file mode 100644 index 6b784124..00000000 --- a/.trellis/spec/backend/plugin-architecture.md +++ /dev/null @@ -1,293 +0,0 @@ -# 插件架构 - -> DDE 应用的插件化架构规范。 - ---- - -## 概览 - -本指南定义 DDE 应用的插件化架构,参考 dde-file-manager 插件系统。 - ---- - -## 插件系统架构 - -### 分层架构 - -``` -┌─────────────────────────────────────┐ -│ Application Core │ -├─────────────────────────────────────┤ -│ Plugin Framework (dpf) │ -├─────────────────────────────────────┤ -│ Plugin Interfaces │ -├─────────────────────────────────────┤ -│ Plugins │ -│ ├── Common │ -│ ├── Specific (filemanager...) │ -│ └── Custom │ -└─────────────────────────────────────┘ -``` - -### 核心组件 - -- **Plugin Framework (dpf)** - 插件加载和生命周期管理 -- **Plugin Interfaces** - 定义插件契约 -- **Plugins** - 实现具体功能 - ---- - -## 插件元数据 - -### JSON 元数据格式 - -```json -{ - "Name": "plugin-name", - "Version": "1.0.0", - "Depends": [ - { - "Name": "core", - "Version": "1.0.0" - } - ], - "Description": "Plugin description", - "Author": "author", - "Enabled": true -} -``` - -### 必须字段 - -| 字段 | 类型 | 必需 | 描述 | -|------|------|------|------| -| `Name` | string | 是 | 插件唯一标识 | -| `Version` | string | 是 | 语义化版本 | -| `Depends` | array | 否 | 依赖的其他插件 | - ---- - -## 插件生命周期 - -### 状态机 - -``` -Invalid → Reading → Readed → Loading → Loaded → Initialized → Started - ↓ ↓ ↓ ↓ - ←─────────←────────←────────←──────────────────────── - Stopped → Shutdown -``` - -### 状态说明 - -| 状态 | 描述 | -|------|------| -| `Invalid` | 插件元数据无效 | -| `Reading` | 正在读取元数据 | -| `Readed` | 元数据已读取 | -| `Loading` | 正在加载插件 | -| `Loaded` | 插件已加载(库) | -| `Initialized` | 插件已初始化 | -| `Started` | 插件正在运行 | -| `Stopped` | 插件已停止 | -| `Shutdown` | 插件已关闭 | - ---- - -## 插件接口定义 - -### 基础插件接口 - -```cpp -#include <QObject> -#include <dpf/plugin.hpp> - -class MyPlugin : public QObject, dpf::PluginInterface -{ - Q_OBJECT - Q_PLUGIN_METADATA(IID "org.deepin.plugin.MyPlugin") - Q_INTERFACES(dpf::PluginInterface) - -public: - explicit MyPlugin(QObject *parent = nullptr); - ~MyPlugin() override; - - // 插件接口 - bool initialize() override; - void start() override; - void stop() override; -}; -``` - -### 扩展点接口 - -```cpp -// 定义扩展点接口 -class IFileOperation : public QObject -{ - Q_OBJECT -public: - virtual ~IFileOperation() = default; - - virtual bool canHandle(const QString &path) = 0; - virtual bool copy(const QString &source, const QString &dest) = 0; - virtual bool move(const QString &source, const QString &dest) = 0; - virtual bool remove(const QString &path) = 0; -}; - -// 实现扩展点 -class LocalFilePlugin : public IFileOperation -{ - Q_OBJECT -public: - bool canHandle(const QString &path) override; - bool copy(const QString &source, const QString &dest) override; - bool move(const QString &source, const QString &dest) override; - bool remove(const QString &path) override; -}; -``` - ---- - -## 插件类型 - -### Common 插件 - -跨应用通用插件: -- 书签 -- 菜单 -- 标签 -- 图标 - -### Application 特定插件 - -特定应用的专用插件: -- filemanager - 文件管理器 -- filedialog - 文件对话框 -- desktop - 桌面 -- daemon - 守护进程 - ---- - -## CMake 配置 - -### 插件 CMakeLists.txt - -```cmake -# 插件库 -add_library(myplugin SHARED - src/myplugin.cpp - src/myplugin.h -) - -target_link_libraries(myplugin PRIVATE - Qt${QT_VERSION_MAJOR}::Core - dtk6::core - dfm6-framework -) - -# 插件元数据 -set(PLUGIN_METADATA_FILE "${CMAKE_CURRENT_SOURCE_DIR}/plugin.json") -set_target_properties(myplugin PROPERTIES - PLUGIN_METADATA "${PLUGIN_METADATA_FILE}" -) - -# 安装到插件目录 -install(TARGETS myplugin - LIBRARY DESTINATION ${PLUGIN_INSTALL_DIR}/myplugin -) - -# 安装元数据 -install(FILES ${PLUGIN_METADATA_FILE} - DESTINATION ${PLUGIN_INSTALL_DIR}/myplugin -) -``` - ---- - -## 命名空间约定 - -### 插件命名空间 - -```cpp -// 遵循下划线分隔的命名空间 -namespace filemanager { -namespace details { - // 内部实现 -} -namespace plugin { - // 插件接口 -} -} - -// 或使用完整命名空间 -namespace MyApplication { -namespace Plugins { -namespace MyPlugin { - // 插件实现 -} -} -} -``` - -### 避免命名冲突 - -- 使用应用前缀 -- 使用功能后缀 -- 避免通用名称(如 `Plugin`、`Manager`) - ---- - -## 事件系统 - -### 插件间通信 - -```cpp -// 发布事件 -dpf::EventDispatcherManager::instance() - .publish(Events::EventType::FileSelected, fileUrl); - -// 订阅事件 -dpf::EventDispatcherManager::instance() - .subscribe(Events::EventType::FileSelected, - this, &MyPlugin::handleFileSelected); -``` - -### 定义事件 - -```cpp -// events/eventtypes.h -namespace Events { -Q_NAMESPACE enum class EventType { - FileSelected = 1, - FileCreated, - FileDeleted, - FileModified -}; -Q_ENUM_NS(EventType) -} -``` - ---- - -## 最佳实践 - -1. **最小依赖** - 插件应该最小化依赖 -2. **清晰契约** - 通过接口明确定义契约 -3. **版本兼容** - 元数据中声明版本依赖 -4. **错误处理** - 妥善处理插件加载失败 -5. **资源清理** - 在 stop() 中清理资源 -6. **线程安全** - 确保插件跨线程安全 - ---- - -## 快速参考 - -| 任务 | 方法 | -|------|------| -| 定义插件 | 继承 `PluginInterface` + `Q_PLUGIN_METADATA` | -| 插件元数据 | `plugin.json` | -| 插件状态 | Invalid → Reading → Readed → Loading → Loaded | -| 事件发布 | `dpf::EventDispatcherManager::instance().publish()` | -| 事件订阅 | `dpf::EventDispatcherManager::instance().subscribe()` | -| 命名约定 | `{app}::{features}::{Plugin}` | diff --git a/.trellis/spec/backend/qt-dtk-guide.md b/.trellis/spec/backend/qt-dtk-guide.md deleted file mode 100644 index 60c6355f..00000000 --- a/.trellis/spec/backend/qt-dtk-guide.md +++ /dev/null @@ -1,507 +0,0 @@ -# Qt/DTK 编程指南 - -> Qt6/DTK Widget 开发的编码规范和最佳实践。 - ---- - -## 概览 - -本指南涵盖 DDE 桌面应用开发中的 Qt/DTK 编码模式、DTK 组件使用和最佳实践。 - ---- - -## 信号槽模式 - -### 连接类型 - -```cpp -// Auto connection (default) - 根据上下文自动选择 -connect(sender, &Sender::signal, receiver, &Receiver::slot); - -// Direct connection - 同线程,立即执行 -connect(sender, &Sender::signal, receiver, &Receiver::slot, Qt::DirectConnection); - -// Queued connection - 跨线程,投递到事件循环 -connect(sender, &Sender::signal, receiver, &Receiver::slot, Qt::QueuedConnection); - -// Blocking queued - 跨线程,阻塞直到处理完成 -connect(sender, &Sender::signal, receiver, &Receiver::slot, Qt::BlockingQueuedConnection); -``` - -### Lambda 连接 - -```cpp -// 简单 lambda -connect(button, &DPushButton::clicked, this, [this]() { - handleButtonClick(); -}); - -// 捕获变量 -const QString id = "button-001"; -connect(button, &DPushButton::clicked, this, [this, id]() { - qDebug() << "Clicked:" << id; -}); - -// 带上下文追踪(上下文销毁时自动断开) -connect(timer, &QTimer::timeout, this, [this]() { - updateProgress(); -}); -``` - -### 断开连接模式 - -```cpp -// 断开发送者的所有连接 -disconnect(sender, nullptr, nullptr, nullptr); - -// 断开特定信号 -disconnect(sender, &Sender::signal, nullptr, nullptr); - -// 断开特定连接(使用 QMetaObject::Connection) -QMetaObject::Connection conn = connect(...); -disconnect(conn); -``` - ---- - -## 内存管理模式 - -### Parent-Child 模式 - -```cpp -// Qt 在父对象删除时自动处理子对象 -class MyWidget : public DWidget { -public: - MyWidget(QWidget *parent = nullptr) : DWidget(parent) { - // 子组件自动删除 - m_label = new DLabel(this); - m_button = new DPushButton(this); - } -private: - DLabel *m_label; - DPushButton *m_button; -}; -``` - -### Pimpl 模式(推荐用于 ABI 稳定性) - -```cpp -// myclass.h -class MyClass : public DWidget { - Q_OBJECT -public: - explicit MyClass(QWidget *parent = nullptr); - ~MyClass() override; - - void setValue(int value); - int value() const; - -private: - class Private; - QSharedPointer<Private> d; -}; - -// myclass.cpp -class MyClass::Private { -public: - int value = 0; - QString name; -}; - -MyClass::MyClass(QWidget *parent) - : DWidget(parent) - , d(new Private()) -{ -} - -MyClass::~MyClass() = default; -``` - -### 智能指针模式 - -```cpp -// QSharedPointer 用于共享所有权 -QSharedPointer<Resource> resource = QSharedPointer<Resource>::create(); - -// QScopedPointer 用于独占所有权 -QScopedPointer<Worker> worker(new Worker()); - -// QWeakPointer 用于非拥有引用 -QWeakPointer<Resource> weakRef = resource; -if (auto strong = weakRef.lock()) { - strong->doSomething(); -} -``` - -### Delete Later 模式 - -```cpp -// 在事件循环中安全删除 -widget->deleteLater(); - -// 在槽处理中 -void MyClass::onWorkComplete() { - m_worker->deleteLater(); - m_worker = nullptr; -} -``` - ---- - -## 线程模式 - -### QThread Worker 模式 - -```cpp -// Worker 类 -class Worker : public QObject { - Q_OBJECT -public: - explicit Worker(QObject *parent = nullptr); - -public slots: - void doWork(); - -signals: - void workComplete(const Result &result); -}; - -// 使用 -QThread *thread = new QThread; -Worker *worker = new Worker; -worker->moveToThread(thread); - -connect(thread, &QThread::started, worker, &Worker::doWork); -connect(worker, &Worker::workComplete, this, &MyClass::handleResult); -connect(worker, &Worker::workComplete, thread, &QThread::quit); -connect(thread, &QThread::finished, thread, &QThread::deleteLater); -connect(thread, &QThread::finished, worker, &Worker::deleteLater); - -thread->start(); -``` - -### Qt 并发模式 - -```cpp -#include <QtConcurrent> - -// 在线程池中运行 -QFuture<Result> future = QtConcurrent::run([this]() { - return expensiveComputation(); -}); - -// 监听完成 -QFutureWatcher<Result> *watcher = new QFutureWatcher<Result>(this); -connect(watcher, &QFutureWatcher<Result>::finished, this, [this, watcher]() { - Result result = watcher->result(); - handleResult(result); - watcher->deleteLater(); -}); -watcher->setFuture(future); -``` - ---- - -## 错误处理模式 - -### DDialog 错误显示 - -```cpp -void showError(const QString &title, const QString &message) { - DDialog dialog; - dialog.setIcon(QIcon::fromTheme("dialog-error")); - dialog.setTitle(title); - dialog.setMessage(message); - dialog.addButton(tr("OK"), true, DDialog::ButtonRecommend); - dialog.exec(); -} -``` - -### Result 模式 - -```cpp -struct Result { - bool success = false; - QString error; - QVariant data; - - static Result ok(const QVariant &data = {}) { - return {true, QString(), data}; - } - - static Result fail(const QString &error) { - return {false, error, QVariant()}; - } -}; -``` - ---- - -## DTK 组件指南 - -### DDialog - -```cpp -#include <DDialog> -#include <DApplication> - -// 简单对话框 -void showSimpleDialog() { - DDialog dialog; - dialog.setTitle(tr("Confirm Action")); - dialog.setMessage(tr("Are you sure you want to proceed?")); - dialog.setIcon(QIcon::fromTheme("dialog-question")); - - dialog.addButton(tr("Cancel"), false, DDialog::ButtonNormal); - dialog.addButton(tr("OK"), true, DDialog::ButtonRecommend); - - if (dialog.exec() == DDialog::Accepted) { - // 用户点击了 OK - } -} - -// 带自定义内容的对话框 -void showCustomDialog() { - DDialog dialog; - dialog.setTitle(tr("Settings")); - - QWidget *content = new QWidget; - QVBoxLayout *layout = new QVBoxLayout(content); - - DLineEdit *lineEdit = new DLineEdit; - lineEdit->setPlaceholderText(tr("Enter value")); - layout->addWidget(lineEdit); - - dialog.addContent(content); - dialog.addButton(tr("Cancel"), false, DDialog::ButtonNormal); - dialog.addButton(tr("Apply"), true, DDialog::ButtonRecommend); - - if (dialog.exec() == DDialog::Accepted) { - QString value = lineEdit->text(); - } -} -``` - -### DMainWindow - -```cpp -#include <DMainWindow> -#include <DTitlebar> -#include <DWidgetUtil> - -class MainWindow : public DMainWindow { - Q_OBJECT -public: - explicit MainWindow(QWidget *parent = nullptr) - : DMainWindow(parent) - { - // 设置标题栏 - titlebar()->setTitle("My Application"); - titlebar()->setMenu(new QMenu(this)); - - // 添加菜单动作 - QMenu *menu = titlebar()->menu(); - menu->addAction(tr("Settings"), this, &MainWindow::showSettings); - menu->addSeparator(); - menu->addAction(tr("About"), this, &MainWindow::showAbout); - - // 设置中心组件 - setCentralWidget(createCentralWidget()); - - // 窗口属性 - setMinimumSize(800, 600); - setWindowIcon(QIcon::fromTheme("preferences-system")); - } - -private: - QWidget* createCentralWidget() { - QWidget *widget = new QWidget(this); - QVBoxLayout *layout = new QVBoxLayout(widget); - return widget; - } -}; -``` - -### DLineEdit - -```cpp -#include <DLineEdit> - -// 基本使用 -DLineEdit *lineEdit = new DLineEdit; -lineEdit->setPlaceholderText(tr("Enter text")); -lineEdit->setClearButtonEnabled(true); - -// 带警告 -lineEdit->setAlert(true); -lineEdit->showAlertMessage(tr("Invalid input")); - -// 密码模式 -DLineEdit *passwordEdit = new DLineEdit; -passwordEdit->setEchoMode(QLineEdit::Password); - -// 信号连接 -connect(lineEdit, &DLineEdit::textChanged, this, [this](const QString &text) { - // 处理文本变化 -}); - -connect(lineEdit, &DLineEdit::returnPressed, this, [this]() { - // 处理回车键 -}); -``` - ---- - -## DTK 工具类 - -### DApplication - -```cpp -#include <DApplication> -#include <DWidgetUtil> - -int main(int argc, char *argv[]) { - DApplication a(argc, argv); - - // 应用信息 - a.setApplicationName("myapp"); - a.setApplicationVersion("1.0.0"); - a.setProductName(QObject::tr("My Application")); - a.setProductIcon(QIcon::fromTheme("myapp")); - - // 加载翻译 - a.loadTranslator(); - - MainWindow w; - w.show(); - - return a.exec(); -} -``` - -### DGuiApplicationHelper (主题) - -```cpp -#include <DGuiApplicationHelper> - -// 获取当前主题类型 -DGuiApplicationHelper::ColorType themeType = - DGuiApplicationHelper::instance()->themeType(); - -if (themeType == DGuiApplicationHelper::DarkType) { - // 深色主题 -} else { - // 浅色主题 -} - -// 监听主题变化 -connect(DGuiApplicationHelper::instance(), &DGuiApplicationHelper::themeTypeChanged, - this, [this](DGuiApplicationHelper::ColorType type) { - // 处理主题变化 - }); - -// 获取调色板(从不硬编码颜色) -QPalette palette = DGuiApplicationHelper::instance()->applicationPalette(); -QColor textColor = palette.color(QPalette::WindowText); -``` - ---- - -## 设置模式 - -```cpp -#include <QSettings> - -void saveSettings() { - QSettings settings("org.deepin", "myapp"); - settings.setValue("window/size", size()); - settings.setValue("window/position", pos()); - settings.setValue("value", m_value); -} - -void loadSettings() { - QSettings settings("org.deepin", "myapp"); - resize(settings.value("window/size", QSize(800, 600)).toSize()); - move(settings.value("window/position", QPoint(100, 100)).toPoint()); - m_value = settings.value("value", 0).toInt(); -} -``` - ---- - -## 最佳实践 - -### 始终使用 DTK 组件 - -| 推荐使用 | 禁止使用 | -|---------|---------| -| `DMainWindow` | `QMainWindow` | -| `DDialog` | `QDialog` | -| `DMessageBox` | `QMessageBox` | -| `DWidget` | `QWidget` | -| `DLabel` | `QLabel` | -| `DPushButton` | `QPushButton` | -| `DLineEdit` | `QLineEdit` | -| `DListView` | `QListView` | - -### 主题适配 - -```cpp -// ❌ 错误: 硬编码颜色 -label->setStyleSheet("color: #333333;"); - -// ✅ 正确: 使用调色板 -QPalette palette = DGuiApplicationHelper::instance()->applicationPalette(); -label->setPalette(palette); -``` - -### 国际化 - -```cpp -// 始终对面向用户的文本使用 tr() -button->setText(tr("Save")); -label->setText(tr("Hello World")); - -// 代码注释使用英文 -// Initialize the widget -m_widget = new DWidget(this); -``` - ---- - -## 反模式 - -```cpp -// ❌ 错误: 没有父对象的原始指针(内存泄漏) -QObject *obj = new QObject(); - -// ❌ 错误: 阻塞 UI 线程 -QThread::sleep(5); // 阻塞 UI! - -// ❌ 错误: 跨线程连接没有使用 queued connection -connect(threadWorker, &Worker::signal, uiWidget, &Widget::slot); // 可能崩溃! - -// ❌ 错误: 在错误线程中直接删除 QObject -delete worker; // 如果 worker 在不同线程中会崩溃! - -// ✅ 正确: 对跨线程删除使用 deleteLater -worker->deleteLater(); - -// ✅ 正确: 对跨线程信号使用 QueuedConnection -connect(threadWorker, &Worker::signal, uiWidget, &Widget::slot, Qt::QueuedConnection); -``` - ---- - -## 快速参考 - -| 任务 | 解决方案 | -|------|----------| -| 显示对话框 | `DDialog` + `exec()` | -| 创建主窗口 | `DMainWindow` + `titlebar()` | -| 获取用户输入 | `DLineEdit` + `textChanged` 信号 | -| 显示列表 | `DListView` + `QStandardItemModel` | -| 获取主题颜色 | `DGuiApplicationHelper::instance()->applicationPalette()` | -| 线程安全信号 | `Qt::QueuedConnection` | -| 安全删除 | `deleteLater()` | -| 设置存储 | `QSettings("org.deepin", "app")` | diff --git a/.trellis/spec/backend/reference/dbus-service-usage.md b/.trellis/spec/backend/reference/dbus-service-usage.md deleted file mode 100644 index 06a42515..00000000 --- a/.trellis/spec/backend/reference/dbus-service-usage.md +++ /dev/null @@ -1,313 +0,0 @@ -# DBus 服务使用指南 - -## 核心原则 - -**使用 DBus 事件驱动获取系统状态**,不要轮询 sysfs 文件。 - -## 何时使用 DBus - -| 场景 | 使用 | 原因 | -|------|------|------| -| 电池/电源状态 | ✅ DBus (UPower) | 事件驱动,实时通知 | -| 网络连接状态 | ✅ DBus (NetworkManager) | 事件驱动,状态完整 | -| 显示器亮度 | ✅ DBus (DDE Display) | 系统统一管理 | -| 音量控制 | ✅ DBus (DDE Audio) | 系统统一管理 | -| 磁盘信息 | ⚠️ DBus 或 udisks2 | 两者都可以 | -| 简单系统信息 | ⚠️ sysfs 或 DBus | 静态信息可轮询 | - -## 常用 DDE/FreeDesktop 服务 - -| 服务 | 总线 | 用途 | -|------|------|------| -| `org.freedesktop.UPower` | System | 电池、电源事件 | -| `org.freedesktop.NetworkManager` | System | 网络连接状态 | -| `org.deepin.dde.Display1` | Session | 显示器设置、亮度 | -| `org.deepin.dde.Audio1` | Session | 音量、音频设备 | -| `org.deepin.dde.SystemInfo1` | Session | 系统信息 | -| `org.freedesktop.UDisks2` | System | 磁盘、挂载 | - -## 基本用法 - -### 监听电源事件 (UPower) - -```cpp -#include <QDBusInterface> -#include <QDBusConnection> - -class PowerManager : public QObject { - Q_OBJECT - -public: - PowerManager(QObject *parent = nullptr) : QObject(parent) { - // 连接 UPower 服务 - m_upowerInterface = new QDBusInterface( - "org.freedesktop.UPower", - "/org/freedesktop/UPower", - "org.freedesktop.UPower", - QDBusConnection::systemBus(), - this - ); - - // 监听属性变化 - QDBusConnection::systemBus().connect( - "org.freedesktop.UPower", - "/org/freedesktop/UPower", - "org.freedesktop.DBus.Properties", - "PropertiesChanged", - this, - SLOT(handlePropertiesChanged(QString, QVariantMap, QStringList)) - ); - - // 获取初始状态 - enumerateDevices(); - } - - void enumerateDevices() { - QDBusReply<QList<QDBusObjectPath>> reply = - m_upowerInterface->call("EnumerateDevices"); - - if (reply.isValid()) { - for (const QDBusObjectPath &path : reply.value()) { - readDeviceProperties(path.path()); - } - } - } - - void readDeviceProperties(const QString &path) { - QDBusInterface device( - "org.freedesktop.UPower", - path, - "org.freedesktop.DBus.Properties", - QDBusConnection::systemBus() - ); - - QDBusReply<QVariantMap> props = device.call("GetAll", - "org.freedesktop.UPower.Device"); - - if (props.isValid()) { - QVariantMap properties = props.value(); - - uint state = properties["State"].toUInt(); - double percentage = properties["Percentage"].toDouble(); - bool present = properties["IsPresent"].toBool(); - - qInfo() << "Battery:" << percentage << "%" - << "State:" << state - << "Present:" << present; - } - } - -private slots: - void handlePropertiesChanged(const QString &interface, - const QVariantMap &changed, - const QStringList &invalidated) { - if (interface == "org.freedesktop.UPower.Device") { - for (auto it = changed.begin(); it != changed.end(); ++it) { - if (it.key() == "Percentage") { - emit batteryLevelChanged(it.value().toDouble()); - } - if (it.key() == "State") { - emit powerStateChanged(it.value().toUInt()); - } - } - } - } - -signals: - void batteryLevelChanged(double percentage); - void powerStateChanged(uint state); - -private: - QDBusInterface *m_upowerInterface; -}; -``` - -### 监听网络状态 (NetworkManager) - -```cpp -#include <QDBusInterface> -#include <QDBusConnection> - -class NetworkMonitor : public QObject { - Q_OBJECT - -public: - NetworkMonitor(QObject *parent = nullptr) : QObject(parent) { - m_nmInterface = new QDBusInterface( - "org.freedesktop.NetworkManager", - "/org/freedesktop/NetworkManager", - "org.freedesktop.NetworkManager", - QDBusConnection::systemBus(), - this - ); - - // 监听状态变化 - QDBusConnection::systemBus().connect( - "org.freedesktop.NetworkManager", - "/org/freedesktop/NetworkManager", - "org.freedesktop.NetworkManager", - "PropertiesChanged", - this, - SLOT(handleNetworkStateChanged(QVariantMap)) - ); - - // 获取初始状态 - getNetworkState(); - } - - void getNetworkState() { - QDBusReply<uint> stateReply = m_nmInterface->call("state"); - - if (stateReply.isValid()) { - uint state = stateReply.value(); - - // NM 状态枚举 - // NM_STATE_CONNECTED_GLOBAL = 70 - // NM_STATE_CONNECTED_SITE = 60 - // NM_STATE_DISCONNECTED = 20 - - qInfo() << "Network state:" << state; - emit networkStateChanged(state); - } - } - -private slots: - void handleNetworkStateChanged(const QVariantMap &properties) { - if (properties.contains("State")) { - uint state = properties["State"].toUInt(); - emit networkStateChanged(state); - } - } - -signals: - void networkStateChanged(uint state); - -private: - QDBusInterface *m_nmInterface; -}; -``` - -### 控制显示器亮度 (DDE) - -```cpp -#include <QDBusInterface> -#include <QDBusConnection> - -class DisplayController : public QObject { - Q_OBJECT - -public: - DisplayController(QObject *parent = nullptr) : QObject(parent) { - m_displayInterface = new QDBusInterface( - "org.deepin.dde.Display1", - "/org/deepin/dde/Display1", - "org.deepin.dde.Display1", - QDBusConnection::sessionBus(), - this - ); - } - - double getBrightness() { - QDBusReply<double> reply = m_displayInterface->property("Brightness"); - return reply.isValid() ? reply.value() : 0.5; - } - - void setBrightness(double value) { - m_displayInterface->call("SetBrightness", qBound(0.0, value, 1.0)); - } - -signals: - void brightnessChanged(double value); - -private: - QDBusInterface *m_displayInterface; -}; -``` - -### 控制音量 (DDE Audio) - -```cpp -#include <QDBusInterface> -#include <QDBusConnection> - -class AudioController : public QObject { - Q_OBJECT - -public: - AudioController(QObject *parent = nullptr) : QObject(parent) { - m_audioInterface = new QDBusInterface( - "org.deepin.dde.Audio1", - "/org/deepin/dde/Audio1/Sink", - "org.deepin.dde.Audio1.Sink", - QDBusConnection::sessionBus(), - this - ); - } - - double getVolume() { - QDBusReply<double> reply = m_audioInterface->property("Volume"); - return reply.isValid() ? reply.value() : 0.5; - } - - void setVolume(double value) { - m_audioInterface->call("SetVolume", qBound(0.0, value, 1.0), true); - } - - bool isMuted() { - QDBusReply<bool> reply = m_audioInterface->property("Mute"); - return reply.isValid() && reply.value(); - } - - void setMute(bool muted) { - m_audioInterface->call("SetMute", muted); - } - -private: - QDBusInterface *m_audioInterface; -}; -``` - -## 错误处理 - -```cpp -QDBusInterface interface("org.example.Service", "/org/example/Path"); - -if (!interface.isValid()) { - qWarning() << "DBus interface invalid:" - << QDBusConnection::sessionBus().lastError().message(); - return; -} - -QDBusReply<QVariant> reply = interface.call("MethodName"); - -if (!reply.isValid()) { - qWarning() << "DBus call failed:" << reply.error().message(); - return; -} -``` - -## 常见错误 - -| 错误 | 后果 | 修复 | -|------|------|------| -| 轮询 `/sys/class/power_supply` | CPU 浪费、延迟 | → DBus 信号监听 | -| 使用错误的总线 | 连接失败 | → System vs Session | -| 不检查接口有效性 | 崩溃 | → `isValid()` 检查 | -| 忽略错误处理 | 无法诊断 | → 检查 `QDBusError` | -| 同步调用阻塞 UI | UI 冻结 | → 异步调用或后台线程 | - -## CMake 配置 - -```cmake -find_package(Qt6 REQUIRED COMPONENTS DBus) -# 或 -find_package(Qt5 REQUIRED COMPONENTS DBus) - -target_link_libraries(app PRIVATE Qt6::DBus) -``` - -## 相关资源 - -- [Qt DBus 文档](https://doc.qt.io/qt-6/qtdbus-index.html) -- [FreeDesktop DBus API](https://www.freedesktop.org/wiki/Software/dbus/) -- [UPower DBus API](https://upower.freedesktop.org/docs/) diff --git a/.trellis/spec/backend/reference/dtk-widgets-guide.md b/.trellis/spec/backend/reference/dtk-widgets-guide.md deleted file mode 100644 index c114bebb..00000000 --- a/.trellis/spec/backend/reference/dtk-widgets-guide.md +++ /dev/null @@ -1,202 +0,0 @@ -# DTK Widgets 使用指南 - -## 核心原则 - -**必须使用 DTK 控件替代 Qt 原生控件**,确保应用符合 DDE 设计规范并支持主题切换。 - -## 控件对照表 - -| 用途 | DTK 控件 | Qt 控件 (禁止) | 说明 | -|------|----------|---------------|------| -| 主窗口 | `DMainWindow` | `QMainWindow` | 支持 DDE 窗口装饰、模糊标题栏 | -| 对话框 | `DDialog` | `QDialog` | 自适应主题、圆角设计 | -| 消息框 | `DMessageBox` | `QMessageBox` | DDE 风格按钮布局 | -| 基础控件 | `DWidget` | `QWidget` | 支持 DTK 样式和调色板 | -| 表格 | `DTableView` | `QTableView` | DDE 风格滚动条、选中效果 | -| 树形 | `DTreeView` | `QTreeView` | 主题适配 | -| 列表 | `DListView` | `QListView` | 主题适配 | -| 按钮 | `DPushButton` | `QPushButton` | DDE 风格按钮 | -| 输入框 | `DLineEdit` | `QLineEdit` | 圆角边框、主题色 | -| 标签 | `DLabel` | `QLabel` | 支持 DTK 调色板 | - -## 主题适配 - -### 正确做法 - -```cpp -#include <DGuiApplicationHelper> -#include <DPalette> - -DWIDGET_USE_NAMESPACE - -// 获取当前主题调色板 -DPalette palette = DGuiApplicationHelper::instance()->applicationPalette(); - -// 使用调色板颜色 -QColor bgColor = palette.color(DPalette::Window); -QColor textColor = palette.color(DPalette::WindowText); -QColor highlight = palette.color(DPalette::Highlight); - -// 设置到控件 -widget->setPalette(palette); -``` - -### 错误做法 - -```cpp -// ❌ 硬编码颜色值 -setStyleSheet("background-color: #2b2b2b; color: #ffffff;"); - -// ❌ 手动检测主题然后切换 -if (isDarkMode) { - setStyleSheet("background-color: #2b2b2b;"); -} else { - setStyleSheet("background-color: #f0f0f0;"); -} -``` - -## 常用控件示例 - -### DMainWindow - -```cpp -#include <DMainWindow> -#include <DTitlebar> -#include <DWidget> - -DWIDGET_USE_NAMESPACE - -DMainWindow *window = new DMainWindow(); -window->setWindowTitle("应用名称"); -window->setMinimumSize(800, 600); - -// 自定义标题栏 -DTitlebar *titlebar = window->titlebar(); -titlebar->setIcon(QIcon::fromTheme("app-icon")); -titlebar->setAutoFillBackground(true); - -// 设置中央控件 -DWidget *centralWidget = new DWidget(window); -window->setCentralWidget(centralWidget); - -window->show(); -``` - -### DDialog - -```cpp -#include <DDialog> -#include <DLineEdit> - -DWIDGET_USE_NAMESPACE - -DDialog *dialog = new DDialog("标题", "内容描述"); -dialog->setIcon(QIcon::fromTheme("dialog-icon")); - -// 添加输入控件 -DLineEdit *lineEdit = new DLineEdit(dialog); -lineEdit->setPlaceholderText("请输入..."); -dialog->addContent(lineEdit); - -// 添加按钮 -dialog->addButton("取消", false, DDialog::ButtonNormal); -dialog->addButton("确定", true, DDialog::ButtonRecommend); - -// 连接信号 -connect(dialog, &DDialog::buttonClicked, - dialog, [](int index, const QString &text) { - if (text == "确定") { - // 处理确认 - } -}); - -dialog->exec(); -``` - -### DMessageBox - -```cpp -#include <DMessageBox> - -DWIDGET_USE_NAMESPACE - -// 信息 -DMessageBox::information(parent, "标题", "操作已完成"); - -// 确认 -int ret = DMessageBox::question( - parent, - "确认删除", - "确定要删除此文件吗?", - DMessageBox::Yes | DMessageBox::No, - DMessageBox::No -); - -if (ret == DMessageBox::Yes) { - // 删除文件 -} - -// 警告 -DMessageBox::warning(parent, "磁盘空间不足", "剩余空间小于 1GB"); - -// 错误 -DMessageBox::critical(parent, "保存失败", "无法写入磁盘,请检查权限"); -``` - -## 样式定制 - -### 使用 DTK 调色板 - -```cpp -#include <DStyleOption> -#include <DStyle> - -void CustomWidget::paintEvent(QPaintEvent *event) { - QPainter painter(this); - - DStyleOption opt; - opt.initFrom(this); - - DStyle *style = qobject_cast<DStyle*>(DApplication::style()); - QColor bgColor = style->palette(opt, this).color(QPalette::Window); - - painter.fillRect(rect(), bgColor); -} -``` - -### 监听主题变化 - -```cpp -#include <DGuiApplicationHelper> - -// 在构造函数中连接信号 -connect(DGuiApplicationHelper::instance(), - &DGuiApplicationHelper::themeTypeChanged, - this, - &YourClass::handleThemeChanged); - -void YourClass::handleThemeChanged(DGuiApplicationHelper::ColorType theme) { - // 更新自定义颜色 - updateColors(); - - // 刷新样式 - style()->unpolish(this); - style()->polish(this); - update(); -} -``` - -## 常见错误 - -| 错误 | 后果 | 修复 | -|------|------|------| -| 使用 `QMainWindow` | 主题切换失效、DDE 集成缺失 | → `DMainWindow` | -| 硬编码 `#2b2b2b` | 深色/浅色模式显示错误 | → `applicationPalette()` | -| 不使用 `DWIDGET_USE_NAMESPACE` | 编译错误 | → 添加宏 | -| 忽略 `themeTypeChanged` 信号 | 主题切换时 UI 不更新 | → 连接信号并刷新 | -| 直接设置 RGB 颜色 | 与系统主题不协调 | → 使用调色板 | - -## 相关资源 - -- [DTK Widget API 文档](https://github.com/linuxdeepin/dtkwidget) -- [DDE 设计规范](https://github.com/linuxdeepin/deepin-design-guidelines) diff --git a/.trellis/spec/backend/reference/gvfs-gio-integration.md b/.trellis/spec/backend/reference/gvfs-gio-integration.md deleted file mode 100644 index 6c1df76a..00000000 --- a/.trellis/spec/backend/reference/gvfs-gio-integration.md +++ /dev/null @@ -1,295 +0,0 @@ -# GVfs/GIO 文件操作指南 - -## 核心原则 - -**访问网络协议(smb://, mtp://, dav://)必须使用 GIO API**,Qt 的 `QFile` 无法处理 GVfs 挂载的路径。 - -## 何时使用 GIO - -| 场景 | 使用 | 原因 | -|------|------|------| -| `smb://` 网络共享 | ✅ GIO | Qt 不支持 SMB 协议 | -| `mtp://` 移动设备 | ✅ GIO | Qt 不支持 MTP 协议 | -| `dav://` WebDAV | ✅ GIO | Qt 不支持 WebDAV 协议 | -| `ftp://` FTP | ✅ GIO | Qt 不支持 GVfs FTP 挂载 | -| `file:///` 本地文件 | ⚠️ QFile 或 GIO | 两者都可以,QFile 更简单 | - -## GVfs 架构 - -``` -用户空间 -├── GVfs 服务 (gvfsd) -│ ├── gvfsd-smb → SMB/CIFS -│ ├── gvfsd-mtp → MTP 设备 -│ ├── gvfsd-dav → WebDAV -│ └── gvfsd-ftp → FTP -│ -├── 挂载点 -│ └── /run/user/1000/gvfs/ -│ ├── smb-share:server=SERVER,share=SHARE/ -│ ├── mtp=host_[device_id]/ -│ └── dav:host=server.com,ssl=true/ -│ -└── 应用层 - ├── GIO API (推荐) → 直接 URI 访问 - └── QFile (仅本地) → 无法访问网络协议 -``` - -## 基本用法 - -### 读取文件 - -```cpp -#include <gio/gio.h> -#include <QByteArray> - -QByteArray readFile(const QString &uri) { - GFile *file = g_file_new_for_uri(uri.toUtf8().constData()); - GError *error = nullptr; - - // 打开文件 - GFileInputStream *input = g_file_read(file, nullptr, &error); - if (error) { - qWarning() << "Failed to open:" << error->message; - g_error_free(error); - g_object_unref(file); - return QByteArray(); - } - - // 读取内容 - QByteArray buffer; - char buf[4096]; - gssize bytes_read; - - while ((bytes_read = g_input_stream_read( - G_INPUT_STREAM(input), buf, sizeof(buf), nullptr, &error)) > 0) { - buffer.append(buf, bytes_read); - } - - if (error) { - qWarning() << "Read error:" << error->message; - g_error_free(error); - } - - // 清理 - g_object_unref(input); - g_object_unref(file); - - return buffer; -} - -// 使用示例 -QByteArray content = readFile("smb://server/share/file.txt"); -``` - -### 写入文件 - -```cpp -bool writeFile(const QString &uri, const QByteArray &data) { - GFile *file = g_file_new_for_uri(uri.toUtf8().constData()); - GError *error = nullptr; - - // 创建输出流 - GFileOutputStream *output = g_file_replace( - file, nullptr, false, G_FILE_CREATE_NONE, nullptr, &error - ); - - if (error) { - qWarning() << "Failed to create output:" << error->message; - g_error_free(error); - g_object_unref(file); - return false; - } - - // 写入数据 - gsize bytes_written; - gboolean success = g_output_stream_write_all( - G_OUTPUT_STREAM(output), - data.constData(), - data.size(), - &bytes_written, - nullptr, - &error - ); - - if (!success) { - qWarning() << "Write failed:" << error->message; - g_error_free(error); - } - - // 关闭流 - g_output_stream_close(G_OUTPUT_STREAM(output), nullptr, nullptr); - g_object_unref(output); - g_object_unref(file); - - return success; -} -``` - -### 获取文件信息 - -```cpp -struct FileInfo { - QString displayName; - qint64 size; - QString mimeType; - QDateTime modified; -}; - -FileInfo getFileInfo(const QString &uri) { - GFile *file = g_file_new_for_uri(uri.toUtf8().constData()); - GError *error = nullptr; - - FileInfo info; - - GFileInfo *fileInfo = g_file_query_info( - file, - G_FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME "," - G_FILE_ATTRIBUTE_STANDARD_SIZE "," - G_FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE "," - G_FILE_ATTRIBUTE_TIME_MODIFIED, - G_FILE_QUERY_INFO_NONE, - nullptr, - &error - ); - - if (error) { - qWarning() << "Query failed:" << error->message; - g_error_free(error); - g_object_unref(file); - return info; - } - - info.displayName = g_file_info_get_display_name(fileInfo); - info.size = g_file_info_get_size(fileInfo); - - const char *contentType = g_file_info_get_content_type(fileInfo); - if (contentType) { - info.mimeType = contentType; - } - - GTimeVal mtime; - if (g_file_info_get_modification_time(fileInfo, &mtime)) { - info.modified = QDateTime::fromSecsSinceEpoch(mtime.tv_sec); - } - - g_object_unref(fileInfo); - g_object_unref(file); - - return info; -} -``` - -## 异步操作 - -**文件 I/O 必须在后台线程执行**,避免阻塞 UI: - -```cpp -#include <QFuture> -#include <QtConcurrent> - -class GVfsFileHandler : public QObject { - Q_OBJECT - -public slots: - void readFileAsync(const QString &uri) { - QFutureWatcher<QByteArray> *watcher = - new QFutureWatcher<QByteArray>(this); - - connect(watcher, &QFutureWatcher<QByteArray>::finished, - this, [this, watcher, uri]() { - try { - QByteArray data = watcher->result(); - emit fileReadComplete(uri, data); - } catch (...) { - emit fileReadError(uri, "Unknown error"); - } - watcher->deleteLater(); - }); - - QFuture<QByteArray> future = QtConcurrent::run([uri]() { - return readFile(uri); // 同步 GIO 操作在后台线程 - }); - - watcher->setFuture(future); - } - -signals: - void fileReadComplete(const QString &uri, const QByteArray &data); - void fileReadError(const QString &uri, const QString &error); -}; -``` - -## 错误处理 - -```cpp -GError *error = nullptr; -GFile *file = g_file_new_for_uri(uri); - -GFileInputStream *input = g_file_read(file, nullptr, &error); -if (error) { - switch (error->code) { - case G_IO_ERROR_NOT_FOUND: - qWarning() << "File not found:" << uri; - break; - case G_IO_ERROR_PERMISSION_DENIED: - qWarning() << "Permission denied:" << uri; - break; - case G_IO_ERROR_HOST_NOT_FOUND: - qWarning() << "Host not found:" << uri; - break; - case G_IO_ERROR_NOT_MOUNTED: - qWarning() << "Volume not mounted:" << uri; - break; - default: - qWarning() << "GIO error:" << error->message; - } - g_error_free(error); -} -``` - -## 常见错误 - -| 错误 | 后果 | 修复 | -|------|------|------| -| `QFile("smb://...")` | 打开失败 | → `g_file_new_for_uri()` | -| 主线程 GIO 操作 | UI 冻结 | → `QtConcurrent::run()` | -| 忽略 `GError` | 无法诊断问题 | → 检查 error 参数 | -| 忘记 `g_object_unref` | 内存泄漏 | → 使用 RAII 包装 | -| 硬编码 GVfs 挂载点 | 路径可能变化 | → 使用 URI API | - -## GVfs 挂载点参考 - -**不要硬编码这些路径**,优先使用 GIO URI API: - -```bash -# GVfs 挂载点位置 -/run/user/[UID]/gvfs/ - -# SMB 挂载点格式 -smb-share:server=SERVER,share=SHARE_NAME/ - -# MTP 挂载点格式 -mtp=host_[device_id]/storage_[id]/ - -# WebDAV 挂载点格式 -dav:host=server.com,ssl=true,port=443,user=username/ - -# FTP 挂载点格式 -ftp:host=server.com,user=username/ -``` - -## CMake 配置 - -```cmake -find_package(PkgConfig REQUIRED) -pkg_check_modules(GIO REQUIRED gio-2.0) - -target_include_directories(app PRIVATE ${GIO_INCLUDE_DIRS}) -target_link_libraries(app PRIVATE ${GIO_LIBRARIES}) -``` - -## 相关资源 - -- [GIO API 文档](https://docs.gtk.org/gio/) -- [GVfs 文档](https://wiki.gnome.org/Projects/GVfs) diff --git a/.trellis/spec/backend/reference/polkit-auth-workflow.md b/.trellis/spec/backend/reference/polkit-auth-workflow.md deleted file mode 100644 index 484e8e14..00000000 --- a/.trellis/spec/backend/reference/polkit-auth-workflow.md +++ /dev/null @@ -1,304 +0,0 @@ -# Polkit 鉴权工作流 - -## 核心原则 - -**使用 Polkit 进行权限提升**,禁止以 root 身份运行整个应用。 - -## 何时使用 Polkit - -| 场景 | 使用 | 原因 | -|------|------|------| -| 修改 `/etc` 配置 | ✅ Polkit | 系统级操作需要鉴权 | -| 安装/卸载软件 | ✅ Polkit | 系统级操作 | -| 修改系统服务 | ✅ Polkit | 系统级操作 | -| 访问硬件设备 | ✅ Polkit | 需要特殊权限 | -| 普通文件操作 | ❌ 不需要 | 用户权限即可 | -| 应用配置修改 | ❌ 不需要 | 用户主目录内操作 | - -## Polkit 架构 - -``` -┌─────────────────┐ -│ 你的应用 │ -│ (非特权进程) │ -└────────┬────────┘ - │ 请求授权 - ↓ -┌─────────────────┐ -│ Polkit Agent │ -│ (认证对话框) │ -│ 由 DDE 提供 │ -└────────┬────────┘ - │ 用户输入密码 - ↓ -┌─────────────────┐ -│ Polkit Daemon │ -│ (系统服务) │ -│ 验证凭据 │ -└────────┬────────┘ - │ 授权结果 - ↓ -┌─────────────────┐ -│ Helper 程序 │ -│ (以 root 运行) │ -│ 执行特权操作 │ -└─────────────────┘ -``` - -## 方法 1: PolkitQt5-1 直接鉴权 - -### CMake 配置 - -```cmake -find_package(PolkitQt5-1 REQUIRED) -# 或 Qt6 -find_package(PolkitQt6-1 REQUIRED) - -target_link_libraries(app PRIVATE PolkitQt5-1::PolkitQt5-1) -``` - -### 基本用法 - -```cpp -#include <PolkitQt1/Authority> -#include <PolkitQt1/Subject> - -using namespace PolkitQt1; - -class PrivilegedOperation : public QObject { - Q_OBJECT - -public: - explicit PrivilegedOperation(QObject *parent = nullptr) - : QObject(parent) {} - - void performOperation() { - // 检查权限 - Authority::instance()->checkAuthorizationAsync( - "org.deepin.dde.policy.authentication", - UnixProcessSubject(QCoreApplication::applicationPid()), - Authority::AllowUserInteraction, - this, - SLOT(authorizationResult(PolkitQt1::Authority::Result)) - ); - } - -private slots: - void authorizationResult(Authority::Result result) { - switch (result) { - case Authority::Yes: - qInfo() << "Authorization granted"; - performPrivilegedAction(); - break; - case Authority::No: - qWarning() << "Authorization denied"; - emit authorizationFailed("Permission denied"); - break; - case Authority::Cancelled: - qWarning() << "Authorization cancelled by user"; - emit authorizationFailed("Cancelled"); - break; - } - } - - void performPrivilegedAction() { - // 执行需要特权的操作 - // 注意:这里仍然需要适当的权限机制 - } - -signals: - void authorizationFailed(const QString &reason); -}; -``` - -## 方法 2: Helper 程序 + pkexec (推荐) - -### 定义 Polkit Action - -创建 `/usr/share/polkit-1/actions/com.example.myapp.policy`: - -```xml -<?xml version="1.0" encoding="UTF-8"?> -<policyconfig> - <vendor>Deepin</vendor> - <vendor_url>https://www.deepin.org</vendor_url> - <icon_name>deepin-editor</icon_name> - - <action id="com.example.myapp.modify-system-config"> - <description>Modify system configuration</description> - <message>Authentication is required to modify system configuration</message> - <defaults> - <allow_any>auth_admin</allow_any> - <allow_inactive>auth_admin</allow_inactive> - <allow_active>auth_admin_keep</allow_active> - </defaults> - <annotate key="org.freedesktop.policykit.exec.path">/usr/lib/myapp/myapp-helper</annotate> - <annotate key="org.freedesktop.policykit.exec.allow_gui">true</annotate> - </action> -</policyconfig> -``` - -### Helper 程序 - -```cpp -// myapp-helper.cpp -// 以 root 身份运行,执行特权操作 - -#include <QCoreApplication> -#include <QFile> -#include <QTextStream> -#include <QFileInfo> - -int main(int argc, char *argv[]) { - QCoreApplication app(argc, argv); - - // 检查是否以 root 运行 - if (geteuid() != 0) { - qCritical() << "Helper must run as root"; - return 1; - } - - // 从 stdin 读取命令 - QTextStream in(stdin); - QString command = in.readLine(); - - if (command == "write-config") { - QString path = in.readLine(); - QString content = in.readLine(); - - // 验证路径(防止目录遍历攻击) - if (!path.startsWith("/etc/myapp/")) { - qCritical() << "Invalid path:" << path; - return 1; - } - - QFile file(path); - if (file.open(QIODevice::WriteOnly)) { - file.write(content.toUtf8()); - file.close(); - qDebug() << "OK"; - return 0; - } else { - qCritical() << "Failed to write:" << file.errorString(); - return 1; - } - } - - qCritical() << "Unknown command:" << command; - return 1; -} -``` - -### 主程序调用 Helper - -```cpp -#include <QProcess> -#include <QStandardPaths> - -class SystemConfigManager : public QObject { - Q_OBJECT - -public: - void writeConfig(const QString &path, const QString &content) { - QProcess *process = new QProcess(this); - - // 查找 helper 程序 - QString helperPath = QStandardPaths::findExecutable("myapp-helper", - QStringList() << "/usr/lib/myapp" << "/usr/libexec/myapp"); - - if (helperPath.isEmpty()) { - emit configWriteFailed("Helper program not found"); - return; - } - - // 使用 pkexec 启动 helper (会触发 Polkit 认证) - process->start("pkexec", QStringList() << helperPath); - - // 发送命令和参数 - process->write("write-config\n"); - process->write(path.toUtf8() + "\n"); - process->write(content.toUtf8() + "\n"); - process->closeWriteChannel(); - - connect(process, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished), - this, [this, process](int exitCode, QProcess::ExitStatus status) { - if (exitCode == 0) { - QString output = QString::fromUtf8(process->readAllStandardOutput()); - if (output.trimmed() == "OK") { - emit configWritten(); - } else { - emit configWriteFailed(output); - } - } else { - QString error = QString::fromUtf8(process->readAllStandardError()); - emit configWriteFailed(error.isEmpty() ? "Unknown error" : error); - } - process->deleteLater(); - }); - - connect(process, &QProcess::errorOccurred, - this, [this, process](QProcess::ProcessError error) { - emit configWriteFailed(QString("Process error: %1").arg(error)); - process->deleteLater(); - }); - } - -signals: - void configWritten(); - void configWriteFailed(const QString &error); -}; -``` - -## CMake 安装规则 - -```cmake -# 安装 helper 程序 -install(TARGETS myapp-helper - DESTINATION lib/myapp -) - -# 安装 Polkit action 文件 -install(FILES com.example.myapp.policy - DESTINATION share/polkit-1/actions -) -``` - -## 常见错误 - -| 错误 | 后果 | 修复 | -|------|------|------| -| `sudo ./app` 启动 | 安全风险、文件权限问题 | → Polkit + Helper | -| 整个应用以 root 运行 | 安全隐患、用户文件损坏 | → 仅 Helper 提权 | -| 不验证 Helper 输入 | 命令注入风险 | → 严格验证路径和参数 | -| 缺少 Polkit action | 认证失败 | → 创建 `.policy` 文件 | -| Helper 路径错误 | pkexec 失败 | → 使用绝对路径 | - -## 安全最佳实践 - -1. **最小权限原则**: Helper 只执行必要的特权操作 -2. **输入验证**: 严格验证所有传入参数 -3. **路径限制**: 限制可访问的目录范围 -4. **日志记录**: 记录所有特权操作 -5. **错误处理**: 不泄露敏感信息 - -## 调试 - -```bash -# 检查 Polkit 服务状态 -systemctl status polkit - -# 查看 Polkit 日志 -journalctl -u polkit -f - -# 测试 action 定义 -pkcheck --action-id com.example.myapp.modify-system-config --process $$ - -# 查看已注册的 actions -ls -la /usr/share/polkit-1/actions/ -``` - -## 相关资源 - -- [Polkit 文档](https://www.freedesktop.org/software/polkit/docs/latest/) -- [PolkitQt5-1 API](https://github.com/linuxdeepin/polkit-qt5-1) -- [Deepin Polkit Agent](https://github.com/linuxdeepin/deepin-polkit-agent) diff --git a/.trellis/spec/backend/test-standards.md b/.trellis/spec/backend/test-standards.md deleted file mode 100644 index a93fbbfd..00000000 --- a/.trellis/spec/backend/test-standards.md +++ /dev/null @@ -1,379 +0,0 @@ -# 测试规范 - -> DDE 应用的 Qt Test 测试框架规范。 - ---- - -## 概览 - -本指南涵盖使用 Qt Test 框架编写 DDE 应用测试的模式和最佳实践。 - ---- - -## Qt Test 框架 - -### 基本测试结构 - -```cpp -#include <QtTest> -#include <DWidget> - -class TestMyWidget : public QObject -{ - Q_OBJECT - -private slots: - // 设置/清理 - void initTestCase(); // 所有测试前调用一次 - void cleanupTestCase(); // 所有测试后调用一次 - void init(); // 每个测试前调用 - void cleanup(); // 每个测试后调用 - - // 测试用例 - void testConstructor(); - void testBasicFunctionality(); - void testEdgeCases(); - void testSignals(); -}; - -void TestMyWidget::initTestCase() -{ - // 一次性初始化 - qRegisterMetaType<MyCustomType>(); -} - -void TestMyWidget::cleanupTestCase() -{ - // 一次性清理 -} - -void TestMyWidget::init() -{ - // 每个测试的设置 - m_widget = new MyWidget(); -} - -void TestMyWidget::cleanup() -{ - // 每个测试的清理 - delete m_widget; - m_widget = nullptr; -} - -void TestMyWidget::testConstructor() -{ - QVERIFY(m_widget != nullptr); - QCOMPARE(m_widget->isEnabled(), true); -} - -QTEST_MAIN(TestMyWidget) -#include "test_mywidget.moc" -``` - -### 常见断言 - -```cpp -// 布尔检查 -QVERIFY(condition); // 条件为真 -QVERIFY2(condition, "message"); // 带自定义消息 - -// 相等性检查 -QCOMPARE(actual, expected); // 使用 operator== - -// 类型检查 -QCOMPARE(actual.type(), ExpectedType); - -// 空指针检查 -QVERIFY(ptr != nullptr); -QVERIFY(ptr == nullptr); - -// 字符串比较 -QCOMPARE(actualString, expectedString); -QVERIFY(actualString.contains("substring")); -QVERIFY(actualString.startsWith("prefix")); -QVERIFY(actualString.isEmpty()); - -// 数值比较 -QCOMPARE(actual, expected); -QVERIFY(actual > expected); -QVERIFY(qFuzzyCompare(actual, expected)); // 浮点数 -``` - -### 数据驱动测试 - -```cpp -private slots: - void testValidation_data(); - void testValidation(); - -void TestMyWidget::testValidation_data() -{ - QTest::addColumn<QString>("input"); - QTest::addColumn<bool>("expectedValid"); - QTest::addColumn<QString>("expectedError"); - - QTest::newRow("valid email") << "test@example.com" << true << ""; - QTest::newRow("invalid email") << "invalid" << false << "Invalid format"; - QTest::newRow("empty") << "" << false << "Required field"; - QTest::newRow("null chars") << QString() << false << "Required field"; -} - -void TestMyWidget::testValidation() -{ - QFETCH(QString, input); - QFETCH(bool, expectedValid); - QFETCH(QString, expectedError); - - bool isValid = m_widget->validate(input); - QCOMPARE(isValid, expectedValid); - - if (!expectedValid) { - QCOMPARE(m_widget->errorString(), expectedError); - } -} -``` - -### 信号测试 - -```cpp -#include <QSignalSpy> - -void TestMyWidget::testSignals() -{ - QSignalSpy spy(m_widget, &MyWidget::valueChanged); - - // 触发信号 - m_widget->setValue(42); - - // 验证信号已发出 - QCOMPARE(spy.count(), 1); - - // 验证信号参数 - QList<QVariant> arguments = spy.takeFirst(); - QCOMPARE(arguments.at(0).toInt(), 42); -} - -void TestMyWidget::testMultipleSignals() -{ - QSignalSpy valueSpy(m_widget, &MyWidget::valueChanged); - QSignalSpy errorSpy(m_widget, &MyWidget::errorOccurred); - - m_widget->setValue(100); - - QCOMPARE(valueSpy.count(), 1); - QCOMPARE(errorSpy.count(), 0); - - m_widget->setInvalidValue(); - - QCOMPARE(valueSpy.count(), 1); // 没有新信号 - QCOMPARE(errorSpy.count(), 1); -} -``` - -### 使用 QSignalSpy 进行 Mock - -```cpp -// 使用信号的 Mock 对象 -class MockService : public QObject { - Q_OBJECT -public: - void emitDataReady(const QString &data) { - emit dataReady(data); - } -signals: - void dataReady(const QString &data); -}; - -void TestIntegration::testServiceIntegration() -{ - MockService mockService; - QSignalSpy spy(&mockService, &MockService::dataReady); - - m_widget->setService(&mockService); - mockService.emitDataReady("test data"); - - QCOMPARE(spy.count(), 1); - QCOMPARE(m_widget->currentData(), QString("test data")); -} -``` - -### 测试异步操作 - -```cpp -void TestAsync::testAsyncOperation() -{ - QEventLoop loop; - QTimer::singleShot(100, &loop, &QEventLoop::quit); - - connect(m_worker, &Worker::finished, &loop, &QEventLoop::quit); - - m_worker->startAsync(); - loop.exec(); // 等待完成 - - QVERIFY(m_worker->isComplete()); - QCOMPARE(m_worker->result(), expectedResult); -} - -// 或使用 QTRY_VERIFY -void TestAsync::testWithTryVerify() -{ - m_worker->startAsync(); - - // 等待最多 5 秒满足条件 - QTRY_VERIFY_WITH_TIMEOUT(m_worker->isComplete(), 5000); - - QCOMPARE(m_worker->result(), expectedResult); -} -``` - -### 测试 D-Bus - -```cpp -void TestDBus::testDBusCall() -{ - // 注册 mock 服务 - QDBusConnection bus = QDBusConnection::sessionBus(); - - MockDBusAdaptor adaptor(m_mockService); - bus.registerService("org.deepin.Test"); - bus.registerObject("/Test", m_mockService); - - // 测试调用 - QDBusInterface iface("org.deepin.Test", "/Test"); - QDBusReply<QString> reply = iface.call("GetMethod"); - - QVERIFY(reply.isValid()); - QCOMPARE(reply.value(), QString("expected")); - - // 清理 - bus.unregisterObject("/Test"); - bus.unregisterService("org.deepin.Test"); -} -``` - ---- - -## 覆盖率分析 - -### 启用覆盖率 (CMake) - -```cmake -# 启用覆盖率标志 -set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} --coverage -fprofile-arcs -ftest-coverage") -set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} --coverage") - -# 查找 gcov/lcov -find_program(GCOV_PATH gcov) -find_program(LCOV_PATH lcov) -find_program(GENHTML_PATH genhtml) -``` - -### 生成覆盖率报告 - -```bash -# 运行测试 -./test_myapp - -# 生成覆盖率数据 -lcov --capture --directory . --output-file coverage.info - -# 过滤系统头文件 -lcov --remove coverage.info '/usr/*' --output-file coverage.info - -# 生成 HTML 报告 -genhtml coverage.info --output-directory coverage_html -``` - -### 覆盖率目标 - -```cmake -# 添加覆盖率目标 -add_custom_target(coverage - COMMAND ${LCOV_PATH} --capture --directory . --output-file coverage.info - COMMAND ${LCOV_PATH} --remove coverage.info '/usr/*' --output-file coverage.info - COMMAND ${GENHTML_PATH} coverage.info --output-directory coverage_html - DEPENDS test_myapp - WORKING_DIRECTORY ${CMAKE_BINARY_DIR} - COMMENT "Generating coverage report..." -) -``` - ---- - -## 测试组织 - -### 目录结构 - -``` -tests/ -├── CMakeLists.txt -├── test_main.cpp # 公共测试设置 -├── unit/ -│ ├── test_widget.cpp -│ ├── test_model.cpp -│ └── test_service.cpp -├── integration/ -│ ├── test_dbus.cpp -│ └── test_workflow.cpp -└── mocks/ - ├── mock_service.h - └── mock_dbus.h -``` - -### 测试 CMakeLists.txt - -```cmake -enable_testing() - -# 查找 Qt Test -find_package(Qt${QT_VERSION_MAJOR} COMPONENTS Test REQUIRED) - -# 添加测试可执行文件 -add_executable(test_myapp - test_main.cpp - unit/test_widget.cpp - unit/test_model.cpp -) - -target_link_libraries(test_myapp - PRIVATE - Qt${QT_VERSION_MAJOR}::Test - myapp_lib -) - -# 使用 CTest 注册 -add_test(NAME test_myapp COMMAND test_myapp) -``` - ---- - -## 最佳实践 - -1. **每个类一个测试文件** -2. **描述性命名测试**: `test<方法>_<场景>_<期望结果>` -3. **测试边界情况**: null、空、最大值 -4. **保持测试独立**: 没有共享状态 -5. **正确使用 init/cleanup** -6. **Mock 外部依赖** -7. **目标 >80% 覆盖率** -8. **在 CI 中运行测试** -9. **测试成功和失败路径** - ---- - -## 测试用例模板 - -```cpp -void TestMyClass::testMethodName_scenario_expectedResult() -{ - // given - QString input = "test input"; - QString expected = "expected output"; - - // when - QString actual = m_object->methodName(input); - - // then - QCOMPARE(actual, expected); -} -``` diff --git a/.trellis/spec/big-question/dbus-async-vs-sync.md b/.trellis/spec/big-question/dbus-async-vs-sync.md deleted file mode 100644 index db5e802e..00000000 --- a/.trellis/spec/big-question/dbus-async-vs-sync.md +++ /dev/null @@ -1,261 +0,0 @@ -# Dbus 异步/同步问题 - -> 使用错误的 DBus 调用方式导致阻塞 UI 或数据不一致。 - ---- - -## 问题描述 (P1) - -在 DDE 应用中使用 DBus 时: -- 阻塞同步调用导致 UI 冻结 -- 异步调用结果未正确处理 -- 跨线程通信使用错误的连接类型 - ---- - -## 根本原因 - -1. 混淆同步/异步调用时机 -2. 系统服务调用未考虑网络延迟 -3. 信号槽连接类型不当 - ---- - -## 错误示例 - -### 1. 同步阻塞调用 - -```cpp -// ❌ 错误: UI 线程阻塞调用 -void checkBattery() { - QDBusInterface iface("org.freedesktop.UPower", - "/org/freedesktop/UPower"); - // 阻塞 UI 线程! - QDBusReply<QVariant> reply = iface.call("GetAll"); - processBatteryLevel(reply.value()); -} -``` - -### 2. 错误的总线类型 - -```cpp -// ❌ 错误: 系统服务使用 sessionBus -void getBrightness() { - // 错误: Power 属于系统服务 - QDBusConnection::sessionBus().connect( - "org.freedesktop.Power", - "/org/freedesktop/Power", - ...); - // 连接失败! -} -``` - -### 3. 异步调用无错误处理 - -```cpp -// ❌ 错误: 异步调用没有错误处理 -void asyncCall() { - QDBusInterface iface(...); - iface.asyncCall("Method"); // 没有监听结果或错误 -} -``` - ---- - -## 正确做法 - -### 使用异步调用 - -```cpp -void checkBattery() { - QDBusInterface iface("org.freedesktop.UPower", - "/org/freedesktop/UPower"); - QDBusPendingCall async = iface.asyncCall("GetAll"); - - // 设置监听 - QDBusPendingCallWatcher *watcher = - new QDBusPendingCallWatcher(async, this); - connect(watcher, &QDBusPendingCallWatcher::finished, - this, &MyClass::onBatteryUpdated); -} - -void onBatteryUpdated(QDBusPendingCallWatcher *watcher) { - QDBusPendingReply<QVariantMap> reply = *watcher; - if (reply.isError()) { - qWarning() << "DBus error:" << reply.error().message(); - return; - } - - QVariantMap values = reply.value(); - updateUI(values); - watcher->deleteLater(); -} -``` - -### 正确的总线类型 - -```cpp -// ✅ 正确: 系统服务使用 systemBus -void monitorPower() { - // ✅ 系统级服务 - QDBusConnection::systemBus().connect( - "org.freedesktop.UPower", - "/org/freedesktop/UPower", - "org.freedesktop.DBus.Properties", - "PropertiesChanged", - this, SLOT(handlePropertyChange(QString, QVariantMap))); -} - -// ✅ 正确: 用户服务使用 sessionBus -void monitorClipboard() { - QDBusConnection::sessionBus().connect( - "org.deepin.dde.Clipboard", - "/org/deepin/dde/Clipboard", - ...); -} -``` - -### 异步调用错误处理 - -```cpp -void asyncCall() { - QDBusPendingCall async = iface.asyncCall("Method", arg1, arg2); - - QDBusPendingCallWatcher *watcher = - new QDBusPendingCallWatcher(async, this); - - connect(watcher, &QDBusPendingCallWatcher::finished, - this, [this, watcher]() { - QDBusPendingReply<QString> reply = *watcher; - if (reply.isError()) { - handleError(reply.error()); - } else { - handleResult(reply.value()); - } - watcher->deleteLater(); - }); -} -``` - ---- - -## DBus 调用流程 - -### 同步调用(仅后台线程) - -```cpp -// 仅在非 UI 线程中使用 -void workerFunction() { - QDBusConnection bus = QDBusConnection::systemBus(); - QDBusInterface iface(...); - - // 后台线程可以同步调用 - QDBusReply<QString> reply = iface.call("Method"); - if (reply.isValid()) { - process(reply.value()); - } -} -``` - -### 异步调用(UI 线程) - -```cpp -// UI 线程必须使用异步调用 -void uiFunction() { - QDBusConnection bus = QDBusConnection::systemBus(); - QDBusInterface iface(...); - - // 异步调用 - QDBusPendingCall async = iface.asyncCall("Method"); - QDBusPendingCallWatcher *watcher = - new QDBusPendingCallWatcher(async, this); - - connect(watcher, &QDBusPendingCallWatcher::finished, - this, &MyClass::onResult); -} -``` - ---- - -## 常见服务总线类型 - -| 服务类型 | 总线 | 示例 | -|---------|------|------| -| 系统服务 | `systemBus` | UPower, NetworkManager, Polkit, systemd | -| 用户服务 | `sessionBus` | Clipboard, Settings, App Services | - ---- - -## 检测方法 - -### 检查 DBus 连接 - -```cpp -bool checkDBusConnection() { - QDBusConnection sys = QDBusConnection::systemBus(); - QDBusConnection sess = QDBusConnection::sessionBus(); - - return sys.isConnected() && sess.isConnected(); -} -``` - -### 记录 DBus 调用耗时 - -```cpp -void callDBusMethod() { - QElapsedTimer timer; - timer.start(); - - QDBusReply reply = iface.call("Method"); - - qint64 elapsed = timer.elapsed(); - if (elapsed > 100) { // 超过 100ms 记录警告 - qWarning() << "DBus call took" << elapsed << "ms"; - } -} -``` - ---- - -## 预防措施 - -### 1. UI 线程异步调用 - -```cpp -// 规则: UI 线程所有 DBus 调用使用 asyncCall -// 后台线程可以同步调用,但推荐也用异步 -``` - -### 2. 总线类型命名约定 - -```cpp -// 清晰命名连接 -class DbusService { - QDBusConnection systemBus() const { - return QDBusConnection::systemBus(); - } - - QDBusConnection sessionBus() const { - return QDBusConnection::sessionBus(); - } -}; -``` - -### 3. 超时设置 - -```cpp -QDBusPendingCall async = iface.asyncCall("Method"); -async.setTimeout(5000); // 5 秒超时 -``` - ---- - -## 代码检查清单 - -- [ ] UI 线程中使用 asyncCall 而非 call -- [ ] 系统服务使用 systemBus -- [ ] 用户服务使用 sessionBus -- [ ] 所有异步调用监听完成和错误 -- [ ] 设置合理的超时时间 -- [ ] 记录慢调用(>100ms) -- [ ] 处理 DBus 连接失败情况 diff --git a/.trellis/spec/big-question/gthread-ui-thread-safety.md b/.trellis/spec/big-question/gthread-ui-thread-safety.md deleted file mode 100644 index 9ff332bf..00000000 --- a/.trellis/spec/big-question/gthread-ui-thread-safety.md +++ /dev/null @@ -1,232 +0,0 @@ -# GThread UI 线程安全陷阱 - -> 在 DDE 应用中跨线程更新 UI 常见的安全问题。 - ---- - -## 问题描述 (P0) - -从非主线程直接更新 UI 组件导致应用崩溃或随机出现显示异常。 - ---- - -## 根本原因 - -Qt UI 组件不是线程安全的,只能在主线程(GUI 线程)中创建和操作。直接从工作线程访问或修改 UI 会导致未定义行为。 - ---- - -## 错误示例 - -### 1. 工作线程直接更新 UI - -```cpp -// ❌ 错误: 从工作线程更新 UI -class Worker : public QObject { - Q_OBJECT -public: - void doWork() { - // 崩溃!不在主线程 - label->setText("Processing..."); - progressBar->setValue(50); - } - QLabel *label; - QProgressBar *progressBar; -}; -``` - -### 2. AutoConnection 跨线程陷阱 - -```cpp -// ❌ 错误: 默认 AutoConnection 可能导致 DirectConnection -class Controller : public QObject { -public: - void startWorker() { - Worker *worker = new Worker; - worker->moveToThread(&workerThread); - - // AutoConnection 在跨线程时使用 QueuedConnection - // 但如果 worker 和 controller 在同一线程,使用 DirectConnection - // 然后如果 worker 移动到其他线程,问题就出现了 - connect(worker, &Worker::progress, this, &Controller::updateUI); - } -}; -``` - -### 3. 直接访问 QMetaObject::invokeMethod - -```cpp -// ❌ 错误: DirectConnection 在错误线程 -void Worker::notifyProgress(int value) { - QMetaObject::invokeMethod(ui, "setProgress", - Qt::DirectConnection, // 错误! - Q_ARG(int, value)); -} - -// ✅ 正确: 使用 Qt::QueuedConnection -void Worker::notifyProgress(int value) { - QMetaObject::invokeMethod(ui, "setProgress", - Qt::QueuedConnection, - Q_ARG(int, value)); -} -``` - ---- - -## 正确做法 - -### 使用信号槽 + QueuedConnection - -```cpp -class Worker : public QObject { - Q_OBJECT -signals: - void progressChanged(int value); - void workComplete(const QString &result); -}; - -class Controller : public QObject { - Q_OBJECT -public: - void startWorker() { - Worker *worker = new Worker; - worker->moveToThread(&workerThread); - - // ✅ 正确: 明确指定 QueuedConnection - connect(worker, &Worker::progressChanged, - this, &Controller::updateProgress, - Qt::QueuedConnection); - - connect(worker, &Worker::workComplete, - this, &Controller::handleComplete, - Qt::QueuedConnection); - } - -private slots: - void updateProgress(int value) { - // 在主线程中安全执行 - progressBar->setValue(value); - } -}; -``` - -### 检查当前线程 - -```cpp -// 检查是否在主线程 -void setSafeText(const QString &text) { - if (QThread::currentThread() == qApp->thread()) { - label->setText(text); - } else { - // 通过信号在主线程更新 - emit textChanged(text); - } -} -``` - -### QMetaObject::invokeMethod 线程安全 - -```cpp -// ✅ 正确: 使用 QueuedConnection -// 确保在主线程执行 -QThread *mainThread = qApp->thread(); -if (QThread::currentThread() != mainThread) { - QMetaObject::invokeMethod(ui, "setProgress", - Qt::QueuedConnection, - Q_ARG(int, value)); -} -``` - ---- - -## 连接类型选择 - -| 连接类型 | 说明 | 使用场景 | -|---------|------|---------| -| `AutoConnection` | 根据上下文自动选择 | 同线程用 Direct,跨线程用 Queued | -| `DirectConnection` | 立即调用 | 同线程,或确定安全的情况 | -| `QueuedConnection` | 投递到事件循环 | 跨线程,确保线程安全 | -| `BlockingQueuedConnection` | 阻塞等待 | 需要同步结果时(谨慎使用) | - ---- - -## 检测方法 - -### 静态分析 - -```cpp -// 断言检查 -Q_ASSERT_X(QThread::currentThread() == qApp->thread(), - "UI update must be on main thread"); -``` - -### 编译时检查 - -```cpp -// 可以使用 C++11 static_assert 检查线程 -constexpr bool IsMainThread() { - return true; // 运行时检查 -} -``` - ---- - -## 预防措施 - -### 1. 设计原则 - -- 所有 UI 操作必须在主线程 -- 工作线程只做计算,不访问 UI -- 通过信号槽通信 - -### 代码模式 - -```cpp -// Worker 类 - 无 UI 依赖 -class Worker : public QObject { - Q_OBJECT -public: - void process(const QString &data); -signals: - void finished(const QString &result); - void error(const QString &message); -}; - -// Controller 类 - 负责桥接 -class Controller : public QObject { - Q_OBJECT -public: - void start(const QString &data); - -private slots: - void onFinished(const QString &result); - void onError(const QString &message); -}; -``` - -### 3. 信号槽约定 - -```cpp -// ✅ 推荐: 跨线程信号总是指定 QueuedConnection -connect(worker, &Worker::signal, this, &Controller::slot, - Qt::QueuedConnection); - -// ✅ 推荐: 在头文件注释中说明线程要求 -/* - * @brief Update progress bar - * @note Must be called from main thread - */ -void updateProgress(int value); -``` - ---- - -## 代码检查清单 - -- [ ] 所有 UI 更新在主线程执行 -- [ ] 工作线程中设置 moveToThread -- [ ] 跨线程信号使用 Qt::QueuedConnection -- [ ] 在调试模式添加线程检查断言 -- [ ] 避免直接从工作线程访问 UI -- [ ] 考虑使用 QtConcurrent 替代手动线程管理 -- [ ] 测试多线程场景下 UI 更新 diff --git a/.trellis/spec/big-question/gvfs-mount-path-issues.md b/.trellis/spec/big-question/gvfs-mount-path-issues.md deleted file mode 100644 index 8138b5e0..00000000 --- a/.trellis/spec/big-question/gvfs-mount-path-issues.md +++ /dev/null @@ -1,223 +0,0 @@ -# GVfs 挂载路径问题 - -> 使用硬编码的 GVfs 挂载路径导致应用在不同会话间失效。 - ---- - -## 问题描述 (P1) - -应用中硬编码 GVfs 挂载路径 `/run/user/UID/gvfs/`,导致路径在不同会话或重启后失效。 - ---- - -## 根本原因 - -GVfs 挂载点: -1. 路径包含会话 ID,随每次登录而变化 -2. `/run/user/UID/gvfs/smb-share:server=...` 会话敏感 -3. 重启或重新登录后会重新生成 - ---- - -## 错误示例 - -### 硬编码 GVfs 路径 - -```cpp -// ❌ 错误: 硬编码 GVfs 挂载路径 -QString getSambaPath(const QString &server, const QString &share) { - // 重启后会失效! - return QString("/run/user/%1/gvfs/smb-share:server=%2,share=%3") - .arg(getuid()) - .arg(server) - .arg(share); -} - -// ❌ 错误: 保存到配置文件 -settings.setValue("lastFile", "/run/user/1000/gvfs/smb..."); -// 重启后路径无效 -``` - ---- - -## 正确做法 - -### 使用 GIO URI API - -```cpp -// ✅ 正确: 始终使用 GIO URI -#include <gio/gio.h> - -void openSmbFile(const QString &server, const QString &share) { - // 构建 URI - QString uri = QString("smb://%1/%2").arg(server, share); - - // 使用 GFile - GFile *file = g_file_new_for_uri(uri.toUtf8().constData()); - GFileInputStream *input = g_file_read(file, nullptr, &error); - - if (input) { - // 读取数据 - g_object_unref(input); - } - g_object_unref(file); -} -``` - -### 使用 gio-qt - -```cpp -// ✅ 正确: 使用 gio-qt 提供的 C++ 接口 -#include <QUrl> -#include <QCoreApplication> -#include <giomm/file.h> - -void openFile(const QUrl &uri) { - auto file = Gio::File::create_for_uri(uri.toString().toStdString()); - - // 异步读取 - file->read_async(sigc::mem_fun(*this, &MyClass::onFileRead), - Glib::PRIORITY_DEFAULT); -} -``` - -### 配置存储 URI 而非路径 - -```cpp -// ✅ 正确: 存储 URI -settings.setValue("lastFile", "smb://server/share/file.txt"); - -// ✅ 当需要时动态解析 -QString getLastFilePath() { - QString uri = settings.value("lastFile").toString(); - // 使用 GIO API 访问 UR,而非转换为路径 - return uri; // 返回 URI 或使用 GIO API 处理 -} -``` - ---- - -## 挂载点路径解析 - -### 获取当前挂载信息 - -```cpp -// 使用 gio mount 命令 -QProcess process; -process.start("gio", QStringList() << "mount" << "-l"); -process.waitForFinished(); -QString mounts = process.readAllStandardOutput(); - -// 解析挂载点 -``` - -### 使用 GMountMonitor 监听挂载变化 - -```cpp -#include <gio/gio.h> - -void setupMountMonitor() { - GVolumeMonitor *monitor = g_volume_monitor_get(); - - g_signal_connect(monitor, "mount-added", - G_CALLBACK(onMountAdded), this); - g_signal_connect(monitor, "mount-removed", - G_CALLBACK(onMountRemoved), this); -} -``` - ---- - -## 检测方法 - -### 测试路径有效性 - -```cpp -bool isPathValid(const QString &path) { - GFile *file = g_file_new_for_path(path.toUtf8().constData()); - bool exists = g_file_query_exists(file, nullptr); - g_object_unref(file); - return exists; -} -``` - -### 验证 URI - -```cpp -bool isUriValid(const QString &uri) { - GFile *file = g_file_new_for_uri(uri.toUtf8().constData()); - bool exists = g_file_query_exists(file, nullptr); - g_object_unref(file); - return exists; -} -``` - ---- - -## 预防措施 - -### 1. 始终使用 URI - -```cpp -// ✅ 错误: 使用路径 -QString path = "/run/user/1000/gvfs/smb-share:..."; - -// ✅ 正确: 使用 URI -QUrl uri("smb://server/share/file.txt"); -``` - -### 2. 配置文件存储 URI - -```cpp -// 配置文件示例 -{ - "recentFiles": [ - "smb://server/file1.txt", - "file:///home/user/file2.txt", - "mtp://device/file3.mp3" - ] -} -``` - -### 3. 运行时路径解析 - -```cpp -// 只有在显示给用户时才解析路径 -QString getDisplayName(const QString &uri) { - GFile *file = g_file_new_for_uri(uri.toUtf8().constData()); - char *path = g_file_get_path(file); - QString displayPath = QString::fromUtf8(path); - g_free(path); - g_object_unref(file); - return displayPath; -} -``` - -### 4. 挂载状态检查 - -```cpp -// 检查远程位置是否已挂载 -bool isMounted(const QString &uri) { - GVolumeMonitor *monitor = g_volume_monitor_get(); - GMount *mount = g_volume_monitor_get_mount_for_uuid( - monitor, uri.toUtf8().constData()); - - if (mount) { - g_object_unref(mount); - return true; - } - return false; -} -``` - ---- - -## 代码检查清单 - -- [ ] 所有远程文件访问使用 GIO URI API -- [ ] 配置文件中存储 URI 而非路径 -- [ ] 避免硬编码 `/run/user/UID/gvfs/` 路径 -- [ ] 使用 gio-qt 或 C API 处理远程文件 -- [ ] 添加挂载状态检查 -- [ ] 提供用户挂载失败的提示 -- [ ] 监听挂载/卸载事件 diff --git a/.trellis/spec/big-question/index.md b/.trellis/spec/big-question/index.md deleted file mode 100644 index 78f6c297..00000000 --- a/.trellis/spec/big-question/index.md +++ /dev/null @@ -1,49 +0,0 @@ -# 常见问题和陷阱 - -> 快速查阅常见问题和解决方案。 - ---- - -## 概览 - -本层收录 DDE 开发中的常见问题和陷阱,提供快速解答和解决方案。 - ---- - -## 问题索引 - -| 问题 | 严重性 | 描述 | -|------|--------|------| -| [qt-memory-management-pitfalls.md](qt-memory-management-pitfalls.md) | P1 | Qt 对象生命周期和内存管理 | -| [gthread-ui-thread-safety.md](gthread-ui-thread-safety.md) | P0 | 跨线程更新 UI | -| [gvfs-mount-path-issues.md](gvfs-mount-path-issues.md) | P1 | GVfs 挂载路径会话敏感 | -| [dbus-async-vs-sync.md](dbus-async-vs-sync.md) | P1 | DBus 调用阻塞 UI | - ---- - -## 严重性说明 - -| 严重性 | 说明 | -|--------|------| -| P0 | 关键问题,必须立即解决 | -| P1 | 重要问题,应该尽快解决 | -| P2 | 一般问题,按优先级解决 | - ---- - -## 使用方式 - -### 查找问题 - -1. 描述问题症状 -2. 在本文档中查找匹配的问题 -3. 按照解决方案修复 -4. 验证修复效果 - -### 贡献新问题 - -发现文档中没有收录的新问题时: -1. 记录问题描述和根因 -2. 记录错误示例和正确做法 -3. 记录检测方法和预防措施 -4. 添加到本文档 diff --git a/.trellis/spec/big-question/qt-memory-management-pitfalls.md b/.trellis/spec/big-question/qt-memory-management-pitfalls.md deleted file mode 100644 index f0027368..00000000 --- a/.trellis/spec/big-question/qt-memory-management-pitfalls.md +++ /dev/null @@ -1,204 +0,0 @@ -# Qt 内存管理陷阱 - -> Qt 对象生命周期和内存管理的常见陷阱及解决方案。 - ---- - -## 问题描述 (P1) - -开发 DDE 应用时,经常出现内存泄漏或崩溃,通常与 Qt Parent-Child 机制、信号槽连接和 deleteLater 使用不当有关。 - ---- - -## 根本原因 - -Qt 的自动内存管理依赖于 Parent-Child 关系和信号槽的生命周期绑定,但不当使用会导致: -1. 对象被删除后仍然被访问 -2. 跨线程删除导致崩溃 -3. 循环引用导致内存泄漏 - ---- - -## 错误示例 - -### 1. 没有父对象的临时对象泄漏 - -```cpp -// ❌ 错误: 没有父对象,泄漏 -void showDialog() { - DDialog *dialog = new DDialog(); - dialog->exec(); - // 泄漏!从未删除 -} - -// ✅ 正确: 设置父对象或使用栈对象 -void showDialog() { - DDialog dialog; // 栈对象自动析构 - dialog.exec(); -} - -// ✅ 正确: 或者正确管理生命周期 -void showDialog() { - DDialog *dialog = new DDialog(parentWidget); - connect(dialog, &DDialog::finished, dialog, &DDialog::deleteLater); - dialog->exec(); -} -``` - -### 2. 跨线程删除崩溃 - -```cpp -// ❌ 错误: 在错误线程中删除 -class Manager : public QObject { -public: - void stopWorker() { - delete worker; // 崩溃!worker 在不同线程 - } - Worker *worker; -}; - -// ✅ 正确: 使用 deleteLater -void stopWorker() { - worker->deleteLater(); // 安全删除 -} -``` - -### 3. 信号槽连接导致的循环引用 - -```cpp -// ❌ 错误: 循环引用 -class Parent : public QObject { - void setChild(Child *child) { - m_child = child; - connect(child, &Child::signal, this, &Parent::handler); - connect(this, &Parent::signal, child, &Child::handler); - // 互相引用,可能泄漏 - } -}; - -// ✅ 正确: 明确所有权或使用 QWeakPointer -class Parent : public QObject { - void setChild(Child *child) { - child->setParent(this); // 明确 Parent 拥有 Child - connect(child, &Child::signal, this, &Parent::handler); - // 连接到 Parent 的槽,Parent 拥有 Child - } -}; -``` - -### 4. deleteLater 后立即使用 - -```cpp -// ❌ 错误: 计划删除后继续使用 -void cleanup() { - widget->deleteLater(); - widget->show(); // 未定义行为! -} - -// ✅ 正确: 释放引用 -void cleanup() { - widget->deleteLater(); - widget = nullptr; // 立即释放引用 -} - -// ✅ 正确: 使用信号处理 -void cleanup() { - connect(widget, &QObject::destroyed, this, [this]() { - // widget 已完全删除 - }); - widget->deleteLater(); -} -``` - ---- - -## 检测方法 - -### Valgrind - -```bash -valgrind --leak-check=full --show-leak-kinds=all ./myapp -``` - -### Qt 内存分析 - -```cpp -// Qt 内存调试模式 -#include <QtCore> -QLoggingCategory::setFilterRules("qt.core.memory.debug=true"); -``` - -### ASAN (AddressSanitizer) - -```cmake -# CMake -target_compile_options(myapp PRIVATE -fsanitize=address) -target_link_options(myapp PRIVATE -fsanitize=address) -``` - ---- - -## 预防措施 - -### 1. 始终设置父对象 - -```cpp -// 规则: 所有没有明确所有权的对象都应该设置父对象 -class MyWidget : public DWidget { - MyWidget(QWidget *parent) : DWidget(parent) { - m_label = new DLabel(this); // 自动删除 - m_button = new DPushButton(this); // 自动删除 - } -}; -``` - -### 2. 使用智能指针 - -```cpp -// 规则: 需要共享所有权时使用 QSharedPointer -QSharedPointer<Resource> resource = QSharedPointer<Resource>::create(); - -// 规则: 非拥有引用使用 QWeakPointer -QWeakPointer<Resource> weakRef = resource; -if (auto strong = weakRef.lock()) { - strong->use(); -} -``` - -### 3. 正确的信号槽连接 - -```cpp -// 规则: 跨线程使用 QueuedConnection -connect(worker, &Worker::signal, ui, &UI::handler, - Qt::QueuedConnection); - -// 规则: 连接时使用上下文对象 -connect(sender, &Sender::signal, receiver, &Receiver::slot); -// receiver 被销毁时连接自动断开 -``` - -### 4. 对象生命周期明确 - -```cpp -// 规则: 在头文件的注释中说明对象所有权 -class Controller : public QObject { - // 以下对象由 Controller 拥有 - QScopedPointer<Worker> m_worker; - QSharedPointer<DataCache> m_cache; - - // 以下为非拥有引用 - QWeakPointer<Config> m_config; -}; -``` - ---- - -## 代码检查清单 - -- [ ] 所有 `new` 创建的对象都有父对象或所有者 -- [ ] 跨线程操作使用 `deleteLater` 而非直接 `delete` -- [ ] 信号槽连接使用正确的连接类型 (Direct/Queued) -- [ ] 检查潜在的循环引用 -- [ ] 在析构函数中断开信号槽 -- [ ] 使用 RAII 模式管理资源 -- [ ] 定期运行 Valgrind/ASAN 检测泄漏 diff --git a/.trellis/spec/dfm-burn/error-handling.md b/.trellis/spec/dfm-burn/error-handling.md deleted file mode 100644 index 2e6efe91..00000000 --- a/.trellis/spec/dfm-burn/error-handling.md +++ /dev/null @@ -1,203 +0,0 @@ -# dfm-burn 错误处理规范 - -## 概述 - -dfm-burn 使用简单直接的错误处理模式:操作返回 `bool`,错误信息通过 `lastError()` 获取。 - -## 错误处理模式 - -### 1. errorMsg 成员变量 - -**私有实现类**: -```cpp -class DOpticalDiscManagerPrivate -{ -public: - QString errorMsg; // 存储最后一次操作错误信息 -}; -``` - -### 2. 公共接口返回值 - -```cpp -class DOpticalDiscManager : public QObject -{ - Q_OBJECT - -public: - // 所有操作返回 bool - bool setStageFile(const QString &diskPath, const QString &isoPath); - bool commit(const BurnOptions &opts, int speed = 0, const QString &volId = "ISOIMAGE"); - bool eraseDisc(EraseType type); - - // 获取错误信息 - QString lastError() const; -}; -``` - -### 3. 使用示例 - -```cpp -DOpticalDiscManager manager(devicePath); - -if (!manager.commit(options)) { - QString err = manager.lastError(); - qWarning() << "Burn failed:" << err; -} else { - qDebug() << "Burn successful"; -} -``` - -## 错误设置模式 - -**位置**: `src/dfm-burn/dfm-burn-lib/dopticaldiscmanager.cpp:34-47` - -```cpp -bool DOpticalDiscManager::setStageFile(const QString &diskPath, const QString &isoPath) -{ - dptr->errorMsg.clear(); - - QUrl diskUrl = QUrl::fromLocalFile(diskPath); - if (diskUrl.isEmpty() || !diskUrl.isValid()) { - dptr->errorMsg = "Invalid disk path"; - return false; - } - - QUrl isoUrl = QUrl::fromLocalFile(isoPath); - if (isoUrl.isEmpty() || !isoUrl.isValid()) { - dptr->errorMsg = "Invalid ISO path"; - return false; - } - - // ... 执行操作 - return true; -} -``` - -## 引擎错误传递 - -**位置**: `src/dfm-burn/dfm-burn-lib/dopticaldiscmanager.cpp:61-101` - -```cpp -bool DOpticalDiscManager::commit(const BurnOptions &opts, int speed, const QString &volId) -{ - QScopedPointer<DXorrisoEngine> xorrisoEngine { new DXorrisoEngine }; - - connect(xorrisoEngine.data(), &DXorrisoEngine::jobStatusChanged, this, - [this, ptr = QPointer(xorrisoEngine.data())](JobStatus status, int progress, QString speed) { - if (ptr) - Q_EMIT jobStatusChanged(status, progress, speed, ptr->takeInfoMessages()); - }, - Qt::DirectConnection); - - connect(xorrisoEngine.data(), &DXorrisoEngine::errorOccurred, this, - [this, ptr = QPointer(xorrisoEngine.data())](const QString &err) { - if (ptr) - dptr->errorMsg = err; - }, - Qt::DirectConnection); - - ret = xorrisoEngine->doBurn(...); - - return ret; -} -``` - -## 错误处理最佳实践 - -### DO - 应该做 - -1. **在操作开始时清空错误** -```cpp -bool MyClass::doSomething() { - d->errorMsg.clear(); // 清空之前的错误 - // ... 执行操作 -} -``` - -2. **返回 bool 表示操作结果** -```cpp -bool MyClass::doSomething() { - if (somethingWrong) { - d->errorMsg = "Something went wrong"; - return false; - } - return true; -} -``` - -3. **使用 lastError() 获取错误** -```cpp -if (!obj.doOperation()) { - qWarning() << obj.lastError(); -} -``` - -### DON'T - 不应该做 - -1. **不要忽略错误检查** -```cpp -// 错误: -obj.doOperation(); // 不检查返回值 - -// 正确: -if (!obj.doOperation()) { - // 处理错误 -} -``` - -2. **不要使用异常** -```cpp -// 错误: -if (invalid) { - throw std::runtime_error("Invalid"); -} - -// 正确: -if (invalid) { - d->errorMsg = "Invalid"; - return false; -} -``` - -3. **不要忘记设置错误信息** -```cpp -// 错误: -if (invalid) { - return false; // 没有解释为什么失败 -} - -// 正确: -if (invalid) { - d->errorMsg = "Invalid parameter: expected X got Y"; - return false; -} -``` - -## 信号报告进度 - -除了 `lastError()`,dfm-burn 使用信号报告进度和状态: - -```cpp -class DOpticalDiscManager : public QObject -{ -Q_SIGNALS: - void jobStatusChanged(JobStatus status, int progress, QString speed, QStringList message); -}; - -// 连接信号 -connect(&manager, &DOpticalDiscManager::jobStatusChanged, - [](JobStatus status, int progress, QString speed, QStringList messages) { - if (status == JobStatus::kFailed) { - qWarning() << "Burn failed:" << messages.join(", "); - } -}); -``` - -## 示例代码引用 - -| 文件路径 | 描述 | -|---------|------| -| `src/dfm-burn/dfm-burn-lib/dopticaldiscmanager.cpp:34-47` | 错误设置示例 | -| `src/dfm-burn/dfm-burn-lib/dopticaldiscmanager.cpp:61-101` | 引擎错误传递 | -| `src/dfm-burn/dfm-burn-lib/dpacketwritingcontroller.cpp:162-163` | lastError() 实现 | diff --git a/.trellis/spec/dfm-burn/index.md b/.trellis/spec/dfm-burn/index.md deleted file mode 100644 index ccf1c554..00000000 --- a/.trellis/spec/dfm-burn/index.md +++ /dev/null @@ -1,40 +0,0 @@ -# dfm-burn 库开发规范 - -> dfm-burn 是光盘刻录库,使用 xorriso/udfburn 后端。 - -## 技术栈 - -- **C++11/14** -- **Qt5/Qt6** - QObject、信号槽 -- **libburnia xorriso** - 光盘刻录后端 -- **UDF 客户端** - 自定义 UDF 文件系统实现 - -## 规范索引 - -| 文档 | 描述 | 优先级 | -|------|------|--------| -| [error-handling.md](error-handling.md) | errorMsg + lastError() 模式 | P0 | -| [naming-conventions.md](naming-conventions.md) | 类名、方法名约定 | P0 | -| [memory-management.md](memory-management.md) | QScopedPointer、QSharedData | P0 | -| [code-patterns.md](code-patterns.md) | 信号连接、DirectConnection | P1 | - ---- - -## 开发前检查清单 - -- [ ] 阅读 [error-handling.md](error-handling.md) 了解 errorMsg 模式 -- [ ] 阅读 [memory-management.md](memory-management.md) 了解 QScopedPointer 和 QSharedData -- [ ] 信号声明使用 `Q_SIGNALS`,槽使用 `Q_SLOTS` - ---- - -## 核心规则摘要 - -| 规则 | 要求 | -|------|------| -| 信号声明 | 必须使用 `Q_SIGNALS` 宏 | -| 成员变量 | 小驼峰命名,bool 用 is/has 前缀 | -| 错误处理 | 返回 bool + lastError() 获取错误 | -| QObject 子类 | 使用 QScopedPointer 管理私有实现 | -| 数据类 | 使用 QSharedData(隐式共享) | -| 异步操作 | 使用 DirectConnection + QPointer 防止野指针 | diff --git a/.trellis/spec/dfm-burn/naming-conventions.md b/.trellis/spec/dfm-burn/naming-conventions.md deleted file mode 100644 index 0070e2f3..00000000 --- a/.trellis/spec/dfm-burn/naming-conventions.md +++ /dev/null @@ -1,171 +0,0 @@ -# dfm-burn 命名约定 - -## 类命名 - -### 公共接口类 - -**规则**: `D` + 功能名词,大驼峰 (PascalCase) - -```cpp -class DOpticalDiscManager; // 光盘管理器 -class DOpticalDiscInfo; // 光盘信息 -class DPacketWritingController; // 包写入控制器 -``` - -### 引擎类 - -**规则**: `D` + 引擎名称 + `Engine` - -```cpp -class DXorrisoEngine; // XORRISO 刻录引擎 -class DUDFBurnEngine; // UDF 刻录引擎 -``` - -## 方法命名 - -### 公共方法 - -**规则**: 小驼峰 (camelCase) - -```cpp -// 操作方法 -bool setStageFile(const QString &diskPath, const QString &isoPath); -bool commit(const BurnOptions &opts, int speed = 0, const QString &volId = "ISOIMAGE"); -bool eraseDisc(EraseType type); - -// 错误获取 -QString lastError() const; - -// 光盘信息查询 -bool hasMedium() const; -const QString &volumeId() const; -quint64 usedSize() const; -quint64 totalSize() const; -``` - -## 成员变量命名 - -### 普通成员 - -**规则**: 小驼峰命名 - -```cpp -class DOpticalDiscManagerPrivate { -public: - QString errorMsg; - QString curDev; - QPair<QString, QString> files; // first: local disk, second: optical disk -}; - -class DOpticalDiscInfoPrivate { -public: - bool formatted {}; - MediaType media; - quint64 data {}; - QString devid {}; - QString volid {}; -}; -``` - -### 布尔成员 - -**规则**: `is` 或 `has` 前缀 - -```cpp -class DOpticalDiscInfoPrivate { -public: - bool hasMedium() const; - bool isBlank() const; - bool isRewritable() const; - bool isFormatted() const; -}; -``` - -## 枚举命名 - -### 枚举类型 - -**规则**: 大驼峰 (PascalCase) - -```cpp -enum JobStatus { ... }; -enum MediaType { ... }; -enum EraseType { ... }; -enum BurnType { ... }; -``` - -### 枚举值 - -**规则**: 大驼峰 (PascalCase) - -```cpp -enum JobStatus { - kIdle, - kRunning, - kFinished, - kFailed -}; - -enum MediaType { - kUnknown, - kCD, - kDVD, - kBD, - kHDDVD -}; - -enum EraseType { - kFast, - kComplete -}; - -enum BurnType { - kISO9660, - kUDF -}; -``` - -## 文件命名 - -| 类型 | 规则 | 示例 | -|------|------|------| -| 公共头文件 | 类名小写 + `.h` | `dopticaldiscmanager.h` | -| 私有实现头文件 | 类名小写 + `_p.h` | `dopticaldiscmanager_p.h` | -| 实现文件 | 类名小写 + `.cpp` | `dopticaldiscmanager.cpp` | - -## 信号命名 - -```cpp -class DOpticalDiscManager : public QObject { -Q_SIGNALS: - void jobStatusChanged(JobStatus status, int progress, QString speed, QStringList message); -}; - -class DXorrisoEngine : public QObject { -Q_SIGNALS: - void jobStatusChanged(JobStatus status, int progress, QString speed); - void errorOccurred(const QString &error); -}; -``` - -## 命名空间 - -```cpp -// 全局头文件定义宏 -#define DFM_BURN_BEGIN_NS namespace DFMBURN { -#define DFM_BURN_END_NS } -#define DFM_BURN_USE_NS using namespace DFMBURN; - -// 使用 -DFM_BURN_BEGIN_NS -class DOpticalDiscManager { /* ... */ }; -DFM_BURN_END_NS -``` - -## 示例代码引用 - -| 文件路径 | 描述 | -|---------|------| -| `src/dfm-burn/dfm-burn-lib/private/dpacketwritingcontroller_p.h:12-28` | 成员变量命名 | -| `src/dfm-burn/dfm-burn-lib/private/dopticaldiscinfo_p.h:44-61` | 布尔成员命名 | -| `include/dfm-burn/dfm-burn/dopticaldiscinfo.h:26-42` | 枚举命名 | diff --git a/.trellis/spec/dfm-io/error-handling.md b/.trellis/spec/dfm-io/error-handling.md deleted file mode 100644 index d51fcb69..00000000 --- a/.trellis/spec/dfm-io/error-handling.md +++ /dev/null @@ -1,228 +0,0 @@ -# dfm-io 错误处理规范 - -## 概述 - -dfm-io 使用双重错误处理系统:`GError`(来自 GLib/GIO)和 `DFMIOErrorCode`(项目自定义)。 - -## 错误类型 - -### 1. DFMIOErrorCode 枚举 - -**位置**: `include/dfm-io/dfm-io/error/en.h` - -```cpp -enum class DFMIOErrorCode : int { - DFM_IO_ERROR_NONE = -1, // 无错误 - DFM_IO_ERROR_FAILED, // 通用错误 - DFM_IO_ERROR_NOT_FOUND, // 文件未找到 - DFM_IO_ERROR_EXISTS, // 文件已存在 - DFM_IO_ERROR_PERMISSION_DENIED, // 权限拒绝 - DFM_IO_ERROR_NO_SPACE, // 磁盘空间不足 - DFM_IO_ERROR_NOT_DIRECTORY, // 不是目录 - // ... 约 70+ 个错误码 -}; -``` - -### 2. DFMIOError 结构体 - -**位置**: `include/dfm-io/dfm-io/error/error.h` - -```cpp -struct DFMIOError { - DFMIOErrorCode errorCode; - QString errMsg; - - // 获取错误码对应的默认消息 - QString code() const { return GetError_En(errorCode); } - - // 优先返回自定义消息,否则返回默认消息 - QString errorMsg() const { returnerrMsg.isEmpty() ? code() : errMsg; } - - // 判断是否有错误 - bool isError() const { return errorCode != DFM_IO_ERROR_NONE; } - - // 设置错误码 - void setCode(DFMIOErrorCode code) { errorCode = code; } - - // 设置自定义消息 - void setMessage(const QString &msg) { errMsg = msg; } - - // 隐式转换为 bool - explicit operator bool() const { return isError(); } -}; -``` - -## 错误转换模式 - -### 1. 从 GError 转换 - -**位置**: `src/dfm-io/dfm-io/doperator.cpp:31-42` - -```cpp -void DOperatorPrivate::setErrorFromGError(GError *gerror) -{ - if (!gerror) - return; - - // 将 GError code 映射到 DFMIOErrorCode - error.setCode(DFMIOErrorCode(gerror->code)); - - // 通用错误需要特殊处理消息 - if (error.code() == DFMIOErrorCode::DFM_IO_ERROR_FAILED) { - QString strErr(gerror->message); - // GIO 错误消息格式通常是 "domain: message: other: info" - // 提取核心信息 - if (strErr.contains(':')) - strErr = strErr.left(strErr.indexOf(":")) + strErr.mid(strErr.lastIndexOf(":")); - error.setMessage(strErr); - } -} -``` - -### 2. 从 errno 转换 - -**位置**: `src/dfm-io/dfm-io/doperator.cpp:44-90` - -```cpp -void DOperatorPrivate::setErrorFromErrno(int errnoValue) -{ - DFMIOErrorCode errorCode; - switch (errnoValue) { - case EACCES: - case EPERM: - errorCode = DFM_IO_ERROR_PERMISSION_DENIED; - break; - case ENOENT: - errorCode = DFM_IO_ERROR_NOT_FOUND; - break; - case EEXIST: - errorCode = DFM_IO_ERROR_EXISTS; - break; - case ENOSPC: - errorCode = DFM_IO_ERROR_NO_SPACE; - break; - default: - errorCode = DFM_IO_ERROR_FAILED; - break; - } - error.setCode(errorCode); -} -``` - -## 公共接口错误处理模式 - -### 每个操作类都有 lastError() 方法 - -```cpp -// DOperator -DFMIOError lastError() const; - -// DFileInfo -DFMIOError lastError() const; - -// DWatcher -DFMIOError lastError() const; - -// DEnumerator -DFMIOError lastError() const; -``` - -### 使用示例 - -```cpp -// 操作失败时,返回 false 或 nullptr -DOperator op(filePath); -if (!op.renameFile(newPath)) { - DFMIOError err = op.lastError(); - qWarning() << "Rename failed:" << err.errorMsg(); - return; -} -``` - -## 错误处理最佳实践 - -### DO - 应该做 - -1. **每次操作后检查错误** -```cpp -DOperator op(filePath); -if (!op.copyFile(destPath)) { - handle_error(op.lastError()); -} -``` - -2. **使用 lastError() 获取完整错误信息** -```cpp -DFMIOError err = op.lastError(); -if (err.isError()) { - errCode = err.errorCode(); // 获取错误码 - errMsg = err.errorMsg(); // 获取错误消息 -} -``` - -3. **使用 g_autoptr 自动管理 GError** -```cpp -g_autoptr(GError) gerror = nullptr; -// GLib 函数调用会填充 gerror -if (opFailed) { - setErrorFromGError(gerror); -} -// g_error_free 不需要,g_autoptr 自动处理 -``` - -### DON'T - 不应该做 - -1. **不要忽略错误** -```cpp -// 错误: -op.renameFile(newPath); // 忽略返回值 - -// 正确: -if (!op.renameFile(newPath)) { - // 处理错误 -} -``` - -2. **不要在转换后丢失原始错误码** -```cpp -// 错误: -if (gerror) { - QString msg(gerror->message); - setError(msg); // 丢失了 gerror->code -} - -// 正确: -if (gerror) { - error.setCode(DFMIOErrorCode(gerror->code)); - error.setMessage(gerror->message); -} -``` - -3. **不要忘记释放手动分配的 GError** -```cpp -// 错误: -GError *err = nullptr; -call_func(&err); -// 忘记 g_error_free(err); - -// 正确: -GError *err = nullptr; -call_func(&err); -if (err) { - // 处理错误 - g_error_free(err); -} - -// 更好: 使用 g_autoptr -g_autoptr(GError) err = nullptr; -call_func(&err); -``` - -## 示例代码引用 - -| 文件路径 | 描述 | -|---------|------| -| `include/dfm-io/dfm-io/error/en.h` | 错误码枚举定义 | -| `include/dfm-io/dfm-io/error/error.h` | DFMIOError 结构体 | -| `src/dfm-io/dfm-io/doperator.cpp:31-90` | 错误转换实现 | -| `src/dfm-io/dfm-io/dfileinfo.cpp` | DFileInfo 错误处理 | diff --git a/.trellis/spec/dfm-io/index.md b/.trellis/spec/dfm-io/index.md deleted file mode 100644 index 8cf11247..00000000 --- a/.trellis/spec/dfm-io/index.md +++ /dev/null @@ -1,41 +0,0 @@ -# dfm-io 库开发规范 - -> dfm-io 是 Deepin 文件管理器的文件 I/O 操作核心库,基于 GIO/GLib 提供异步文件操作。 - -## 技术栈 - -- **C++11+** -- **Qt5/Qt6** - QUrl、QObject、智能指针 -- **GLib/GIO** - 底层文件系统操作 -- **g_autoptr** - GLib 自动指针 - -## 规范索引 - -| 文档 | 描述 | 优先级 | -|------|------|--------| -| [error-handling.md](error-handling.md) | GError + DFMIOErrorCode 错误处理 | P0 | -| [naming-conventions.md](naming-conventions.md) | 类名、方法名、变量名约定 | P0 | -| [memory-management.md](memory-management.md) | P-impl 模式、智能指针、GLib 对象管理 | P0 | -| [code-patterns.md](code-patterns.md) | 信号槽、回调、文件组织 | P1 | -| [signals-and-callbacks.md](signals-and-callbacks.md) | Q_SIGNALS、GIO 信号桥接 | P1 | - ---- - -## 开发前检查清单 - -- [ ] 阅读 [error-handling.md](error-handling.md) 了解 GError 转换和 DFMIOErrorCode 使用 -- [ ] 阅读 [memory-management.md](memory-management.md) 了解 g_autoptr 和 GLib 对象释放 -- [ ] 确认禁用 signals/slots 关键字,使用 Q_SIGNALS/Q_SLOTS 宏 - ---- - -## 核心规则摘要 - -| 规则 | 要求 | -|------|------| -| 信号声明 | 必须使用 `Q_SIGNALS` 宏 | -| 槽声明 | 必须使用 `Q_SLOTS` 宏 | -| GLib 对象 | 优先使用 `g_autoptr`,手动释放用 `g_object_unref` | -| 错误处理 | 检查 `lastError()` 获取错误信息 | -| 命名空间 | 使用 `BEGIN_IO_NAMESPACE` / `END_IO_NAMESPACE` 宏 | -| 私有实现 | 派生 QSharedData | diff --git a/.trellis/spec/dfm-io/naming-conventions.md b/.trellis/spec/dfm-io/naming-conventions.md deleted file mode 100644 index c151c5f2..00000000 --- a/.trellis/spec/dfm-io/naming-conventions.md +++ /dev/null @@ -1,198 +0,0 @@ -# dfm-io 命名约定 - -## 类命名 - -### 公共接口类 - -**规则**: `D` + 功能名词,大驼峰 (PascalCase) - -```cpp -class DFile; -class DFileInfo; -class DOperator; -class DWatcher; -class DEnumerator; -class DFileFuture; -class DFileInfoFuture; -class DTrashHelper; -class DLocalHelper; -class DMediaInfo; -``` - -### 私有实现类 - -**规则**: 公共类名 + `Private` - -```cpp -class DFilePrivate; -class DFileInfoPrivate; -class DOperatorPrivate; -class DWatcherPrivate; -class DEnumeratorPrivate; -class DFileFuturePrivate; -``` - -## 方法命名 - -### 公共方法 - -**规则**: 小驼峰 (camelCase),动词开头 - -```cpp -// 文件操作 -bool renameFile(const QString &newName); -bool copyFile(const QString &dest); -bool moveFile(const QString &dest); -bool deleteFile(); - -// 属性访问 -QUrl uri() const; -bool exists() const; -void setUri(const QUrl &uri); - -// 异步方法 -void renameFileAsync(const QString &newName, RenameCallbackFunc cb, void *userData); -void copyFileAsync(const QString &dest, CopyCallbackFunc cb, void *userData); -``` - -### 私有方法 - -**规则**: 小驼峰 - -```cpp -void setErrorFromGError(GError *gerror); -void setErrorFromErrno(int errnoValue); -GFile *makeGFile(const QUrl &url); -void checkAndResetCancel(); -``` - -## 成员变量命名 - -### d 和 q 指针 - -**规则**: -- `d`: 指向私有实现(公共类中) -- `q`: 指向公共类(私有实现类中) - -```cpp -// 公共类 -class DFileInfo { -private: - mutable QSharedDataPointer<DFileInfoPrivate> d; // d 指向私有实现 -}; - -// 私有实现类 -class DFileInfoPrivate { -public: - DFileInfo *q { nullptr }; // q 指向公共类 -}; -``` - -### 普通成员变量 - -**规则**: 小驼峰命名 - -```cpp -class DWatcherPrivate { - QUrl uri; - QList<QUrl> children; - GFileMonitor *monitor { nullptr }; - GCancellable *gcancellable { nullptr }; - DFMIOError error; -}; -``` - -### GLib 对象 - -**规则**: 加 `g` 前缀或直接使用类型名 - -```cpp -GFile *gfile { nullptr }; -GFileInfo *gfileinfo { nullptr }; -GFileMonitor *monitor { nullptr }; -GCancellable *gcancellable { nullptr }; -``` - -## 枚举命名 - -### 枚举类型 - -**规则**: 大驼峰 (PascalCase) - -```cpp -enum class DFileType : uint16_t { ... }; -enum class DFileAttributeType : uint8_t { ... }; -enum class FileQueryInfoFlags : uint8_t { ... }; -``` - -### 枚举值 - -**规则**: `k` + 大驼峰 (PascalCase) - -```cpp -enum class DFileType : uint16_t { - kUnknown = 0, - kRegular = 1, - kDirectory = 2, - kSymbolicLink = 3, - kSpecial = 4, - kShortcut = 5, - kMountable = 6, - kUserType = 0x100 -}; - -enum class DFileAttributeType : uint8_t { - kTypeInvalid = 0, - kTypeString = 1, - kTypeByteString = 2, - kTypeBool = 3, - kTypeUint32 = 4, - kTypeInt32 = 5, - kTypeUint64 = 6, - kTypeInt64 = 7 -}; -``` - -## 文件命名 - -| 类型 | 规则 | 示例 | -|------|------|------| -| 公共头文件 | 类名小写 + `.h` | `dfile.h`, `dfileinfo.h` | -| 私有实现头文件 | 类名小写 + `_p.h` | `dfile_p.h`, `dfileinfo_p.h` | -| 实现文件 | 类名小写 + `.cpp` | `dfile.cpp`, `dfileinfo.cpp` | -| 工具类 | 小驼峰 + `.h` | `dlocalhelper.h`, `dmediainfo.h` | - -## 命名空间 - -```cpp -// 全局头文件定义宏 -#define DFMIO dfmio -#define BEGIN_IO_NAMESPACE namespace DFMIO { -#define USING_IO_NAMESPACE using namespace DFMIO; -#define END_IO_NAMESPACE } - -// 使用 -BEGIN_IO_NAMESPACE -class DFileInfo { /* ... */ }; -END_IO_NAMESPACE -``` - -## 类型别名 - -```cpp -// 回调函数类型 -using ProgressCallbackFunc = void (*)(int64_t, int64_t, void *); -using FileOperateCallbackFunc = void (*)(bool, void *); - -// std::function 类型 -using InitQuerierAsyncCallback = std::function<void(bool, void *)>; -using AttributeAsyncCallback = std::function<void(bool, void *, QVariant)>; -``` - -## 示例代码引用 - -| 文件路径 | 描述 | -|---------|------| -| `include/dfm-io/dfm-io/dfileinfo.h:27-191` | 枚举命名示例 | -| `src/dfm-io/dfm-io/private/dfileinfo_p.h` | 私有类命名示例 | -| `include/dfm-io/dfm-io/doperator.h:25-27` | 回调函数类型定义 | diff --git a/.trellis/spec/dfm-mount/error-handling.md b/.trellis/spec/dfm-mount/error-handling.md deleted file mode 100644 index be4577ea..00000000 --- a/.trellis/spec/dfm-mount/error-handling.md +++ /dev/null @@ -1,259 +0,0 @@ -# dfm-mount 错误处理规范 - -## 概述 - -dfm-mount 处理来自多个来源的错误:UDisks2、GIO、GDBus,通过 `DeviceError` 枚举统一管理。 - -## 错误类型 - -### 1. DeviceError 枚举 - -**位置**: `include/dfm-mount/dfm-mount/base/dmount_global.h:158-343` - -```cpp -enum class DeviceError : int16_t { - kNoError = 0, - kUnhandledError = 10001, - - // UDisks2 错误 (10000-10099) - kUDisksErrorFailed = UDISKS_ERR_START, // 10000 - kUDisksErrorAlreadyMounted, - kUDisksErrorNotMounted, - // ... - - // GIO 错误 (10100-10199) - kGIOError = GIO_ERR_START, // 10100 - kGIOErrorFailed, - kGIOErrorNotFound, - // ... - - // GDBus 错误 (10200-10299) - kGDBusError = GDBUS_ERR_START, // 10200 - - // 自定义错误 (20000-29999) - kUserError = USER_ERR_START, // 20000 - kUserErrorNotMountable, - kUserErrorAlreadyMounted, - // ... -}; -``` - -### 2. OperationErrorInfo 结构体 - -```cpp -struct OperationErrorInfo { - DeviceError code; // 错误码 - QString message; // 错误消息 - - OperationErrorInfo() - : code(DeviceError::kNoError) {} - - OperationErrorInfo(DeviceError c, const QString &msg = "") - : code(c), message(msg) {} - - bool hasError() const { return code != DeviceError::kNoError; } -}; - -// 私有实现中存储最后一次错误 -mutable OperationErrorInfo lastError { DeviceError::kNoError, "" }; -``` - -## 错误转换 - -### 1. GError 到 DeviceError 转换 - -**位置**: `src/dfm-mount/lib/base/dmountutils.cpp:503-519` - -```cpp -DeviceError Utils::castFromGError(const GError *const err) -{ - if (!err) - return DeviceError::kNoError; - - // 根据 GError domain 映射到不同的 DeviceError 范围 - const char *errDomain = g_quark_to_string(err->domain); - if (strcmp(errDomain, UDISKS_ERR_DOMAIN) == 0) { - return static_cast<DeviceError>(err->code + UDISKS_ERR_START); - } else if (strcmp(errDomain, GIO_ERR_DOMAIN) == 0) { - return static_cast<DeviceError>(err->code + GIO_ERR_START); - } else if (strcmp(errDomain, GDBUS_ERR_DOMAIN) == 0) { - return static_cast<DeviceError>(err->code + GDBUS_ERR_START); - } - return DeviceError::kUnhandledError; -} -``` - -### 2. 错误生成工具 - -```cpp -OperationErrorInfo Utils::genOperateErrorInfo(DeviceError errCode, const QString &errMsg = "") -{ - return { errCode, errMsg }; -} -``` - -## 同步操作错误处理 - -**位置**: `src/dfm-mount/lib/dblockdevice.cpp:581-612` - -```cpp -QString DBlockDevicePrivate::mount(const QVariantMap &opts) -{ - warningIfNotInMain(); // 线程安全检查 - - // 检查是否有正在进行的作业 - if (findJob(kBlockJob)) - return ""; - - UDisksFilesystem_autoptr fs = getFilesystemHandler(); - if (!fs) { - lastError = Utils::genOperateErrorInfo(DeviceError::kUserErrorNotMountable); - return ""; - } - - // 准备参数 - GError *err = nullptr; - GVariant *gopts = Utils::castFromQVariantMap(opts); - char *mountPoint = nullptr; - - // 同步调用 DBus 方法 - bool mounted = udisks_filesystem_call_mount_sync(fs, gopts, &mountPoint, nullptr, &err); - - // 处理错误 - handleErrorAndRelease(err); - - 返回挂载点或空字符串 - QString ret; - if (mounted && mountPoint) { - ret = mountPoint; - g_free(mountPoint); - } - return ret; -} -``` - -## 异步操作错误处理 - -**位置**: `src/dfm-mount/lib/dblockdevice.cpp:23-55` - -```cpp -// 错误处理回调 -void DBlockDevicePrivate::handleErrorAndRelease(CallbackProxy *proxy, - bool result, - GError *gerr, - QString info) -{ - OperationErrorInfo err; - if (!result && gerr) { - // 转换 GError - err.code = Utils::castFromGError(gerr); - err.message = gerr->message; - qInfo() << "error occured while operating device" << err.message; - - // 释放 GError - g_error_free(gerr); - } - - // 调用用户回调 - if (proxy) { - if (proxy->cb) { - proxy->cb(result, err); - } else if (proxy->cbWithInfo) { - proxy->cbWithInfo(result, err, info); - } - delete proxy; // 清理代理对象 - } -} - -// 异步挂载回调 -void DBlockDevicePrivate::mountAsyncCallback(GObject *sourceObj, - GAsyncResult *res, - gpointer userData) -{ - UDisksFilesystem *fs = UDISKS_FILESYSTEM(sourceObj); - CallbackProxy *proxy = static_cast<CallbackProxy *>(userData); - - GError *err = nullptr; - g_autofree char *mountPoint = nullptr; - - // 完成 DBus 异步调用 - bool result = udisks_filesystem_call_mount_finish(fs, &mountPoint, res, &err); - if (mountPoint) - result = true; - - // 处理结果和错误 - QString info(mountPoint ? mountPoint : ""); - handleErrorAndRelease(proxy, result, err, info); -} -``` - -## 错误处理最佳实践 - -### DO - 应该做 - -1. **使用 GError 自动指针** -```cpp -g_autoptr(GError) gerror = nullptr; -// GError 自动释放 -``` - -2. **回调中正确释放 GError** -```cpp -if (gerr) { - // 处理错误 - g_error_free(gerr); -} -``` - -3. **使用 warningIfNotInMain() 检查线程安全** -```cpp -QString DBlockDevicePrivate::mount(const QVariantMap &opts) { - warningIfNotInMain(); // 同步方法必须在主线程 -} -``` - -### DON'T - 不应该做 - -1. **不要忽略 GError 释放** -```cpp -GError *err = nullptr; -udisks_call(&err); -// 忘记 g_error_free(err) -``` - -2. **不要在非主线程调用同步方法** -```cpp -// 错误: 在工作线程中调用 mount() -QThread::create([]() { - device->mount(); // 会触发警告 -})->start(); - -// 正确: 使用异步方法 -QThread::create([]() { - device->mountAsync({}, [](bool ok, OperationErrorInfo err) { - // 处理结果 - }); -})->start(); -``` - -3. **不要忘记释放 g_autofree 和 g_free** -```cpp -// g_autofree 自动释放 -g_autofree char *mountPoint = nullptr; -udisks_filesystem_call_mount_sync(fs, opts, &mountPoint, nullptr, &err); -// 不需要 g_free(mountPoint) - -// 手动分配需要释放 -char *data = g_strdup("hello"); -// 使用后 -g_free(data); -``` - -## 示例代码引用 - -| 文件路径 | 描述 | -|---------|------| -| `include/dfm-mount/dfm-mount/base/dmount_global.h:158-343` | DeviceError 枚举 | -| `src/dfm-mount/lib/base/dmountutils.cpp:503-519` | GError 转换函数 | -| `src/dfm-mount/lib/dblockdevice.cpp:581-612` | 同步挂载错误处理 | -| `src/dfm-mount/lib/dblockdevice.cpp:23-55` | 异步回调错误处理 | diff --git a/.trellis/spec/dfm-mount/index.md b/.trellis/spec/dfm-mount/index.md deleted file mode 100644 index 10cfb6e2..00000000 --- a/.trellis/spec/dfm-mount/index.md +++ /dev/null @@ -1,44 +0,0 @@ -# dfm-mount 库开发规范 - -> dfm-mount 是设备挂载库,使用 UDisks2 和 GIO/GDBus 管理设备。 - -## 技术栈 - -- **C++11+** -- **Qt5/Qt6** - QObject、QSharedPointer、QDBusServiceWatcher -- **UDisks2** - libudisks2 设备管理系统 -- **GLib/GIO** - GVariant、GError、GDBus、GSignal -- **libmount** - 解析挂载点 - -## 规范索引 - -| 文档 | 描述 | 优先级 | -|------|------|--------| -| [error-handling.md](error-handling.md) | UDisks2、GIO、GDBus 错误处理 | P0 | -| [naming-conventions.md](naming-conventions.md) | 类名、方法名、变量名约定 | P0 | -| [memory-management.md](memory-management.md) | GLib autoptr、UDisksX_autoptr | P0 | -| [code-patterns.md](code-patterns.md) | DBus 集成、GIO 信号桥接 | P1 | -| [dbus-integration.md](dbus-integration.md) | UDisks2 调用模式 | P1 | - ---- - -## 开发前检查清单 - -- [ ] 阅读 [error-handling.md](error-handling.md) 了解 GError 转换和 DeviceError 枚举 -- [ ] 阅读 [memory-management.md](memory-management.md) 了解 UDisksX_autoptr 使用 -- [ ] 阅读 [dbus-integration.md](dbus-integration.md) 了解同步/异步 DBus 调用 -- [ ] 确认禁用 signals/slots 关键字,使用 Q_SIGNALS/Q_SLOTS 宏 -- [ ] 同步方法会调用 `warningIfNotInMain()` 检查线程安全 - ---- - -## 核心规则摘要 - -| 规则 | 要求 | -|------|------| -| 信号声明 | 必须使用 `Q_SIGNALS` 宏 | -| GIO 回调 | 使用静态回调函数 + userData 传递 this | -| GLib 对象 | 必须使用 `UDisksX_autoptr` 自动管理 | -| 线程安全 | 同步方法仅主线程,否则用 Async 版本 | -| 错误处理 | `lastError()` 返回 OperationErrorInfo | -| 函数注册 | 使用 std::bind 注册虚函数到基类 | diff --git a/.trellis/spec/dfm-mount/naming-conventions.md b/.trellis/spec/dfm-mount/naming-conventions.md deleted file mode 100644 index ec4bcd3a..00000000 --- a/.trellis/spec/dfm-mount/naming-conventions.md +++ /dev/null @@ -1,192 +0,0 @@ -# dfm-mount 命名约定 - -## 类命名 - -### 公共接口类 - -**规则**: `D` + 功能名词,大驼峰 (PascalCase) - -```cpp -class DDevice; // 设备基类 -class DBlockDevice; // 块设备 -class DProtocolDevice; // 协议设备 -class DBlockMonitor; // 块设备监视器 -class DProtocolMonitor; // 协议设备监视器 -class DDeviceManager; // 设备管理器 -``` - -### 私有实现类 - -**规则**: 公共类名 + `Private` - -```cpp -class DDevicePrivate; -class DBlockDevicePrivate; -class DProtocolDevicePrivate; -class DBlockMonitorPrivate; -class DProtocolMonitorPrivate; -``` - -## 方法命名 - -### 公共方法 - -**规则**: 小驼峰 (camelCase) - -```cpp -// 同步/异步方法配对 -QString mount(const QVariantMap &opts = {}); -void mountAsync(const QVariantMap &opts = {}, DeviceOperateCallbackWithMessage cb = nullptr); - -bool unmount(const QVariantMap &opts = {}); -void unmountAsync(const QVariantMap &opts = {}, DeviceOperateCallback cb = nullptr); - -QString path() const; -bool hasBlock() const; -QVariant queryProperty(Property property) const; -``` - -### 静态回调函数 - -**规则**: `on` + 事件描述,静态成员 - -```cpp -class DBlockMonitorPrivate final { -private: - // GIO 信号使用静态回调函数 - static void onObjectAdded(GDBusObjectManager *mng, - GDBusObject *obj, - gpointer userData); - static void onObjectRemoved(GDBusObjectManager *mng, - GDBusObject *obj, - gpointer userData); - static void onPropertyChanged(GDBusObjectManagerClient *mngClient, - GDBusObjectProxy *objProxy, - GDBusProxy *dbusProxy, - GVariant *property, - const gchar *const invalidProperty, - gpointer userData); -}; -``` - -## 成员变量命名 - -### d 和 q 指针 - -```cpp -class DDevice { -protected: - QScopedPointer<DDevicePrivate> d; // d 指向私有实现 -}; - -class DDevicePrivate { -public: - DDevice *q { nullptr }; // q 指向公共类 -}; -``` - -### 普通成员变量 - -**规则**: 小驼峰命名 - -```cpp -class DBlockDevicePrivate { - QString blkObjPath; - UDisksClient *client { nullptr }; - bool deviceOpended { false }; - OperationErrorInfo lastError; - QHash<QString, ulong> connections; -}; -``` - -### GLib/UDisks 对象 - -**规则**: 全小写描述性名称 - -```cpp -UDisksClient *client { nullptr }; -UDisksObject_autoptr udisksObj; -UDisksBlock_autoptr block; -UDisksDrive_autoptr drive; -``` - -## 枚举命名 - -### 枚举类型 - -**规则**: 大驼峰 (PascalCase) - -```cpp -enum class DeviceType : uint16_t { ... }; -enum class Property : uint16_t { ... }; -enum class DeviceError : int16_t { ... }; -``` - -### 枚举值 - -**规则**: `k` + 大驼峰 (PascalCase) - -```cpp -enum class DeviceType : uint16_t { - kAllDevice = 0, - kBlockDevice = 1, - kProtocolDevice = 2, - kNetDevice = 3, -}; - -enum class Property : uint16_t { - kNotInit = 0, - kBlockProperty = 1, - kDriveProperty = 30, - kFileSystemProperty = 31, -}; - -enum class DeviceError : int16_t { - kNoError = 0, - kUnhandledError = 10001, - kUDisksErrorFailed = 10000, - kUserErrorNotMountable = 20000, -}; -``` - -## 文件命名 - -| 类型 | 规则 | 示例 | -|------|------|------| -| 公共头文件 | 类名小写 + `.h` | `ddevice.h`, `dblockdevice.h` | -| 私有实现头文件 | 类名小写 + `_p.h` | `ddevice_p.h`, `dblockdevice_p.h` | -| 实现文件 | 类名小写 + `.cpp` | `ddevice.cpp`, `dblockdevice.cpp` | - -## 类型别名 - -```cpp -using DeviceOperateCallback = std::function<void(bool, OperationErrorInfo)>; -using DeviceOperateCallbackWithMessage = std::function<void(bool, OperationErrorInfo, QString)>; -``` - -## 宏定义 - -```cpp -// 虚函数占位符 -#define DMNT_VIRTUAL virtual - -// 线程安全警告 -#define warningIfNotInMain() \ - { \ - if (qApp->thread() != QThread::currentThread()) \ - qWarning() << "<" << __PRETTY_FUNCTION__ << ">"; \ - } - -// 错误域常量 -#define UDISKS_ERR_DOMAIN "org.freedesktop.UDisks2.Error" -#define GIO_ERR_DOMAIN "g-io-error-quark" -#define GDBUS_ERR_DOMAIN "g-dbus-error-quark" -``` - -## 示例代码引用 - -| 文件路径 | 描述 | -|---------|------| -| `include/dfm-mount/dfm-mount/base/dmount_global.h:36-57` | 枚举命名示例 | -| `src/dfm-mount/private/dblockmonitor_p.h:48-53` | 静态回调函数命名 | -| `src/dfm-mount/private/dblockdevice_p.h:95-96` | 成员变量命名 | diff --git a/.trellis/spec/dfm-search/error-handling.md b/.trellis/spec/dfm-search/error-handling.md deleted file mode 100644 index aebcea2d..00000000 --- a/.trellis/spec/dfm-search/error-handling.md +++ /dev/null @@ -1,271 +0,0 @@ -# dfm-search 错误处理规范 - -## 概述 - -dfm-search 使用现代 C++ 的 `std::error_code` 模式进行错误处理,不使用异常。 - -## 错误类型 - -### 1. 分层错误码设计 - -**位置**: `include/dfm-search/dfm-search/searcherror.h:16-83` - -```cpp -// 通用搜索错误码 (0-999) -enum class SearchErrorCode : int { - Success = 0, - PermissionDenied = 1, - InvalidQuery = 100, - PathIsEmpty, - PathNotFound, - SearchTimeout, - InternalError, - InvalidBoolean, - InvalidSerchMethod, -}; - -// 文件名搜索错误码 (1000-1999) -enum class FileNameSearchErrorCode : int { - KeywordIsEmpty = 1000, - KeywordTooLong, - InvalidPinyinFormat = 1050, - InvalidFileTypes = 1100, - FileNameIndexNotFound = 1200, - FileNameIndexException = 1300, -}; - -// 内容搜索错误码 (2000-2999) -enum class ContentSearchErrorCode : int { - KeywordTooShort = 2000, - WildcardNotSupported = 2001, - ContentIndexNotFound = 2200, - ContentIndexException = 2300, -}; -``` - -### 2. 错误分类(ErrorCategory) - -```cpp -class SearchErrorCategory : public std::error_category -{ -public: - const char *name() const noexcept override { return "search_error"; } - - std::string message(int ev) const override { - switch (static_cast<SearchErrorCode>(ev)) { - case SearchErrorCode::Success: - return "Success: The operation completed successfully."; - case SearchErrorCode::InvalidQuery: - return "Invalid search query: The provided search query is not valid."; - // ... - default: - return "Unknown error"; - } - } - - // Qt 友好的消息获取 - virtual QString qMessage(int ev) const { - return QString::fromStdString(message(ev)); - } -}; - -// 继承支持派生类型 -class FileNameSearchErrorCategory : public SearchErrorCategory { - std::string message(int ev) const override { - switch (static_cast<FileNameSearchErrorCode>(ev)) { - case FileNameSearchErrorCode::KeywordIsEmpty: - return "Keyword is empty: The search keyword cannot be empty."; - // ... - } - } -}; - -class ContentSearchErrorCategory : public SearchErrorCategory { /* ... */ }; -``` - -### 3. 错误包装类 - -**位置**: `include/dfm-search/dfm-search/searcherror.h:109-128` - -```cpp -class SearchError { -public: - SearchError() = default; - explicit SearchError(SearchErrorCode code); - explicit SearchError(FileNameSearchErrorCode code); - explicit SearchError(ContentSearchErrorCode code); - - bool isError() const { return m_code.value() != 0; } - const std::error_code &code() const { return m_code; } - QString message() const; - QString name() const; - -private: - std::error_code m_code; -}; -``` - -## make_error_code 实现 - -**位置**: `src/dfm-search/dfm-search-lib/core/searcherror.cpp` - -```cpp -// 单例模式提供 ErrorCategory 实例 -const SearchErrorCategory &search_category() { - static SearchErrorCategory c; - return c; -} - -const FileNameSearchErrorCategory &filename_search_category() { - static FileNameSearchErrorCategory c; - return c; -} - -const ContentSearchErrorCategory &content_search_category() { - static ContentSearchErrorCategory c; - return c; -} - -// 在 searcherror.h 中声明 -inline std::error_code make_error_code(SearchErrorCode ec) { - return std::error_code(static_cast<int>(ec), search_category()); -} - -inline std::error_code make_error_code(FileNameSearchErrorCode ec) { - return std::error_code(static_cast<int>(ec), filename_search_category()); -} -``` - -## 错误处理模式 - -### 1. 信号报告错误 - -```cpp -class AbstractSearchEngine : public QObject { - Q_OBJECT -Q_SIGNALS: - void errorOccurred(const DFMSEARCH::SearchError &error); -}; - -// 使用 -connect(engine, &SearchEngine::errorOccurred, [](const SearchError &err) { - qWarning() << "Search error:" << err.message(); -}); -``` - -### 2. 返回错误状态 - -```cpp -class IndexManager { -public: - bool buildIndex(const QString &path, SearchError &error) { - if (path.isEmpty()) { - error = SearchError(FileNameSearchErrorCode::KeywordIsEmpty); - return false; - } - // ... 构建索引 - return true; - } -}; - -// 使用 -SearchError err; -if (!manager.buildIndex(path, err)) { - qWarning() << err.message(); -} -``` - -## 错误处理最佳实践 - -### DO - 应该做 - -1. **使用标准错误码模式** -```cpp -// 返回 + 错误参数模式 -bool doSomething(SearchError &error); - -// 使用信号异步报告 -Q_SIGNAL void errorOccurred(const SearchError &error); -``` - -2. **单例模式管理 ErrorCategory** -```cpp -const SearchErrorCategory &search_category() { - static SearchErrorCategory c; - return c; -} -``` - -3. **清晰的错误分层** -```cpp -// 基础错误: 0-999 -enum class SearchErrorCode { ... }; - -// 文件名错误: 1000-1999 -enum class FileNameSearchErrorCode { ... }; - -// 内容错误: 2000-2999 -enum class ContentSearchErrorCode { ... }; -``` - -### DON'T - 不应该做 - -1. **不要使用异常** -```cpp -// 错误: -if (path.isEmpty()) { - throw std::runtime_error("Path is empty"); -} - -// 正确: -if (path.isEmpty()) { - error = SearchError(FileNameSearchErrorCode::KeywordIsEmpty); - return false; -} -``` - -2. **不要使用 std::shared_ptr 管理错误** -```cpp -// 错误: -std::shared_ptr<SearchError> error; - -// 正确: -SearchError error; // 值语义,轻量级 -``` - -3. **不要使用裸指针管理 ErrorCategory** -```cpp -// 错误: -static SearchErrorCategory *c = new SearchErrorCategory(); -// 永远不会被 delete - -// 正确: -static SearchErrorCategory c; // 静态局部变量 -``` - -## Qt 集成 - -```cpp -// qMessage() 提供友好的 Qt 字符串接口 -class SearchError { -public: - QString message() const { - if (auto cat = dynamic_cast<const SearchErrorCategory *>(&m_code.category())) { - return cat->qMessage(m_code.value()); - } - return QString::fromStdString(m_code.message()); - } - - QString name() const { - return QString::fromLocal8Bit(m_code.category().name()); - } -}; -``` - -## 示例代码引用 - -| 文件路径 | 描述 | -|---------|------| -| `include/dfm-search/dfm-search/searcherror.h` | 错误码和 ErrorCategory 定义 | -| `src/dfm-search/dfm-search-lib/core/searcherror.cpp` | ErrorCategory 实现 | -| `src/dfm-search/dfm-search-lib/core/abstractsearchengine.h` | 错误信号声明 | diff --git a/.trellis/spec/dfm-search/index.md b/.trellis/spec/dfm-search/index.md deleted file mode 100644 index 16b8aa14..00000000 --- a/.trellis/spec/dfm-search/index.md +++ /dev/null @@ -1,42 +0,0 @@ -# dfm-search 库开发规范 - -> dfm-search 是文件搜索库,采用现代 C++17 + Qt,使用 std::error_code 进行错误处理。 - -## 技术栈 - -- **C++17** -- **Qt5/Qt6** - 信号槽、线程、事件循环 -- **Lucene++** - 全文搜索引擎 -- **std::error_code** - 错误处理 - -## 规范索引 - -| 文档 | 描述 | 优先级 | -|------|------|--------| -| [error-handling.md](error-handling.md) | std::error_code + ErrorCategory模式 | P0 | -| [naming-conventions.md](naming-conventions.md) | 类名、方法名、m_ 前缀成员变量 | P0 | -| [memory-management.md](memory-management.md) | std::unique_ptr、PIMPL | P0 | -| [code-patterns.md](code-patterns.md) | 策略模式、线程模型 | P1 | -| [signal-threading.md](signal-threading.md) | 线程间信号通信 | P1 | - ---- - -## 开发前检查清单 - -- [ ] 阅读 [error-handling.md](error-handling.md) 了解 std::error_code 和 ErrorCategory -- [ ] 阅读 [memory-management.md](memory-management.md) 了解 std::unique_ptr 使用规则 -- [ ] 阅读 [signal-threading.md](signal-threading.md) 了解工作线程与主线程通信 -- [ ] 信号声明使用 `Q_SIGNALS`,槽使用 `Q_SLOTS` - ---- - -## 核心规则摘要 - -| 规则 | 要求 | -|------|------| -| 成员变量 | `m_` 前缀 + camelCase | -| 智能指针 | 仅用 `std::unique_ptr`,不用 `std::shared_ptr` | -| 错误处理 | `std::error_code` + 自定义 ErrorCategory | -| PIMPL | SearchResult、SearchOptions 使用 PIMPL | -| 线程模型 | SearchWorker 在独立线程,使用信号通信 | -| 策略模式 | BaseSearchStrategy + SearchStrategyFactory | diff --git a/.trellis/spec/dfm-search/naming-conventions.md b/.trellis/spec/dfm-search/naming-conventions.md deleted file mode 100644 index a32002a6..00000000 --- a/.trellis/spec/dfm-search/naming-conventions.md +++ /dev/null @@ -1,247 +0,0 @@ -# dfm-search 命名约定 - -## 类命名 - -### 公共接口类 - -**规则**: 大驼峰 (PascalCase) - -```cpp -class SearchEngine; // 搜索引擎 -class AbstractSearchEngine; // 抽象搜索引擎 -class GenericSearchEngine; // 通用搜索引擎 -class FileNameSearchEngine; // 文件名搜索引擎 -class ContentSearchEngine; // 内容搜索引擎 -class SearchWorker; // 搜索工作线程 -class SearchFactory; // 搜索工厂 -class SearchQuery; // 搜索查询 -class SearchOptions; // 搜索选项 -class SearchResult; // 搜索结果 -``` - -### 策略类 - -**规则**: 功能描述 + `Strategy` - -```cpp -class BaseSearchStrategy; // 基础搜索策略 -class FileNameBaseStrategy; // 文件名基础策略 -class FileNameIndexedStrategy; // 文件名索引策略 -class FileNameRealtimeStrategy; // 文件名实时策略 -class ContentBaseStrategy; // 内容基础策略 -class ContentIndexedStrategy; // 内容索引策略 -class QueryBuilder; // 查询构建器 -class IndexManager; // 索引管理器 -``` - -### 数据类 - -**规则**: 类名 + `Data` - -```cpp -class SearchResultData; // 搜索结果数据 -class SearchOptionsData; // 搜索选项数据 -class SearchWorkerPrivate; // 工作线程私有数据 -``` - -### API 类 - -**规则**: 功能描述 + `API` - -```cpp -class FileNameOptionsAPI; // 文件名选项 API -class FileNameResultAPI; // 文件名结果 API -class ContentOptionsAPI; // 内容选项 API -class ContentResultAPI; // 内容结果 API -``` - -### 工厂类 - -**规则**: 功能描述 + `Factory` - -```cpp -class SearchStrategyFactory; // 搜索策略工厂 -class FileNameSearchStrategyFactory; // 文件名策略工厂 -class ContentSearchStrategyFactory; // 内容策略工厂 -``` - -## 方法命名 - -### 公共方法 - -**规则**: 小驼峰 (camelCase) - -```cpp -// 搜索操作 -void search(const SearchQuery &query); -void cancel(); -bool isCancelled() const; - -// 属性访问 -SearchType searchType() const; -void setSearchType(SearchType type); -SearchStatus status() const; -SearchOptions searchOptions() const; -void setSearchOptions(const SearchOptions &options); - -// 结果处理 -void handleSearchResult(const SearchResult &result); -void handleSearchFinished(const SearchResultList &results); -void handleErrorOccurred(const SearchError &error); -``` - -### 静态工厂方法 - -```cpp -class SearchFactory { -public: - static SearchEngine *create(SearchType type, QObject *parent = nullptr); - static SearchQuery createQuery(const QString &keyword, QueryType type); -}; -``` - -## 成员变量命名 - -### 主成员变量 - -**规则**: `m_` 前缀 + 小驼峰 (camelCase) - -```cpp -class GenericSearchEngine { -private: - SearchOptions m_options; - SearchQuery m_currentQuery; - SearchResultList m_results; - SearchError m_lastError; - QTimer m_batchTimer; - SearchResultList m_batchResults; - std::atomic<SearchStatus> m_status; - std::atomic<bool> m_cancelled; -}; -``` - -### 指针成员 - -**规则**: `m_` + 描述名 - -```cpp -class GenericSearchEngine { -private: - QThread m_workerThread; - SearchWorker *m_worker; -}; -``` - -### PIMPL 成员 - -**规则**: 用于 PIMPL 的指针用 `d_ptr` 或 `d` - -```cpp -class SearchResult { -protected: - std::unique_ptr<SearchResultData> d; // PIMPL -}; - -class SearchEngine { -private: - std::unique_ptr<AbstractSearchEngine> d_ptr; // PIMPL -}; - -class FileNameIndexedStrategy { -private: - std::unique_ptr<QueryBuilder> m_queryBuilder; - std::unique_ptr<IndexManager> m_indexManager; -}; -``` - -## 枚举命名 - -### 枚举类型 - -**规则**: 大驼峰 (PascalCase) - -```cpp -enum SearchType { ... }; -enum SearchStatus { ... }; -enum SearchMethod { ... }; -enum QueryType { ... }; -``` - -### enum class 值 - -**规则**: 大驼峰 (PascalCase) 或 `k` + 大驼峰 - -```cpp -enum SearchType { - FileName, - Content, - Custom = 50 -}; - -enum SearchStatus { - Ready, - Searching, - Finished, - Cancelled, - Error -}; - -enum SearchMethod { - Indexed, - Realtime -}; - -enum class SearchErrorCode { - Success = 0, - InvalidQuery = 100, - PathIsEmpty, - PathNotFound, - SearchTimeout, - InternalError -}; -``` - -## 文件命名 - -| 类型 | 规则 | 示例 | -|------|------|------| -| 公共头文件 | 类名小写 + `.h` | `searchengine.h`, `filenamesearchapi.h` | -| 私有头文件 | 类名小写 + `.h` | `searchresultdata.h` | -| 实现文件 | 类名小写 + `.cpp` | `searchengine.cpp`, `indexedstrategy.cpp` | - -## 命名空间 - -```cpp -// 全局头文件定义宏 -#define DFMSEARCH dfmsearch -#define DFM_SEARCH_BEGIN_NS namespace DFMSEARCH { -#define DFM_SEARCH_END_NS } - -// 使用 -DFM_SEARCH_BEGIN_NS -class SearchEngine { /* ... */ }; -DFM_SEARCH_END_NS -``` - -## Lambda 和回调命名 - -```cpp -// 命名清晰的 lambda -auto resultHandler = [this](const SearchResult &result) { - m_results.append(result); -}; - -// 槽函数命名 -private Q_SLOTS: - void handleSearchResult(const SearchResult &result); - void handleSearchFinished(const SearchResultList &results); - void handleErrorOccurred(const SearchError &error); -``` - -## 示例代码引用 - -| 文件路径 | 描述 | -|---------|------| -| `include/dfm-search/dfm-search/searchoptions.h` | 成员变量命名 | -| `src/dfm-search/dfm-search-lib/core/genericsearchengine.h` | m_ 前缀成员变量 | -| `src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.h` | 策略类命名 | diff --git a/.trellis/spec/guides/index.md b/.trellis/spec/guides/index.md deleted file mode 100644 index 74347c00..00000000 --- a/.trellis/spec/guides/index.md +++ /dev/null @@ -1,73 +0,0 @@ -# 思考指南 - -> 跨层的思考流程和方法论。 - ---- - -## 概览 - -本层的指南帮助扩展思考,发现可能未考虑的问题。避免"没想到"导致的 Bug 和技术债务。 - ---- - -## 指南索引 - -| 指南 | 描述 | 何时使用 | -|------|------|----------| -| [root-cause-analysis.md](root-cause-analysis.md) | 5-Why 根因分析方法 | Bug 修复深度分析 | - ---- - -## 核心哲学 - -> **30 分钟思考节省 3 小时调试** - -### 为什么需要思考流程 - -大多数 Bug 来自"没想到": -- 没想到跨层边界问题 → 跨层 Bug -- 没想到代码模式重复 → 到处重复代码 -- 没想到边界情况 → 运行时错误 -- 没想到未来维护者 → 不可读代码 - -### 关键原则 - -1. **系统性分析** - 使用 5-Why 到达根因 -2. **证据导向** - 每一层需要证据支撑 -3. **预防为主** - 从修复中提取教训,预防复发 - -### 5-Why 方法 - -``` -问题现象 - ↓ Why 1: 为什么会出现这个现象? -直接原因 - ↓ Why 2: 为什么会有这个直接原因? -深层原因 - ↓ Why 3: 为什么允许这个条件? -系统原因 - ↓ Why 4: 为什么设计允许这个问题? -设计原因 - ↓ Why 5: 根本原因是什么? -根本原因 -``` - ---- - -## 何时使用思考流程 - -### 根因分析 - -当出现以下情况时使用 5-Why 方法: -- 复现困难的 Bug -- 多次出现相同类型的 Bug -- 修复后仍然复发的问题 -- 涉及跨模块/跨层的问题 - -### 分析步骤 - -1. **描述现象** - 清晰描述问题 -2. **收集证据** - 日志、堆栈、代码 -3. **迭代提问** - 问为什么,直到根本原因 -4. **验证假设** - 确保每个假设有证据 -5. **设计修复** - 针对根本原因的修复方案 diff --git a/.trellis/spec/guides/root-cause-analysis.md b/.trellis/spec/guides/root-cause-analysis.md deleted file mode 100644 index 3052ea83..00000000 --- a/.trellis/spec/guides/root-cause-analysis.md +++ /dev/null @@ -1,186 +0,0 @@ -# 根因分析指南 - -> 使用 5-Why 方法进行系统性根因分析的思考流程。 - ---- - -## 概览 - -本指南提供 Bug 修复和问题分析中使用的 5-Why 方法,帮助找到根本原因而非表面症状。 - ---- - -## 5-Why 方法 - -### 原理 - -5-Why 方法是一种迭代技术,通过重复问"为什么"来找到问题的根本原因。目标是从表面症状走向深度理解。 - -### 基本流程 - -``` -问题现象 - ↓ Why 1 -直接原因 - ↓ Why 2 -深层原因 - ↓ Why 3 -系统原因 - ↓ Why 4 -设计原因 - ↓ Why 5 -根本原因 -``` - ---- - -## 5-Why 分析模板 - -### 分析表 - -| Level | Question | Answer | Evidence | -|-------|----------|--------|----------| -| Why 1 | 为什么会出现[现象]? | [答案] | [证据] | -| Why 2 | 为什么会有[原因1]? | [答案] | [证据] | -| Why 3 | 为什么允许[条件]? | [答案] | [证据] | -| Why 4 | 为什么设计允许[问题]? | [答案] | [证据] | -| Why 5 | 根本原因是什么? | [答案] | [证据] | - ---- - -## 实际案例 - -### 案例 1: 内存泄漏崩溃 - -| Level | Question | Answer | Evidence | -|-------|----------|--------|----------| -| Why 1 | 为什么应用崩溃? | 访问释放的内存 | 崩溃堆栈显示无效指针 | -| 为什么 2 | 为什么内存被释放了还被访问? | 信号槽连接在对象销毁后仍然活跃 | 日志显示对象已 deleteLater 但信号仍然发送 | -| 为什么 3 | 为什么对象销毁后信号仍然发送? | 没有正确断开信号槽连接 | 代码中缺少 disconnect 调用 | -| 为什么 4 | 为什么没有断开连接? | 对象生命周期管理不清晰 | 多个组件共享同一个对象没有所有权明确 | -| 为什么 5 | 为什么没有明确的所有权语义? | 设计中缺少对象生命周期模型 | 架构文档中没有定义对象生存期 | - -**根本原因**: 缺少清晰的对象生命周期模型和所有权语义设计。 - -**修复**: 定义对象所有权规则,使用 smart pointer 或 parent-child 模式,确保正确断开信号槽。 - ---- - -### 案例 2: 文件复制失败 - -| Level | Question | Answer | Evidence | -|-------|----------|--------|----------| -| Why 1 | 为什么文件复制失败? | 返回权限错误 | 错误日志: "Permission denied" | -| 为什么 2 | 为什么没有权限? | 目标是系统目录 /etc | 日志显示目标路径 | -| 为什么 3 | 为什么要写入系统目录? | 配置保存路径硬编码 | 代码中硬编码路径 | -| 为什么 4 | 为什么硬编码系统路径? | 没有使用 XDG 配置目录 | 配置管理不遵循规范 | -| 为什么 5 | 为什么不遵循 XDG 规范? | 开发时没考虑到不同环境 | 缺少跨平台兼容性设计 | - -**根本原因**: 缺少跨平台路径规范,未使用 XDG 配置目录标准。 - -**修复**: 使用 QStandardPaths 获取配置目录,确保跨平台兼容。 - ---- - -## 根因类型 - -### 代码层面 - -- **边界条件缺失** - 没有检查空值、边界 -- **状态管理错误** - 对象状态不一致 -- **资源管理问题** - 内存泄漏、文件未关闭 - -### 设计层面 - -- **架构缺陷** - 分层不合理、耦合过强 -- **接口设计** - API 语义不清、容易误用 -- **数据模型** - 状态机设计不完整 - -### 流程层面 - -- **开发流程** - 缺少代码审查、测试不足 -- **配置管理** - 版本管理混乱 -- **部署流程** - 配置错误或环境不一致 - ---- - -## 常见陷阱 - -### 1. 停止在表面原因 - -``` -现象: 应用崩溃 -❌ 错误: "因为访问了空指针" → 添加空指针检查 -✅ 正确: 继续问为什么访问了空指针的流程 -``` - -### 2. 归因于人为错误 - -``` -❌ 错误: "因为开发者写错了代码" -✅ 正确: 为什么这种错误会导致进入生产环境?缺少什么检查? -``` - -### 忽略证据 - -``` -❌ 错误: 基于假设分析,没有验证 -✅ 正确: 每一层都需要证据支撑(日志、堆栈、代码) -``` - ---- - -## 修复策略 - -### 针对根本原因修复 - -| 根因类型 | 修复策略 | -|---------|---------| -| 边界条件 | 添加验证和防御性编程 | -| 状态管理 | 定义清晰的状态机 | -| 资源管理 | 使用 RAII 和 smart pointer | -| 架构缺陷 | 重构分层、降低耦合 | -| 开发流程 | 改进代码审查、增加测试 | - -### 修复验证 - -- [ ] 修复解决根本问题,不只解决症状 -- [ ] 添加回归测试防止再次发生 -- [ ] 更新文档记录根本原因和修复 -- [ ] 检查其他地方是否有相同模式 - ---- - -## 预防措施 - -### 代码层面 - -- 使用 RAII 资源管理 -- 清晰定义对象所有权 -- 完善的边界条件检查 -- 充分的单元测试覆盖 - -### 流程层面 - -- 强制代码审查 -- 静态代码分析 -- 自动化测试 -- 持续集成 - -### 文档层面 - -- 记录设计决策和权衡 -- 维护 Bug 根因分析库 -- 分享常见模式案例 - ---- - -## 快速参考 - -| 任务 | 方法 | -|------|------| -| 开始分析 | 从问题描述开始,问第一次"为什么" | -| 迭代提问 | 对每个答案继续问"为什么",最多 5 次 | -| 收集证据 | 每层都需要证据支持(日志、代码、配置) | -| 验证原因 | 确保修复能解决根本问题而非症状 | -| 预防复发 | 添加测试、更新文档、改进流程 | diff --git a/.trellis/spec/review/code-review-standards.md b/.trellis/spec/review/code-review-standards.md deleted file mode 100644 index 951b82da..00000000 --- a/.trellis/spec/review/code-review-standards.md +++ /dev/null @@ -1,373 +0,0 @@ -# 代码审查标准 - -> DDE 桌面应用代码审查指南,专注于 C/C++/Qt 开发场景。 - ---- - -## 概览 - -本指南定义 DDE 代码审查的标准流程和检查清单。 - ---- - -## 审查流程 - -### 第 1 阶段: 上下文收集(2-3 分钟) - -审查前,理解: -1. 阅读 PR/commit 描述和关联的 issue -2. 检查 PR 大小(超过 400 行?要求拆分) -3. 检查 CI/CD 状态(构建通过?) -4. 理解业务需求 -5. 注意相关的架构决策 - -### 第 2 阶段: 高级审查(5-10 分钟) - -1. **架构与设计** - - 解决方案是否合适? - - 检查:SOLID 原则、耦合/内聚、反模式 -2. **性能评估** - - 算法复杂度、内存使用、阻塞操作 -3. **文件组织** - - 新文件是否在正确的位置? -4. **测试策略** - - 是否有测试覆盖边界情况? - -### 第 3 阶段: 逐行审查(10-20 分钟) - -对每个文件检查: -- **逻辑与正确性** - 边界情况、空检查、竞态条件 -- **安全性** - 输入验证、注入风险、敏感数据 -- **性能** - 不必要的循环、内存泄漏、阻塞 UI -- **可维护性** - 清晰命名、单一职责、注释 - -### 第 4 阶段: 总结与决策(2-3 分钟) - -1. 总结关键问题 -2. 突出你喜欢的部分 -3. 做出明确决策: - - ✅ 批准 - - 💬 评论(建议) - - 🔄 请求更改(必须解决) - ---- - -## C/C++ 审查清单 - -### 内存安全 - -```cpp -// ❌ 错误: 内存泄漏 -QObject *obj = new QObject(); // 没有父对象! - -// ✅ 正确: Parent-child 所有权 -QObject *obj = new QObject(parent); - -// ❌ 错误: Use after free -delete widget; -widget->show(); // 崩溃! - -// ✅ 正确: 使用 deleteLater -widget->deleteLater(); - -// ❌ 错误: 缓冲区溢出 -char buffer[10]; -strcpy(buffer, "very long string"); // 溢出! - -// ✅ 正确: 使用安全函数 -strncpy(buffer, "string", sizeof(buffer) - 1); -buffer[sizeof(buffer) - 1] = '\0'; -``` - -### 未定义行为 - -```cpp -// ❌ 错误: 整数溢出 -int size = a + b; // 可能溢出 - -// ✅ 正确: 检查边界 -if (a > INT_MAX - b) return error; -int size = a + b; - -// ❌ 错误: 空指针解引用 -QString name = obj->name(); // 如果 obj 为空会怎样? - -// ✅ 正确: 空检查 -if (!obj) return error; -QString name = obj->name(); - -// ❌ 错误: 未初始化变量 -int value; -if (condition) value = 1; -// value 这里可能未初始化 - -// ✅ 正确: 初始化 -int value = 0; -``` - -### RAII 模式 - -```cpp -// ❌ 错误: 手动资源管理 -FILE *fp = fopen("file.txt", "r"); -// ... 很多代码 ... -fclose(fp); // 错误时可能被跳过! - -// ✅ 正确: RAII -std::ifstream file("file.txt"); -// 作用域结束时自动关闭 - -// ✅ 正确: Qt 智能指针 -QScopedPointer<Worker> worker(new Worker()); -QSharedPointer<Resource> resource = QSharedPointer<Resource>::create(); -``` - ---- - -## Qt 审查清单 - -### 信号槽连接 - -```cpp -// ❌ 错误: 跨线程没有 queued connection -connect(threadWorker, &Worker::signal, uiWidget, &Widget::slot); - -// ✅ 正确: 跨线程使用 QueuedConnection -connect(threadWorker, &Worker::signal, uiWidget, &Widget::slot, - Qt::QueuedConnection); - -// ❌ 错误: 阻塞 UI 线程 -QThread::sleep(5); // UI 冻结! - -// ✅ 正确: 使用后台线程 -QtConcurrent::run([this]() { - // 耗时工作 -}); -``` - -### 对象生命周期 - -```cpp -// ❌ 错误: 在错误线程中删除 QObject -delete worker; // 如果 worker 在不同线程会崩溃! - -// ✅ 正确: 使用 deleteLater -worker->deleteLater(); - -// ❌ 错误: Parent-child 违规 -child->setParent(nullptr); -delete child; // 谁拥有它? - -// ✅ 正确: 清晰的所有权 -child->setParent(nullptr); -child->deleteLater(); -``` - -### 内存泄漏 - -```cpp -// ❌ 错误: 没有父对象的 Widget -DWidget *widget = new DWidget(); // 泄漏! - -// ✅ 正确: 有父对象的 Widget -DWidget *widget = new DWidget(parentWidget); - -// ❌ 错误: 没有清理的 Timer -QTimer *timer = new QTimer(); -timer->start(1000); // 泄漏! - -// ✅ 正确: 有父对象的 Timer -QTimer *timer = new QTimer(this); -timer->start(1000); -``` - ---- - -## DDE 特定清单 - -### DTK 组件使用 - -| 禁止使用 | 改用 | -|---------|------| -| `QMainWindow` | `DMainWindow` | -| `QDialog` | `DDialog` | -| `QMessageBox` | `DMessageBox` | -| `QWidget` | `DWidget` | -| `QLabel` | `DLabel` | -| `QPushButton` | `DPushButton` | -| `QLineEdit` | `DLineEdit` | -| `QListView` | `DListView` | - -### 主题适配 - -```cpp -// ❌ 错误: 硬编码颜色 -label->setStyleSheet("color: #333333; background: #ffffff;"); -button->setStyleSheet("background-color: #FF6B35;"); - -// ✅ 正确: 使用调色板 -QPalette palette = DGuiApplicationHelper::instance()->applicationPalette(); -label->setPalette(palette); - -// ✅ 正确: 使用 DTK 样式 -button->setButtonType(DPushButton::RecommendButton); -``` - -### D-Bus 集成 - -```cpp -// ❌ 错误: 错误的总线类型 -QDBusConnection::sessionBus().connect(...); // 应该用 systemBus? - -// ✅ 正确: 正确的总线类型 -// 系统服务: systemBus -QDBusConnection::systemBus().connect( - "org.freedesktop.UPower", ...); - -// 用户服务: sessionBus -QDBusConnection::sessionBus().connect(...); - -// ❌ 错误: UI 线程阻塞调用 -QDBusInterface iface(...); -QDBusReply<QString> reply = iface.call("SlowMethod"); // 阻塞 UI! - -// ✅ 正确: 异步调用 -QDBusPendingCall async = iface.asyncCall("SlowMethod"); -QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(async, this); -connect(watcher, &QDBusPendingCallWatcher::finished, - this, &MyClass::handleResult); -``` - -### Polkit 认证 - -```cpp -// ❌ 错误: 使用 sudo -system("sudo rm -rf /important"); // 安全风险! - -// ✅ 正确: 使用 Polkit -PolkitQt1::Authority::instance()->checkAuthorizationSync( - "org.deepin.myapp.authentication", - PolkitQt1::UnixProcessSubject(QCoreApplication::applicationPid()), - PolkitQt1::Authority::AllowUserInteraction); -``` - ---- - -## 安全检查清单 - -### 输入验证 - -```cpp -// ❌ 错误: 没有验证 -QFile file(userInput); // 路径遍历风险! - -// ✅ 正确: 验证输入 -QString sanitized = sanitizePath(userInput); -if (!isPathSafe(sanitized)) return; -QFile file(sanitized); - -// ❌ 错误: SQL 注入 -QString query = "SELECT * FROM users WHERE name = '" + name + "'"; - -// ✅ 正确: 使用预处理语句 -QSqlQuery query; -query.prepare("SELECT * FROM users WHERE name = ?"); -query.addBindValue(name); -``` - -### 敏感数据 - -```cpp -// ❌ 错误: 记录敏感数据 -qDebug() << "Password:" << password; - -// ✅ 正确: 掩码敏感数据 -qDebug() << "User authenticated"; - -// ❌ 错误: 明文存储密码 -settings.setValue("password", password); - -// ✅ 正确: 使用密钥环 -// 使用 libsecret 或 KWallet 进行安全存储 -``` - ---- - -## 性能检查清单 - -### 常见问题 - -```cpp -// ❌ 错误: N+1 查询 -for (const auto &item : items) { - db.query("SELECT * FROM details WHERE id = " + item.id); -} - -// ✅ 正确: 批量查询 -QStringList ids; -for (const auto &item : items) ids << QString::number(item.id); -db.query("SELECT * FROM details WHERE id IN (" + ids.join(",") + ")"); - -// ❌ 错误: UI 线程繁重工作 -void onButtonClick() { - processLargeFile(); // UI 冻结! -} - -// ✅ 正确: 使用后台线程 -void onButtonClick() { - QtConcurrent::run([this]() { - processLargeFile(); - }); -} -``` - ---- - -## 严重性标签 - -| 标签 | 含义 | 示例 | -|-----|------|------| -| 🔴 `blocking` | 合并前必须修复 | 内存泄漏、安全问题、崩溃 | -| 🟡 `important` | 应该修复 | 性能问题、代码异味 | -| 🟢 `nit` | 次要样式问题 | 变量命名、空格 | -| 💡 `suggestion` | 可选改进 | 更好的算法建议 | -| 🎉 `praise` | 做得好 | 结构良好的代码 | - ---- - -## 审查反馈模板 - -```markdown -## 代码审查总结 - -**文件变更**: X 个文件, +Y/-Z 行 - -### 🔴 阻塞问题 -1. [file:line] 关键问题描述 - -### 🟡 重要问题 -1. [file:line] 重要问题描述 - -### 💡 建议 -1. [file:line] 可选改进 - -### 🎉 做得好 -- [file] 中出色的错误处理 -- [class] 的清晰抽象 - -**决策**: 🔄 请求更改 / ✅ 批准 / 💬 评论 -``` - ---- - -## 快速参考 - -| 检查 | 命令/方法 | -|------|----------| -| 构建错误 | 变更文件上的 `lsp_diagnostics` | -| 内存泄漏 | 检查没有父对象 `new`、没有 `free` 的 `malloc` | -| 线程安全 | 检查跨线程的 `Qt::QueuedConnection` | -| 主题支持 | 检查没有硬编码颜色 | -| DTK 组件 | 检查没有 `QMainWindow`、`QDialog` 等 | -| D-Bus 正确 | 检查 systemBus vs sessionBus | -| 安全性 | 检查输入验证、日志中没有敏感数据 | diff --git a/.trellis/spec/review/index.md b/.trellis/spec/review/index.md deleted file mode 100644 index de7baee6..00000000 --- a/.trellis/spec/review/index.md +++ /dev/null @@ -1,112 +0,0 @@ -# 代码审查层 - -> DDE 代码审查标准和检查清单。 - ---- - -## 概览 - -本层定义 DDE 应用代码审查的标准流程,包括 C/C++/Qt 特定检查、DDE 约定检查、安全审计和性能评估。 - ---- - -## 规范索引 - -| 文档 | 描述 | 何时阅读 | -|------|------|----------| -| [code-review-standards.md](code-review-standards.md) | 代码审查流程、C/C++/Qt 检查清单 | 所有审查 | - ---- - -## 开发前检查清单 - -在审查 PR 前阅读: - -- [x] 理解 PR 描述和关联 issue -- [x] 检查 CI/CD 状态(构建通过?) -- [ ] 评估 PR 大小(>400 行?要求拆分) -- [ ] 识别变更类型(新功能、Bug 修复、重构) - ---- - -## 质量检查 - -代码审查时验证: - -- [ ] 代码质量(可读性、可维护性) -- [ ] 内存安全(无泄漏、正确的生命周期) -- [ ] 线程安全(UI 更新在主线程) -- [ ] DTE 约定(DTK 组件、主题适配) -- [ ] 安全性(输入验证、敏感数据) -- [ ] 性能(无阻塞 UI、避免 N+1 查询) - ---- - -## 技术参考 - -### 审查参考文档 - -- [reference/architecture-review-guide.md](reference/architecture-review-guide.md) - 架构审查 -- [reference/code-review-best-practices.md](reference/code-review-best-practices.md) - 最佳实践 -- [reference/common-bugs-checklist.md](reference/common-bugs-checklist.md) - 常见 Bug -- [reference/performance-review-guide.md](reference/performance-review-guide.md) - 性能审查 -- [reference/security-review-guide.md](reference/security-review-guide.md) - 安全审查 - -### 语言特定 - -- [/reference/cpp.md](reference/cpp.md) - C++ 审查 -- [reference/c.md](reference/c.md) - C 审查 -- [reference/qt.md](reference/qt.md) - Qt 审查 - ---- - -## 核心规则摘要 - -| 检查 | 要求 | -|------|------| -| DTK 组件 | 禁止 QMainWindow、QDialog 等 QtWidgets | -| 主题适配 | 禁止硬编码颜色 | -| 内存安全 | Parent-child 关系、智能指针 | -| 线程安全 | 跨线程使用 QueuedConnection | -| 输入验证 | 文件路径、SQL 参数验证 | -| 敏感数据 | 不记录密码等敏感信息 | - ---- - -## 审查流程 - -### 第 1 阶段:上下文收集(2-3 分钟) - -1. 阅读 PR/commit 描述 -2. 关联 issue -3. CI 状态 - -### 第 2 阶段:高级审查(5-10 分钟) - -1. 架构设计 -2. 性能评估 -3. 文件组织 -4. 测试策略 - -### 第 3 阶段:逐行审查(10-20 分钟) - -1. 逻辑与正确性 -2. 安全性 -3. 性能 -4. 可维护性 - -### 第 4 阶段:总结(2-3 分钟) - -1. 关键问题 -2. 决策(批准/评论/请求更改) - ---- - -## 快速参考 - -| 检查 | 命令/方法 | -|------|----------| -| 内存泄漏 | grep "new" 检查父对象 | -| 硬编码颜色 | grep -E "#[0-9a-fA-F]{6}" | -| Qt 组件 | grep "QMainWindow\|QDialog" | -| 主线程更新 | 检查跨线程连接 | diff --git a/.trellis/spec/review/reference/architecture-review-guide.md b/.trellis/spec/review/reference/architecture-review-guide.md deleted file mode 100644 index abde68ce..00000000 --- a/.trellis/spec/review/reference/architecture-review-guide.md +++ /dev/null @@ -1,472 +0,0 @@ -# Architecture Review Guide - -架构设计审查指南,帮助评估代码的架构是否合理、设计是否恰当。 - -## SOLID 原则检查清单 - -### S - 单一职责原则 (SRP) - -**检查要点:** -- 这个类/模块是否只有一个改变的理由? -- 类中的方法是否都服务于同一个目的? -- 如果要向非技术人员描述这个类,能否用一句话说清楚? - -**代码审查中的识别信号:** -``` -⚠️ 类名包含 "And"、"Manager"、"Handler"、"Processor" 等泛化词汇 -⚠️ 一个类超过 200-300 行代码 -⚠️ 类有超过 5-7 个公共方法 -⚠️ 不同的方法操作完全不同的数据 -``` - -**审查问题:** -- "这个类负责哪些事情?能否拆分?" -- "如果 X 需求变化,哪些方法需要改?如果 Y 需求变化呢?" - -### O - 开闭原则 (OCP) - -**检查要点:** -- 添加新功能时,是否需要修改现有代码? -- 是否可以通过扩展(继承、组合)来添加新行为? -- 是否存在大量的 if/else 或 switch 语句来处理不同类型? - -**代码审查中的识别信号:** -``` -⚠️ switch/if-else 链处理不同类型 -⚠️ 添加新功能需要修改核心类 -⚠️ 类型检查 (instanceof, typeof) 散布在代码中 -``` - -**审查问题:** -- "如果要添加新的 X 类型,需要修改哪些文件?" -- "这个 switch 语句会随着新类型增加而增长吗?" - -### L - 里氏替换原则 (LSP) - -**检查要点:** -- 子类是否可以完全替代父类使用? -- 子类是否改变了父类方法的预期行为? -- 是否存在子类抛出父类未声明的异常? - -**代码审查中的识别信号:** -``` -⚠️ 显式类型转换 (casting) -⚠️ 子类方法抛出 NotImplementedException -⚠️ 子类方法为空实现或只有 return -⚠️ 使用基类的地方需要检查具体类型 -``` - -**审查问题:** -- "如果用子类替换父类,调用方代码是否需要修改?" -- "这个方法在子类中的行为是否符合父类的契约?" - -### I - 接口隔离原则 (ISP) - -**检查要点:** -- 接口是否足够小且专注? -- 实现类是否被迫实现不需要的方法? -- 客户端是否依赖了它不使用的方法? - -**代码审查中的识别信号:** -``` -⚠️ 接口超过 5-7 个方法 -⚠️ 实现类有空方法或抛出 NotImplementedException -⚠️ 接口名称过于宽泛 (IManager, IService) -⚠️ 不同的客户端只使用接口的部分方法 -``` - -**审查问题:** -- "这个接口的所有方法是否都被每个实现类使用?" -- "能否将这个大接口拆分为更小的专用接口?" - -### D - 依赖倒置原则 (DIP) - -**检查要点:** -- 高层模块是否依赖于抽象而非具体实现? -- 是否使用依赖注入而非直接 new 对象? -- 抽象是否由高层模块定义而非低层模块? - -**代码审查中的识别信号:** -``` -⚠️ 高层模块直接 new 低层模块的具体类 -⚠️ 导入具体实现类而非接口/抽象类 -⚠️ 配置和连接字符串硬编码在业务逻辑中 -⚠️ 难以为某个类编写单元测试 -``` - -**审查问题:** -- "这个类的依赖能否在测试时被 mock 替换?" -- "如果要更换数据库/API 实现,需要修改多少地方?" - ---- - -## 架构反模式识别 - -### 致命反模式 - -| 反模式 | 识别信号 | 影响 | -|--------|----------|------| -| **大泥球 (Big Ball of Mud)** | 没有清晰的模块边界,任何代码都可能调用任何其他代码 | 难以理解、修改和测试 | -| **上帝类 (God Object)** | 单个类承担过多职责,知道太多、做太多 | 高耦合,难以重用和测试 | -| **意大利面条代码** | 控制流程混乱,goto 或深层嵌套,难以追踪执行路径 | 难以理解和维护 | -| **熔岩流 (Lava Flow)** | 没人敢动的古老代码,缺乏文档和测试 | 技术债务累积 | - -### 设计反模式 - -| 反模式 | 识别信号 | 建议 | -|--------|----------|------| -| **金锤子 (Golden Hammer)** | 对所有问题使用同一种技术/模式 | 根据问题选择合适的解决方案 | -| **过度工程 (Gas Factory)** | 简单问题用复杂方案解决,滥用设计模式 | YAGNI 原则,先简单后复杂 | -| **船锚 (Boat Anchor)** | 为"将来可能需要"而写的未使用代码 | 删除未使用代码,需要时再写 | -| **复制粘贴编程** | 相同逻辑出现在多处 | 提取公共方法或模块 | - -### 审查问题 - -```markdown -🔴 [blocking] "这个类有 2000 行代码,建议拆分为多个专注的类" -🟡 [important] "这段逻辑在 3 个地方重复,考虑提取为公共方法?" -💡 [suggestion] "这个 switch 语句可以用策略模式替代,更易扩展" -``` - ---- - -## 耦合度与内聚性评估 - -### 耦合类型(从好到差) - -| 类型 | 描述 | 示例 | -|------|------|------| -| **消息耦合** ✅ | 通过参数传递数据 | `calculate(price, quantity)` | -| **数据耦合** ✅ | 共享简单数据结构 | `processOrder(orderDTO)` | -| **印记耦合** ⚠️ | 共享复杂数据结构但只用部分 | 传入整个 User 对象但只用 name | -| **控制耦合** ⚠️ | 传递控制标志影响行为 | `process(data, isAdmin=true)` | -| **公共耦合** ❌ | 共享全局变量 | 多个模块读写同一个全局状态 | -| **内容耦合** ❌ | 直接访问另一模块的内部 | 直接操作另一个类的私有属性 | - -### 内聚类型(从好到差) - -| 类型 | 描述 | 质量 | -|------|------|------| -| **功能内聚** | 所有元素完成单一任务 | ✅ 最佳 | -| **顺序内聚** | 输出作为下一步输入 | ✅ 良好 | -| **通信内聚** | 操作相同数据 | ⚠️ 可接受 | -| **时间内聚** | 同时执行的任务 | ⚠️ 较差 | -| **逻辑内聚** | 逻辑相关但功能不同 | ❌ 差 | -| **偶然内聚** | 没有明显关系 | ❌ 最差 | - -### 度量指标参考 - -```yaml -耦合指标: - CBO (类间耦合): - 好: < 5 - 警告: 5-10 - 危险: > 10 - - Ce (传出耦合): - 描述: 依赖多少外部类 - 好: < 7 - - Ca (传入耦合): - 描述: 被多少类依赖 - 高值意味着: 修改影响大,需要稳定 - -内聚指标: - LCOM4 (方法缺乏内聚): - 1: 单一职责 ✅ - 2-3: 可能需要拆分 ⚠️ - >3: 应该拆分 ❌ -``` - -### 审查问题 - -- "这个模块依赖了多少其他模块?能否减少?" -- "修改这个类会影响多少其他地方?" -- "这个类的方法是否都操作相同的数据?" - ---- - -## 分层架构审查 - -### Clean Architecture 层次检查 - -``` -┌─────────────────────────────────────┐ -│ Frameworks & Drivers │ ← 最外层:Web、DB、UI -├─────────────────────────────────────┤ -│ Interface Adapters │ ← Controllers、Gateways、Presenters -├─────────────────────────────────────┤ -│ Application Layer │ ← Use Cases、Application Services -├─────────────────────────────────────┤ -│ Domain Layer │ ← Entities、Domain Services -└─────────────────────────────────────┘ - ↑ 依赖方向只能向内 ↑ -``` - -### 依赖规则检查 - -**核心规则:源代码依赖只能指向内层** - -```typescript -// ❌ 违反依赖规则:Domain 层依赖 Infrastructure -// domain/User.ts -import { MySQLConnection } from '../infrastructure/database'; - -// ✅ 正确:Domain 层定义接口,Infrastructure 实现 -// domain/UserRepository.ts (接口) -interface UserRepository { - findById(id: string): Promise<User>; -} - -// infrastructure/MySQLUserRepository.ts (实现) -class MySQLUserRepository implements UserRepository { - findById(id: string): Promise<User> { /* ... */ } -} -``` - -### 审查清单 - -**层次边界检查:** -- [ ] Domain 层是否有外部依赖(数据库、HTTP、文件系统)? -- [ ] Application 层是否直接操作数据库或调用外部 API? -- [ ] Controller 是否包含业务逻辑? -- [ ] 是否存在跨层调用(UI 直接调用 Repository)? - -**关注点分离检查:** -- [ ] 业务逻辑是否与展示逻辑分离? -- [ ] 数据访问是否封装在专门的层? -- [ ] 配置和环境相关代码是否集中管理? - -### 审查问题 - -```markdown -🔴 [blocking] "Domain 实体直接导入了数据库连接,违反依赖规则" -🟡 [important] "Controller 包含业务计算逻辑,建议移到 Service 层" -💡 [suggestion] "考虑使用依赖注入来解耦这些组件" -``` - ---- - -## 设计模式使用评估 - -### 何时使用设计模式 - -| 模式 | 适用场景 | 不适用场景 | -|------|----------|------------| -| **Factory** | 需要创建不同类型对象,类型在运行时确定 | 只有一种类型,或类型固定不变 | -| **Strategy** | 算法需要在运行时切换,有多种可互换的行为 | 只有一种算法,或算法不会变化 | -| **Observer** | 一对多依赖,状态变化需要通知多个对象 | 简单的直接调用即可满足需求 | -| **Singleton** | 确实需要全局唯一实例,如配置管理 | 可以通过依赖注入传递的对象 | -| **Decorator** | 需要动态添加职责,避免继承爆炸 | 职责固定,不需要动态组合 | - -### 过度设计警告信号 - -``` -⚠️ Patternitis(模式炎)识别信号: - -1. 简单的 if/else 被替换为策略模式 + 工厂 + 注册表 -2. 只有一个实现的接口 -3. 为了"将来可能需要"而添加的抽象层 -4. 代码行数因模式应用而大幅增加 -5. 新人需要很长时间才能理解代码结构 -``` - -### 审查原则 - -```markdown -✅ 正确使用模式: -- 解决了实际的可扩展性问题 -- 代码更容易理解和测试 -- 添加新功能变得更简单 - -❌ 过度使用模式: -- 为了使用模式而使用 -- 增加了不必要的复杂度 -- 违反了 YAGNI 原则 -``` - -### 审查问题 - -- "使用这个模式解决了什么具体问题?" -- "如果不用这个模式,代码会有什么问题?" -- "这个抽象层带来的价值是否大于它的复杂度?" - ---- - -## 可扩展性评估 - -### 扩展性检查清单 - -**功能扩展性:** -- [ ] 添加新功能是否需要修改核心代码? -- [ ] 是否提供了扩展点(hooks、plugins、events)? -- [ ] 配置是否外部化(配置文件、环境变量)? - -**数据扩展性:** -- [ ] 数据模型是否支持新增字段? -- [ ] 是否考虑了数据量增长的场景? -- [ ] 查询是否有合适的索引? - -**负载扩展性:** -- [ ] 是否可以水平扩展(添加更多实例)? -- [ ] 是否有状态依赖(session、本地缓存)? -- [ ] 数据库连接是否使用连接池? - -### 扩展点设计检查 - -```typescript -// ✅ 好的扩展设计:使用事件/钩子 -class OrderService { - private hooks: OrderHooks; - - async createOrder(order: Order) { - await this.hooks.beforeCreate?.(order); - const result = await this.save(order); - await this.hooks.afterCreate?.(result); - return result; - } -} - -// ❌ 差的扩展设计:硬编码所有行为 -class OrderService { - async createOrder(order: Order) { - await this.sendEmail(order); // 硬编码 - await this.updateInventory(order); // 硬编码 - await this.notifyWarehouse(order); // 硬编码 - return await this.save(order); - } -} -``` - -### 审查问题 - -```markdown -💡 [suggestion] "如果将来需要支持新的支付方式,这个设计是否容易扩展?" -🟡 [important] "这里的逻辑是硬编码的,考虑使用配置或策略模式?" -📚 [learning] "事件驱动架构可以让这个功能更容易扩展" -``` - ---- - -## 代码结构最佳实践 - -### 目录组织 - -**按功能/领域组织(推荐):** -``` -src/ -├── user/ -│ ├── User.ts (实体) -│ ├── UserService.ts (服务) -│ ├── UserRepository.ts (数据访问) -│ └── UserController.ts (API) -├── order/ -│ ├── Order.ts -│ ├── OrderService.ts -│ └── ... -└── shared/ - ├── utils/ - └── types/ -``` - -**按技术层组织(不推荐):** -``` -src/ -├── controllers/ ← 不同领域混在一起 -│ ├── UserController.ts -│ └── OrderController.ts -├── services/ -├── repositories/ -└── models/ -``` - -### 命名约定检查 - -| 类型 | 约定 | 示例 | -|------|------|------| -| 类名 | PascalCase,名词 | `UserService`, `OrderRepository` | -| 方法名 | camelCase,动词 | `createUser`, `findOrderById` | -| 接口名 | I 前缀或无前缀 | `IUserService` 或 `UserService` | -| 常量 | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT` | -| 私有属性 | 下划线前缀或无 | `_cache` 或 `#cache` | - -### 文件大小指南 - -```yaml -建议限制: - 单个文件: < 300 行 - 单个函数: < 50 行 - 单个类: < 200 行 - 函数参数: < 4 个 - 嵌套深度: < 4 层 - -超出限制时: - - 考虑拆分为更小的单元 - - 使用组合而非继承 - - 提取辅助函数或类 -``` - -### 审查问题 - -```markdown -🟢 [nit] "这个 500 行的文件可以考虑按职责拆分" -🟡 [important] "建议按功能领域而非技术层组织目录结构" -💡 [suggestion] "函数名 `process` 不够明确,考虑改为 `calculateOrderTotal`?" -``` - ---- - -## 快速参考清单 - -### 架构审查 5 分钟速查 - -```markdown -□ 依赖方向是否正确?(外层依赖内层) -□ 是否存在循环依赖? -□ 核心业务逻辑是否与框架/UI/数据库解耦? -□ 是否遵循 SOLID 原则? -□ 是否存在明显的反模式? -``` - -### 红旗信号(必须处理) - -```markdown -🔴 God Object - 单个类超过 1000 行 -🔴 循环依赖 - A → B → C → A -🔴 Domain 层包含框架依赖 -🔴 硬编码的配置和密钥 -🔴 没有接口的外部服务调用 -``` - -### 黄旗信号(建议处理) - -```markdown -🟡 类间耦合度 (CBO) > 10 -🟡 方法参数超过 5 个 -🟡 嵌套深度超过 4 层 -🟡 重复代码块 > 10 行 -🟡 只有一个实现的接口 -``` - ---- - -## 工具推荐 - -| 工具 | 用途 | 语言支持 | -|------|------|----------| -| **SonarQube** | 代码质量、耦合度分析 | 多语言 | -| **NDepend** | 依赖分析、架构规则 | .NET | -| **JDepend** | 包依赖分析 | Java | -| **Madge** | 模块依赖图 | JavaScript/TypeScript | -| **ESLint** | 代码规范、复杂度检查 | JavaScript/TypeScript | -| **CodeScene** | 技术债务、热点分析 | 多语言 | - ---- - -## 参考资源 - -- [Clean Architecture - Uncle Bob](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) -- [SOLID Principles in Code Review - JetBrains](https://blog.jetbrains.com/upsource/2015/08/31/what-to-look-for-in-a-code-review-solid-principles-2/) -- [Software Architecture Anti-Patterns](https://medium.com/@christophnissle/anti-patterns-in-software-architecture-3c8970c9c4f5) -- [Coupling and Cohesion in System Design](https://www.geeksforgeeks.org/system-design/coupling-and-cohesion-in-system-design/) -- [Design Patterns - Refactoring Guru](https://refactoring.guru/design-patterns) diff --git a/.trellis/spec/review/reference/c.md b/.trellis/spec/review/reference/c.md deleted file mode 100644 index cfd31ed1..00000000 --- a/.trellis/spec/review/reference/c.md +++ /dev/null @@ -1,285 +0,0 @@ -# C Code Review Guide - -> C code review guide focused on memory safety, undefined behavior, and portability. Examples assume C11. - -## Table of Contents - -- [Pointer and Buffer Safety](#pointer-and-buffer-safety) -- [Ownership and Resource Management](#ownership-and-resource-management) -- [Undefined Behavior Pitfalls](#undefined-behavior-pitfalls) -- [Integer Types and Overflow](#integer-types-and-overflow) -- [Error Handling](#error-handling) -- [Concurrency](#concurrency) -- [Macros and Preprocessor](#macros-and-preprocessor) -- [API Design and Const](#api-design-and-const) -- [Tooling and Build Checks](#tooling-and-build-checks) -- [Review Checklist](#review-checklist) - ---- - -## Pointer and Buffer Safety - -### Always carry size with buffers - -```c -// ? Bad: ignores destination size -bool copy_name(char *dst, size_t dst_size, const char *src) { - strcpy(dst, src); - return true; -} - -// ? Good: validate size and terminate -bool copy_name(char *dst, size_t dst_size, const char *src) { - size_t len = strlen(src); - if (len + 1 > dst_size) { - return false; - } - memcpy(dst, src, len + 1); - return true; -} -``` - -### Avoid dangerous APIs - -Prefer `snprintf`, `fgets`, and explicit bounds over `gets`, `strcpy`, or `sprintf`. - -```c -// ? Bad: unbounded write -sprintf(buf, "%s", input); - -// ? Good: bounded write -snprintf(buf, buf_size, "%s", input); -``` - -### Use the right copy primitive - -```c -// ? Bad: memcpy with overlapping regions -memcpy(dst, src, len); - -// ? Good: memmove handles overlap -memmove(dst, src, len); -``` - ---- - -## Ownership and Resource Management - -### One allocation, one free - -Track ownership and clean up on every error path. - -```c -// ? Good: cleanup label avoids leaks -int load_file(const char *path) { - int rc = -1; - FILE *f = NULL; - char *buf = NULL; - - f = fopen(path, "rb"); - if (!f) { - goto cleanup; - } - buf = malloc(4096); - if (!buf) { - goto cleanup; - } - - if (fread(buf, 1, 4096, f) == 0) { - goto cleanup; - } - - rc = 0; - -cleanup: - free(buf); - if (f) { - fclose(f); - } - return rc; -} -``` - ---- - -## Undefined Behavior Pitfalls - -### Common UB patterns - -```c -// ? Bad: use after free -char *p = malloc(10); -free(p); -p[0] = 'a'; - -// ? Bad: uninitialized read -int x; -if (x > 0) { /* UB */ } - -// ? Bad: signed overflow -int sum = a + b; -``` - -### Avoid pointer arithmetic past the object - -```c -// ? Bad: pointer past the end then dereference -int arr[4]; -int *p = arr + 4; -int v = *p; // UB -``` - ---- - -## Integer Types and Overflow - -### Avoid signed/unsigned surprises - -```c -// ? Bad: negative converted to large size_t -int len = -1; -size_t n = len; - -// ? Good: validate before converting -if (len < 0) { - return -1; -} -size_t n = (size_t)len; -``` - -### Check for overflow in size calculations - -```c -// ? Bad: potential overflow in multiplication -size_t bytes = count * sizeof(Item); - -// ? Good: check before multiplying -if (count > SIZE_MAX / sizeof(Item)) { - return NULL; -} -size_t bytes = count * sizeof(Item); -``` - ---- - -## Error Handling - -### Always check return values - -```c -// ? Bad: ignore errors -fread(buf, 1, size, f); - -// ? Good: handle errors -size_t read = fread(buf, 1, size, f); -if (read != size && ferror(f)) { - return -1; -} -``` - -### Consistent error contracts - -- Use a clear convention: 0 for success, negative for failure. -- Document ownership rules on success and failure. -- If using `errno`, set it only for actual failures. - ---- - -## Concurrency - -### volatile is not synchronization - -```c -// ? Bad: data race -volatile int stop = 0; -void worker(void) { - while (!stop) { /* ... */ } -} - -// ? Good: C11 atomics -_Atomic int stop = 0; -void worker(void) { - while (!atomic_load(&stop)) { /* ... */ } -} -``` - -### Use mutexes for shared state - -Protect shared data with `pthread_mutex_t` or equivalent. Avoid holding locks while doing I/O. - ---- - -## Macros and Preprocessor - -### Parenthesize arguments - -```c -// ? Bad: macro with side effects -#define MIN(a, b) ((a) < (b) ? (a) : (b)) -int x = MIN(i++, j++); - -// ? Good: static inline function -static inline int min_int(int a, int b) { - return a < b ? a : b; -} -``` - ---- - -## API Design and Const - -### Const-correctness and sizes - -```c -// ? Good: explicit size and const input -int hash_bytes(const uint8_t *data, size_t len, uint8_t *out); -``` - -### Document nullability - -Clearly document whether pointers may be NULL. Prefer returning error codes instead of NULL when possible. - ---- - -## Tooling and Build Checks - -```bash -# Warnings -clang -Wall -Wextra -Werror -Wconversion -Wshadow -std=c11 ... - -# Sanitizers (debug builds) -clang -fsanitize=address,undefined -fno-omit-frame-pointer -g ... -clang -fsanitize=thread -fno-omit-frame-pointer -g ... - -# Static analysis -clang-tidy src/*.c -- -std=c11 -cppcheck --enable=warning,performance,portability src/ - -# Formatting -clang-format -i src/*.c include/*.h -``` - ---- - -## Review Checklist - -### Memory and UB -- [ ] All buffers have explicit size parameters -- [ ] No out-of-bounds access or pointer arithmetic past objects -- [ ] No use after free or uninitialized reads -- [ ] Signed overflow and shift rules are respected - -### API and Design -- [ ] Ownership rules are documented and consistent -- [ ] const-correctness is applied for inputs -- [ ] Error contracts are clear and consistent - -### Concurrency -- [ ] No data races on shared state -- [ ] volatile is not used for synchronization -- [ ] Locks are held for minimal time - -### Tooling and Tests -- [ ] Builds clean with warnings enabled -- [ ] Sanitizers run on critical code paths -- [ ] Static analysis results are addressed diff --git a/.trellis/spec/review/reference/code-review-best-practices.md b/.trellis/spec/review/reference/code-review-best-practices.md deleted file mode 100644 index 8c6b9cdb..00000000 --- a/.trellis/spec/review/reference/code-review-best-practices.md +++ /dev/null @@ -1,136 +0,0 @@ -# Code Review Best Practices - -Comprehensive guidelines for conducting effective code reviews. - -## Review Philosophy - -### Goals of Code Review - -**Primary Goals:** -- Catch bugs and edge cases before production -- Ensure code maintainability and readability -- Share knowledge across the team -- Enforce coding standards consistently -- Improve design and architecture decisions - -**Secondary Goals:** -- Mentor junior developers -- Build team culture and trust -- Document design decisions through discussions - -### What Code Review is NOT - -- A gatekeeping mechanism to block progress -- An opportunity to show off knowledge -- A place to nitpick formatting (use linters) -- A way to rewrite code to personal preference - -## Review Timing - -### When to Review - -| Trigger | Action | -|---------|--------| -| PR opened | Review within 24 hours, ideally same day | -| Changes requested | Re-review within 4 hours | -| Blocking issue found | Communicate immediately | - -### Time Allocation - -- **Small PR (<100 lines)**: 10-15 minutes -- **Medium PR (100-400 lines)**: 20-40 minutes -- **Large PR (>400 lines)**: Request to split, or 60+ minutes - -## Review Depth Levels - -### Level 1: Skim Review (5 minutes) -- Check PR description and linked issues -- Verify CI/CD status -- Look at file changes overview -- Identify if deeper review needed - -### Level 2: Standard Review (20-30 minutes) -- Full code walkthrough -- Logic verification -- Test coverage check -- Security scan - -### Level 3: Deep Review (60+ minutes) -- Architecture evaluation -- Performance analysis -- Security audit -- Edge case exploration - -## Communication Guidelines - -### Tone and Language - -**Use collaborative language:** -- "What do you think about..." instead of "You should..." -- "Could we consider..." instead of "This is wrong" -- "I'm curious about..." instead of "Why didn't you..." - -**Be specific and actionable:** -- Include code examples when suggesting changes -- Link to documentation or past discussions -- Explain the "why" behind suggestions - -### Handling Disagreements - -1. **Seek to understand**: Ask clarifying questions -2. **Acknowledge valid points**: Show you've considered their perspective -3. **Provide data**: Use benchmarks, docs, or examples -4. **Escalate if needed**: Involve senior dev or architect -5. **Know when to let go**: Not every hill is worth dying on - -## Review Prioritization - -### Must Fix (Blocking) -- Security vulnerabilities -- Data corruption risks -- Breaking changes without migration -- Critical performance issues -- Missing error handling for user-facing features - -### Should Fix (Important) -- Test coverage gaps -- Moderate performance concerns -- Code duplication -- Unclear naming or structure -- Missing documentation for complex logic - -### Nice to Have (Non-blocking) -- Style preferences beyond linting -- Minor optimizations -- Additional test cases -- Documentation improvements - -## Anti-Patterns to Avoid - -### Reviewer Anti-Patterns -- **Rubber stamping**: Approving without actually reviewing -- **Bike shedding**: Debating trivial details extensively -- **Scope creep**: "While you're at it, can you also..." -- **Ghosting**: Requesting changes then disappearing -- **Perfectionism**: Blocking for minor style preferences - -### Author Anti-Patterns -- **Mega PRs**: Submitting 1000+ line changes -- **No context**: Missing PR description or linked issues -- **Defensive responses**: Arguing every suggestion -- **Silent updates**: Making changes without responding to comments - -## Metrics and Improvement - -### Track These Metrics -- Time to first review -- Review cycle time -- Number of review rounds -- Defect escape rate -- Review coverage percentage - -### Continuous Improvement -- Hold retrospectives on review process -- Share learnings from escaped bugs -- Update checklists based on common issues -- Celebrate good reviews and catches diff --git a/.trellis/spec/review/reference/common-bugs-checklist.md b/.trellis/spec/review/reference/common-bugs-checklist.md deleted file mode 100644 index 97e2e637..00000000 --- a/.trellis/spec/review/reference/common-bugs-checklist.md +++ /dev/null @@ -1,1227 +0,0 @@ -# Common Bugs Checklist - -Language-specific bugs and issues to watch for during code review. - -## Universal Issues - -### Logic Errors -- [ ] Off-by-one errors in loops and array access -- [ ] Incorrect boolean logic (De Morgan's law violations) -- [ ] Missing null/undefined checks -- [ ] Race conditions in concurrent code -- [ ] Incorrect comparison operators (== vs ===, = vs ==) -- [ ] Integer overflow/underflow -- [ ] Floating point comparison issues - -### Resource Management -- [ ] Memory leaks (unclosed connections, listeners) -- [ ] File handles not closed -- [ ] Database connections not released -- [ ] Event listeners not removed -- [ ] Timers/intervals not cleared - -### Error Handling -- [ ] Swallowed exceptions (empty catch blocks) -- [ ] Generic exception handling hiding specific errors -- [ ] Missing error propagation -- [ ] Incorrect error types thrown -- [ ] Missing finally/cleanup blocks - -## TypeScript/JavaScript - -### Type Issues -```typescript -// ❌ Using any defeats type safety -function process(data: any) { return data.value; } - -// ✅ Use proper types -interface Data { value: string; } -function process(data: Data) { return data.value; } -``` - -### Async/Await Pitfalls -```typescript -// ❌ Missing await -async function fetch() { - const data = fetchData(); // Missing await! - return data.json(); -} - -// ❌ Unhandled promise rejection -async function risky() { - const result = await fetchData(); // No try-catch - return result; -} - -// ✅ Proper error handling -async function safe() { - try { - const result = await fetchData(); - return result; - } catch (error) { - console.error('Fetch failed:', error); - throw error; - } -} -``` - -### React Specific - -#### Hooks 规则违反 -```tsx -// ❌ 条件调用 Hooks — 违反 Hooks 规则 -function BadComponent({ show }) { - if (show) { - const [value, setValue] = useState(0); // Error! - } - return <div>...</div>; -} - -// ✅ Hooks 必须在顶层无条件调用 -function GoodComponent({ show }) { - const [value, setValue] = useState(0); - if (!show) return null; - return <div>{value}</div>; -} - -// ❌ 循环中调用 Hooks -function BadLoop({ items }) { - items.forEach(item => { - const [selected, setSelected] = useState(false); // Error! - }); -} - -// ✅ 将状态提升或使用不同的数据结构 -function GoodLoop({ items }) { - const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); - return items.map(item => ( - <Item key={item.id} selected={selectedIds.has(item.id)} /> - )); -} -``` - -#### useEffect 常见错误 -```tsx -// ❌ 依赖数组不完整 — stale closure -function StaleClosureExample({ userId, onSuccess }) { - const [data, setData] = useState(null); - useEffect(() => { - fetchData(userId).then(result => { - setData(result); - onSuccess(result); // onSuccess 可能是 stale 的! - }); - }, [userId]); // 缺少 onSuccess 依赖 -} - -// ✅ 完整的依赖数组 -useEffect(() => { - fetchData(userId).then(result => { - setData(result); - onSuccess(result); - }); -}, [userId, onSuccess]); - -// ❌ 无限循环 — 在 effect 中更新依赖 -function InfiniteLoop() { - const [count, setCount] = useState(0); - useEffect(() => { - setCount(count + 1); // 触发重渲染,又触发 effect - }, [count]); // 无限循环! -} - -// ❌ 缺少清理函数 — 内存泄漏 -function MemoryLeak({ userId }) { - const [user, setUser] = useState(null); - useEffect(() => { - fetchUser(userId).then(setUser); // 组件卸载后仍然调用 setUser - }, [userId]); -} - -// ✅ 正确的清理 -function NoLeak({ userId }) { - const [user, setUser] = useState(null); - useEffect(() => { - let cancelled = false; - fetchUser(userId).then(data => { - if (!cancelled) setUser(data); - }); - return () => { cancelled = true; }; - }, [userId]); -} - -// ❌ useEffect 用于派生状态(反模式) -function BadDerived({ items }) { - const [total, setTotal] = useState(0); - useEffect(() => { - setTotal(items.reduce((a, b) => a + b.price, 0)); - }, [items]); // 不必要的 effect + 额外渲染 -} - -// ✅ 直接计算或用 useMemo -function GoodDerived({ items }) { - const total = useMemo( - () => items.reduce((a, b) => a + b.price, 0), - [items] - ); -} - -// ❌ useEffect 用于事件响应 -function BadEvent() { - const [query, setQuery] = useState(''); - useEffect(() => { - if (query) logSearch(query); // 应该在事件处理器中 - }, [query]); -} - -// ✅ 副作用在事件处理器中 -function GoodEvent() { - const handleSearch = (q: string) => { - setQuery(q); - logSearch(q); - }; -} -``` - -#### useMemo / useCallback 误用 -```tsx -// ❌ 过度优化 — 常量不需要 memo -function OverOptimized() { - const config = useMemo(() => ({ api: '/v1' }), []); // 无意义 - const noop = useCallback(() => {}, []); // 无意义 -} - -// ❌ 空依赖的 useMemo(可能隐藏 bug) -function EmptyDeps({ user }) { - const greeting = useMemo(() => `Hello ${user.name}`, []); - // user 变化时 greeting 不更新! -} - -// ❌ useCallback 依赖总是变化 -function UselessCallback({ data }) { - const process = useCallback(() => { - return data.map(transform); - }, [data]); // 如果 data 每次都是新引用,完全无效 -} - -// ❌ useMemo/useCallback 没有配合 React.memo -function Parent() { - const data = useMemo(() => compute(), []); - const handler = useCallback(() => {}, []); - return <Child data={data} onClick={handler} />; - // Child 没有用 React.memo,这些优化毫无意义 -} - -// ✅ 正确的优化组合 -const MemoChild = React.memo(function Child({ data, onClick }) { - return <button onClick={onClick}>{data}</button>; -}); - -function Parent() { - const data = useMemo(() => expensiveCompute(), [dep]); - const handler = useCallback(() => {}, []); - return <MemoChild data={data} onClick={handler} />; -} -``` - -#### 组件设计问题 -```tsx -// ❌ 在组件内定义组件 -function Parent() { - // 每次渲染都创建新的 Child 函数,导致完全重新挂载 - const Child = () => <div>child</div>; - return <Child />; -} - -// ✅ 组件定义在外部 -const Child = () => <div>child</div>; -function Parent() { - return <Child />; -} - -// ❌ Props 总是新引用 — 破坏 memo -function BadProps() { - return ( - <MemoComponent - style={{ color: 'red' }} // 每次渲染新对象 - onClick={() => handle()} // 每次渲染新函数 - items={data.filter(x => x)} // 每次渲染新数组 - /> - ); -} - -// ❌ 直接修改 props -function MutateProps({ user }) { - user.name = 'Changed'; // 永远不要这样做! - return <div>{user.name}</div>; -} -``` - -#### Server Components 错误 (React 19+) -```tsx -// ❌ 在 Server Component 中使用客户端 API -// app/page.tsx (默认是 Server Component) -export default function Page() { - const [count, setCount] = useState(0); // Error! - useEffect(() => {}, []); // Error! - return <button onClick={() => {}}>Click</button>; // Error! -} - -// ✅ 交互逻辑移到 Client Component -// app/counter.tsx -'use client'; -export function Counter() { - const [count, setCount] = useState(0); - return <button onClick={() => setCount(c => c + 1)}>{count}</button>; -} - -// app/page.tsx -import { Counter } from './counter'; -export default async function Page() { - const data = await fetchData(); // Server Component 可以直接 await - return <Counter initialCount={data.count} />; -} - -// ❌ 在父组件标记 'use client',整个子树变成客户端 -// layout.tsx -'use client'; // 坏主意!所有子组件都变成客户端组件 -export default function Layout({ children }) { ... } -``` - -#### 测试常见错误 -```tsx -// ❌ 使用 container 查询 -const { container } = render(<Component />); -const button = container.querySelector('button'); // 不推荐 - -// ✅ 使用 screen 和语义查询 -render(<Component />); -const button = screen.getByRole('button', { name: /submit/i }); - -// ❌ 使用 fireEvent -fireEvent.click(button); - -// ✅ 使用 userEvent -await userEvent.click(button); - -// ❌ 测试实现细节 -expect(component.state.isOpen).toBe(true); - -// ✅ 测试行为 -expect(screen.getByRole('dialog')).toBeVisible(); - -// ❌ 等待同步查询 -await screen.getByText('Hello'); // getBy 是同步的 - -// ✅ 异步用 findBy -await screen.findByText('Hello'); // findBy 会等待 -``` - -### React Common Mistakes Checklist -- [ ] Hooks 不在顶层调用(条件/循环中) -- [ ] useEffect 依赖数组不完整 -- [ ] useEffect 缺少清理函数 -- [ ] useEffect 用于派生状态计算 -- [ ] useMemo/useCallback 过度使用 -- [ ] useMemo/useCallback 没配合 React.memo -- [ ] 在组件内定义子组件 -- [ ] Props 是新对象/函数引用(传给 memo 组件时) -- [ ] 直接修改 props -- [ ] 列表缺少 key 或用 index 作为 key -- [ ] Server Component 使用客户端 API -- [ ] 'use client' 放在父组件导致整个树客户端化 -- [ ] 测试使用 container 查询而非 screen -- [ ] 测试实现细节而非行为 - -### React 19 Actions & Forms 错误 - -```tsx -// === useActionState 错误 === - -// ❌ 在 Action 中直接 setState 而不是返回状态 -const [state, action] = useActionState(async (prev, formData) => { - setSomeState(newValue); // 错误!应该返回新状态 -}, initialState); - -// ✅ 返回新状态 -const [state, action] = useActionState(async (prev, formData) => { - const result = await submitForm(formData); - return { ...prev, data: result }; // 返回新状态 -}, initialState); - -// ❌ 忘记处理 isPending -const [state, action] = useActionState(submitAction, null); -return <button>Submit</button>; // 用户可以重复点击 - -// ✅ 使用 isPending 禁用按钮 -const [state, action, isPending] = useActionState(submitAction, null); -return <button disabled={isPending}>Submit</button>; - -// === useFormStatus 错误 === - -// ❌ 在 form 同级调用 useFormStatus -function Form() { - const { pending } = useFormStatus(); // 永远是 undefined! - return <form><button disabled={pending}>Submit</button></form>; -} - -// ✅ 在子组件中调用 -function SubmitButton() { - const { pending } = useFormStatus(); - return <button disabled={pending}>Submit</button>; -} -function Form() { - return <form><SubmitButton /></form>; -} - -// === useOptimistic 错误 === - -// ❌ 用于关键业务操作 -function PaymentButton() { - const [optimisticPaid, setPaid] = useOptimistic(false); - const handlePay = async () => { - setPaid(true); // 危险:显示已支付但可能失败 - await processPayment(); - }; -} - -// ❌ 没有处理回滚后的 UI 状态 -const [optimisticLikes, addLike] = useOptimistic(likes); -// 失败后 UI 回滚,但用户可能困惑为什么点赞消失了 - -// ✅ 提供失败反馈 -const handleLike = async () => { - addLike(1); - try { - await likePost(); - } catch { - toast.error('点赞失败,请重试'); // 通知用户 - } -}; -``` - -### React 19 Forms Checklist -- [ ] useActionState 返回新状态而不是 setState -- [ ] useActionState 正确使用 isPending 禁用提交 -- [ ] useFormStatus 在 form 子组件中调用 -- [ ] useOptimistic 不用于关键业务(支付、删除等) -- [ ] useOptimistic 失败时有用户反馈 -- [ ] Server Action 正确标记 'use server' - -### Suspense & Streaming 错误 - -```tsx -// === Suspense 边界错误 === - -// ❌ 整个页面一个 Suspense——慢内容阻塞快内容 -function BadPage() { - return ( - <Suspense fallback={<FullPageLoader />}> - <FastHeader /> {/* 快 */} - <SlowMainContent /> {/* 慢——阻塞整个页面 */} - <FastFooter /> {/* 快 */} - </Suspense> - ); -} - -// ✅ 独立边界,互不阻塞 -function GoodPage() { - return ( - <> - <FastHeader /> - <Suspense fallback={<ContentSkeleton />}> - <SlowMainContent /> - </Suspense> - <FastFooter /> - </> - ); -} - -// ❌ 没有 Error Boundary -function NoErrorHandling() { - return ( - <Suspense fallback={<Loading />}> - <DataFetcher /> {/* 抛错导致白屏 */} - </Suspense> - ); -} - -// ✅ Error Boundary + Suspense -function WithErrorHandling() { - return ( - <ErrorBoundary fallback={<ErrorFallback />}> - <Suspense fallback={<Loading />}> - <DataFetcher /> - </Suspense> - </ErrorBoundary> - ); -} - -// === use() Hook 错误 === - -// ❌ 在组件外创建 Promise(每次渲染新 Promise) -function BadUse() { - const data = use(fetchData()); // 每次渲染都创建新 Promise! - return <div>{data}</div>; -} - -// ✅ 在父组件创建,通过 props 传递 -function Parent() { - const dataPromise = useMemo(() => fetchData(), []); - return <Child dataPromise={dataPromise} />; -} -function Child({ dataPromise }) { - const data = use(dataPromise); - return <div>{data}</div>; -} - -// === Next.js Streaming 错误 === - -// ❌ 在 layout.tsx 中 await 慢数据——阻塞所有子页面 -// app/layout.tsx -export default async function Layout({ children }) { - const config = await fetchSlowConfig(); // 阻塞整个应用! - return <ConfigProvider value={config}>{children}</ConfigProvider>; -} - -// ✅ 将慢数据放在页面级别或使用 Suspense -// app/layout.tsx -export default function Layout({ children }) { - return ( - <Suspense fallback={<ConfigSkeleton />}> - <ConfigProvider>{children}</ConfigProvider> - </Suspense> - ); -} -``` - -### Suspense Checklist -- [ ] 慢内容有独立的 Suspense 边界 -- [ ] 每个 Suspense 有对应的 Error Boundary -- [ ] fallback 是有意义的骨架屏(不是简单 spinner) -- [ ] use() 的 Promise 不在渲染时创建 -- [ ] 没有在 layout 中 await 慢数据 -- [ ] 嵌套层级不超过 3 层 - -### TanStack Query 错误 - -```tsx -// === 查询配置错误 === - -// ❌ queryKey 不包含查询参数 -function BadQuery({ userId, filters }) { - const { data } = useQuery({ - queryKey: ['users'], // 缺少 userId 和 filters! - queryFn: () => fetchUsers(userId, filters), - }); - // userId 或 filters 变化时数据不会更新 -} - -// ✅ queryKey 包含所有影响数据的参数 -function GoodQuery({ userId, filters }) { - const { data } = useQuery({ - queryKey: ['users', userId, filters], - queryFn: () => fetchUsers(userId, filters), - }); -} - -// ❌ staleTime: 0 导致过度请求 -const { data } = useQuery({ - queryKey: ['data'], - queryFn: fetchData, - // 默认 staleTime: 0,每次组件挂载/窗口聚焦都会 refetch -}); - -// ✅ 设置合理的 staleTime -const { data } = useQuery({ - queryKey: ['data'], - queryFn: fetchData, - staleTime: 5 * 60 * 1000, // 5 分钟内不会自动 refetch -}); - -// === useSuspenseQuery 错误 === - -// ❌ useSuspenseQuery + enabled(不支持) -const { data } = useSuspenseQuery({ - queryKey: ['user', userId], - queryFn: () => fetchUser(userId), - enabled: !!userId, // 错误!useSuspenseQuery 不支持 enabled -}); - -// ✅ 条件渲染实现 -function UserQuery({ userId }) { - const { data } = useSuspenseQuery({ - queryKey: ['user', userId], - queryFn: () => fetchUser(userId), - }); - return <UserProfile user={data} />; -} - -function Parent({ userId }) { - if (!userId) return <SelectUser />; - return ( - <Suspense fallback={<UserSkeleton />}> - <UserQuery userId={userId} /> - </Suspense> - ); -} - -// === Mutation 错误 === - -// ❌ Mutation 成功后不 invalidate 查询 -const mutation = useMutation({ - mutationFn: updateUser, - // 忘记 invalidate,UI 显示旧数据 -}); - -// ✅ 成功后 invalidate 相关查询 -const mutation = useMutation({ - mutationFn: updateUser, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['users'] }); - }, -}); - -// ❌ 乐观更新不处理回滚 -const mutation = useMutation({ - mutationFn: updateTodo, - onMutate: async (newTodo) => { - queryClient.setQueryData(['todos'], (old) => [...old, newTodo]); - // 没有保存旧数据,失败后无法回滚! - }, -}); - -// ✅ 完整的乐观更新 -const mutation = useMutation({ - mutationFn: updateTodo, - onMutate: async (newTodo) => { - await queryClient.cancelQueries({ queryKey: ['todos'] }); - const previous = queryClient.getQueryData(['todos']); - queryClient.setQueryData(['todos'], (old) => [...old, newTodo]); - return { previous }; - }, - onError: (err, newTodo, context) => { - queryClient.setQueryData(['todos'], context.previous); - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ['todos'] }); - }, -}); - -// === v5 迁移错误 === - -// ❌ 使用废弃的 API -const { data, isLoading } = useQuery(['key'], fetchFn); // v4 语法 - -// ✅ v5 单一对象参数 -const { data, isPending } = useQuery({ - queryKey: ['key'], - queryFn: fetchFn, -}); - -// ❌ 混淆 isPending 和 isLoading -if (isLoading) return <Spinner />; -// v5 中 isLoading = isPending && isFetching - -// ✅ 根据意图选择 -if (isPending) return <Spinner />; // 没有缓存数据 -// 或 -if (isFetching) return <Refreshing />; // 正在后台刷新 -``` - -### TanStack Query Checklist -- [ ] queryKey 包含所有影响数据的参数 -- [ ] 设置了合理的 staleTime(不是默认 0) -- [ ] useSuspenseQuery 不使用 enabled -- [ ] Mutation 成功后 invalidate 相关查询 -- [ ] 乐观更新有完整的回滚逻辑 -- [ ] v5 使用单一对象参数语法 -- [ ] 理解 isPending vs isLoading vs isFetching - -### TypeScript/JavaScript Common Mistakes -- [ ] `==` instead of `===` -- [ ] Modifying array/object during iteration -- [ ] `this` context lost in callbacks -- [ ] Missing `key` prop in lists -- [ ] Closure capturing loop variable -- [ ] parseInt without radix parameter - -## Vue 3 - -### 响应性丢失 -```vue -<!-- ❌ 解构 reactive 丢失响应性 --> -<script setup> -const state = reactive({ count: 0 }) -const { count } = state // count 不是响应式的! -</script> - -<!-- ✅ 使用 toRefs --> -<script setup> -const state = reactive({ count: 0 }) -const { count } = toRefs(state) // count.value 是响应式的 -</script> -``` - -### Props 响应性传递 -```vue -<!-- ❌ 传递 props 值到 composable 丢失响应性 --> -<script setup> -const props = defineProps<{ id: string }>() -const { data } = useFetch(props.id) // id 变化时不会重新获取! -</script> - -<!-- ✅ 使用 toRef 或 getter --> -<script setup> -const props = defineProps<{ id: string }>() -const { data } = useFetch(() => props.id) // getter 保持响应性 -// 或 -const { data } = useFetch(toRef(props, 'id')) -</script> -``` - -### Watch 清理 -```vue -<!-- ❌ 异步 watch 无清理,导致竞态 --> -<script setup> -watch(id, async (newId) => { - const data = await fetchData(newId) - result.value = data // 旧请求可能覆盖新结果! -}) -</script> - -<!-- ✅ 使用 onCleanup 取消旧请求 --> -<script setup> -watch(id, async (newId, _, onCleanup) => { - const controller = new AbortController() - onCleanup(() => controller.abort()) - - const data = await fetchData(newId, controller.signal) - result.value = data -}) -</script> -``` - -### Computed 副作用 -```vue -<!-- ❌ computed 中修改其他状态 --> -<script setup> -const total = computed(() => { - sideEffect.value++ // 副作用!每次访问都会执行 - return items.value.reduce((a, b) => a + b, 0) -}) -</script> - -<!-- ✅ computed 只做纯计算 --> -<script setup> -const total = computed(() => { - return items.value.reduce((a, b) => a + b, 0) -}) -// 副作用放 watch -watch(total, () => { sideEffect.value++ }) -</script> -``` - -### 模板常见错误 -```vue -<!-- ❌ v-if 和 v-for 同时使用(v-if 优先级更高) --> -<template> - <div v-for="item in items" v-if="item.visible" :key="item.id"> - {{ item.name }} - </div> -</template> - -<!-- ✅ 使用 computed 或 template 包裹 --> -<template> - <template v-for="item in items" :key="item.id"> - <div v-if="item.visible">{{ item.name }}</div> - </template> -</template> -``` - -### Common Mistakes -- [ ] 解构 reactive 对象丢失响应性 -- [ ] props 传递给 composable 时未保持响应性 -- [ ] watch 异步回调无清理函数 -- [ ] computed 中产生副作用 -- [ ] v-for 使用 index 作为 key(列表会重排时) -- [ ] v-if 和 v-for 在同一元素上 -- [ ] defineProps 未使用 TypeScript 类型声明 -- [ ] withDefaults 对象默认值未使用工厂函数 -- [ ] 直接修改 props(而不是 emit) -- [ ] watchEffect 依赖不明确导致过度触发 - -## Python - -### Mutable Default Arguments -```python -# ❌ Bug: List shared across all calls -def add_item(item, items=[]): - items.append(item) - return items - -# ✅ Correct -def add_item(item, items=None): - if items is None: - items = [] - items.append(item) - return items -``` - -### Exception Handling -```python -# ❌ Catching everything, including KeyboardInterrupt -try: - risky_operation() -except: - pass - -# ✅ Catch specific exceptions -try: - risky_operation() -except ValueError as e: - logger.error(f"Invalid value: {e}") - raise -``` - -### Class Attributes -```python -# ❌ Shared mutable class attribute -class User: - permissions = [] # Shared across all instances! - -# ✅ Initialize in __init__ -class User: - def __init__(self): - self.permissions = [] -``` - -### Common Mistakes -- [ ] Using `is` instead of `==` for value comparison -- [ ] Forgetting `self` parameter in methods -- [ ] Modifying list while iterating -- [ ] String concatenation in loops (use join) -- [ ] Not closing files (use `with` statement) - -## Rust - -### 所有权与借用 - -```rust -// ❌ Use after move -let s = String::from("hello"); -let s2 = s; -println!("{}", s); // Error: s was moved - -// ✅ Clone if needed (but consider if clone is necessary) -let s = String::from("hello"); -let s2 = s.clone(); -println!("{}", s); // OK - -// ❌ 用 clone() 绕过借用检查器(反模式) -fn process(data: &Data) { - let owned = data.clone(); // 不必要的 clone - do_something(owned); -} - -// ✅ 正确使用借用 -fn process(data: &Data) { - do_something(data); // 传递引用 -} - -// ❌ 在结构体中存储借用(通常是坏主意) -struct Parser<'a> { - input: &'a str, // 生命周期复杂化 - position: usize, -} - -// ✅ 使用拥有的数据 -struct Parser { - input: String, // 拥有数据,简化生命周期 - position: usize, -} - -// ❌ 迭代时修改集合 -let mut vec = vec![1, 2, 3]; -for item in &vec { - vec.push(*item); // Error: cannot borrow as mutable -} - -// ✅ 收集到新集合 -let vec = vec![1, 2, 3]; -let new_vec: Vec<_> = vec.iter().map(|x| x * 2).collect(); -``` - -### Unsafe 代码审查 - -```rust -// ❌ unsafe 没有安全注释 -unsafe { - ptr::write(dest, value); -} - -// ✅ 必须有 SAFETY 注释说明不变量 -// SAFETY: dest 指针由 Vec::as_mut_ptr() 获得,保证: -// 1. 指针有效且已对齐 -// 2. 目标内存未被其他引用借用 -// 3. 写入不会超出分配的容量 -unsafe { - ptr::write(dest, value); -} - -// ❌ unsafe fn 没有 # Safety 文档 -pub unsafe fn from_raw_parts(ptr: *mut T, len: usize) -> Self { ... } - -// ✅ 必须文档化安全契约 -/// Creates a new instance from raw parts. -/// -/// # Safety -/// -/// - `ptr` must have been allocated via `GlobalAlloc` -/// - `len` must be less than or equal to the allocated capacity -/// - The caller must ensure no other references to the memory exist -pub unsafe fn from_raw_parts(ptr: *mut T, len: usize) -> Self { ... } - -// ❌ 跨模块 unsafe 不变量 -mod a { - pub fn set_flag() { FLAG = true; } // 安全代码影响 unsafe -} -mod b { - pub unsafe fn do_thing() { - if FLAG { /* assumes FLAG means something */ } - } -} - -// ✅ 将 unsafe 边界封装在单一模块 -mod safe_wrapper { - // 所有 unsafe 逻辑在一个模块内 - // 对外提供 safe API -} -``` - -### 异步/并发 - -```rust -// ❌ 在异步上下文中阻塞 -async fn bad_fetch(url: &str) -> Result<String> { - let resp = reqwest::blocking::get(url)?; // 阻塞整个运行时! - Ok(resp.text()?) -} - -// ✅ 使用异步版本 -async fn good_fetch(url: &str) -> Result<String> { - let resp = reqwest::get(url).await?; - Ok(resp.text().await?) -} - -// ❌ 跨 .await 持有 Mutex -async fn bad_lock(mutex: &Mutex<Data>) { - let guard = mutex.lock().unwrap(); - some_async_op().await; // 持锁跨越 await! - drop(guard); -} - -// ✅ 缩短锁持有时间 -async fn good_lock(mutex: &Mutex<Data>) { - let data = { - let guard = mutex.lock().unwrap(); - guard.clone() // 获取数据后立即释放锁 - }; - some_async_op().await; - // 处理 data -} - -// ❌ 在异步函数中使用 std::sync::Mutex -async fn bad_async_mutex(mutex: &std::sync::Mutex<Data>) { - let _guard = mutex.lock().unwrap(); // 可能死锁 - tokio::time::sleep(Duration::from_secs(1)).await; -} - -// ✅ 使用 tokio::sync::Mutex(如果必须跨 await) -async fn good_async_mutex(mutex: &tokio::sync::Mutex<Data>) { - let _guard = mutex.lock().await; - tokio::time::sleep(Duration::from_secs(1)).await; -} - -// ❌ 忘记 Future 是惰性的 -fn bad_spawn() { - let future = async_operation(); // 没有执行! - // future 被丢弃,什么都没发生 -} - -// ✅ 必须 await 或 spawn -async fn good_spawn() { - async_operation().await; // 执行 - // 或 - tokio::spawn(async_operation()); // 后台执行 -} - -// ❌ spawn 任务缺少 'static -async fn bad_spawn_lifetime(data: &str) { - tokio::spawn(async { - println!("{}", data); // Error: data 不是 'static - }); -} - -// ✅ 使用 move 或 Arc -async fn good_spawn_lifetime(data: String) { - tokio::spawn(async move { - println!("{}", data); // OK: 拥有数据 - }); -} -``` - -### 错误处理 - -```rust -// ❌ 生产代码中使用 unwrap/expect -fn bad_parse(input: &str) -> i32 { - input.parse().unwrap() // panic! -} - -// ✅ 正确传播错误 -fn good_parse(input: &str) -> Result<i32, ParseIntError> { - input.parse() -} - -// ❌ 吞掉错误信息 -fn bad_error_handling() -> Result<()> { - match operation() { - Ok(v) => Ok(v), - Err(_) => Err(anyhow!("operation failed")) // 丢失原始错误 - } -} - -// ✅ 使用 context 添加上下文 -fn good_error_handling() -> Result<()> { - operation().context("failed to perform operation")?; - Ok(()) -} - -// ❌ 库代码使用 anyhow(应该用 thiserror) -// lib.rs -pub fn parse_config(path: &str) -> anyhow::Result<Config> { - // 调用者无法区分错误类型 -} - -// ✅ 库代码用 thiserror 定义错误类型 -#[derive(Debug, thiserror::Error)] -pub enum ConfigError { - #[error("failed to read config file: {0}")] - Io(#[from] std::io::Error), - #[error("invalid config format: {0}")] - Parse(#[from] serde_json::Error), -} - -pub fn parse_config(path: &str) -> Result<Config, ConfigError> { - // 调用者可以 match 不同错误 -} - -// ❌ 忽略 must_use 返回值 -fn bad_ignore_result() { - some_fallible_operation(); // 警告:unused Result -} - -// ✅ 显式处理或标记忽略 -fn good_handle_result() { - let _ = some_fallible_operation(); // 显式忽略 - // 或 - some_fallible_operation().ok(); // 转换为 Option -} -``` - -### 性能陷阱 - -```rust -// ❌ 不必要的 collect -fn bad_process(items: &[i32]) -> i32 { - items.iter() - .filter(|x| **x > 0) - .collect::<Vec<_>>() // 不必要的分配 - .iter() - .sum() -} - -// ✅ 惰性迭代 -fn good_process(items: &[i32]) -> i32 { - items.iter() - .filter(|x| **x > 0) - .sum() -} - -// ❌ 循环中重复分配 -fn bad_loop() -> String { - let mut result = String::new(); - for i in 0..1000 { - result = result + &i.to_string(); // 每次迭代都重新分配! - } - result -} - -// ✅ 预分配或使用 push_str -fn good_loop() -> String { - let mut result = String::with_capacity(4000); // 预分配 - for i in 0..1000 { - write!(result, "{}", i).unwrap(); // 原地追加 - } - result -} - -// ❌ 过度使用 clone -fn bad_clone(data: &HashMap<String, Vec<u8>>) -> Vec<u8> { - data.get("key").cloned().unwrap_or_default() -} - -// ✅ 返回引用或使用 Cow -fn good_ref(data: &HashMap<String, Vec<u8>>) -> &[u8] { - data.get("key").map(|v| v.as_slice()).unwrap_or(&[]) -} - -// ❌ 大结构体按值传递 -fn bad_pass(data: LargeStruct) { ... } // 拷贝整个结构体 - -// ✅ 传递引用 -fn good_pass(data: &LargeStruct) { ... } - -// ❌ Box<dyn Trait> 用于小型已知类型 -fn bad_trait_object() -> Box<dyn Iterator<Item = i32>> { - Box::new(vec![1, 2, 3].into_iter()) -} - -// ✅ 使用 impl Trait -fn good_impl_trait() -> impl Iterator<Item = i32> { - vec![1, 2, 3].into_iter() -} - -// ❌ retain 比 filter+collect 慢(某些场景) -vec.retain(|x| x.is_valid()); // O(n) 但常数因子大 - -// ✅ 如果不需要原地修改,考虑 filter -let vec: Vec<_> = vec.into_iter().filter(|x| x.is_valid()).collect(); -``` - -### 生命周期与引用 - -```rust -// ❌ 返回局部变量的引用 -fn bad_return_ref() -> &str { - let s = String::from("hello"); - &s // Error: s will be dropped -} - -// ✅ 返回拥有的数据或静态引用 -fn good_return_owned() -> String { - String::from("hello") -} - -// ❌ 生命周期过度泛化 -fn bad_lifetime<'a, 'b>(x: &'a str, y: &'b str) -> &'a str { - x // 'b 没有被使用 -} - -// ✅ 简化生命周期 -fn good_lifetime(x: &str, _y: &str) -> &str { - x // 编译器自动推断 -} - -// ❌ 结构体持有多个相关引用但生命周期独立 -struct Bad<'a, 'b> { - name: &'a str, - data: &'b [u8], // 通常应该是同一个生命周期 -} - -// ✅ 相关数据使用相同生命周期 -struct Good<'a> { - name: &'a str, - data: &'a [u8], -} -``` - -### Rust 审查清单 - -**所有权与借用** -- [ ] clone() 是有意为之,不是绕过借用检查器 -- [ ] 避免在结构体中存储借用(除非必要) -- [ ] Rc/Arc 使用合理,没有隐藏不必要的共享状态 -- [ ] 没有不必要的 RefCell(运行时检查 vs 编译时) - -**Unsafe 代码** -- [ ] 每个 unsafe 块有 SAFETY 注释 -- [ ] unsafe fn 有 # Safety 文档 -- [ ] 安全不变量被清晰记录 -- [ ] unsafe 边界尽可能小 - -**异步/并发** -- [ ] 没有在异步上下文中阻塞 -- [ ] 没有跨 .await 持有 std::sync 锁 -- [ ] spawn 的任务满足 'static 约束 -- [ ] Future 被正确 await 或 spawn -- [ ] 锁的顺序一致(避免死锁) - -**错误处理** -- [ ] 库代码使用 thiserror,应用代码使用 anyhow -- [ ] 错误有足够的上下文信息 -- [ ] 没有在生产代码中 unwrap/expect -- [ ] must_use 返回值被正确处理 - -**性能** -- [ ] 避免不必要的 collect() -- [ ] 大数据结构传引用 -- [ ] 字符串拼接使用 String::with_capacity 或 write! -- [ ] impl Trait 优于 Box<dyn Trait>(当可能时) - -**类型系统** -- [ ] 善用 newtype 模式增加类型安全 -- [ ] 枚举穷尽匹配(没有 _ 通配符隐藏新变体) -- [ ] 生命周期尽可能简化 - -## SQL - -### Injection Vulnerabilities -```sql --- ❌ String concatenation (SQL injection risk) -query = "SELECT * FROM users WHERE id = " + user_id - --- ✅ Parameterized queries -query = "SELECT * FROM users WHERE id = ?" -cursor.execute(query, (user_id,)) -``` - -### Performance Issues -- [ ] Missing indexes on filtered/joined columns -- [ ] SELECT * instead of specific columns -- [ ] N+1 query patterns -- [ ] Missing LIMIT on large tables -- [ ] Inefficient subqueries vs JOINs - -### Common Mistakes -- [ ] Not handling NULL comparisons correctly -- [ ] Missing transactions for related operations -- [ ] Incorrect JOIN types -- [ ] Case sensitivity issues -- [ ] Date/timezone handling errors - -## API Design - -### REST Issues -- [ ] Inconsistent resource naming -- [ ] Wrong HTTP methods (POST for idempotent operations) -- [ ] Missing pagination for list endpoints -- [ ] Incorrect status codes -- [ ] Missing rate limiting - -### Data Validation -- [ ] Missing input validation -- [ ] Incorrect data type validation -- [ ] Missing length/range checks -- [ ] Not sanitizing user input -- [ ] Trusting client-side validation - -## Testing - -### Test Quality Issues -- [ ] Testing implementation details instead of behavior -- [ ] Missing edge case tests -- [ ] Flaky tests (non-deterministic) -- [ ] Tests with external dependencies -- [ ] Missing negative tests (error cases) -- [ ] Overly complex test setup diff --git a/.trellis/spec/review/reference/cpp.md b/.trellis/spec/review/reference/cpp.md deleted file mode 100644 index 58743f68..00000000 --- a/.trellis/spec/review/reference/cpp.md +++ /dev/null @@ -1,385 +0,0 @@ -# C++ Code Review Guide - -> C++ code review guide focused on memory safety, lifetime, API design, and performance. Examples assume C++17/20. - -## Table of Contents - -- [Ownership and RAII](#ownership-and-raii) -- [Lifetime and References](#lifetime-and-references) -- [Copy and Move Semantics](#copy-and-move-semantics) -- [Const-Correctness and API Design](#const-correctness-and-api-design) -- [Error Handling and Exception Safety](#error-handling-and-exception-safety) -- [Concurrency](#concurrency) -- [Performance and Allocation](#performance-and-allocation) -- [Templates and Type Safety](#templates-and-type-safety) -- [Tooling and Build Checks](#tooling-and-build-checks) -- [Review Checklist](#review-checklist) - ---- - -## Ownership and RAII - -### Prefer RAII and smart pointers - -Use RAII to express ownership. Default to `std::unique_ptr`, use `std::shared_ptr` only for shared lifetime. - -```cpp -// ? Bad: manual new/delete with early returns -Foo* make_foo() { - Foo* foo = new Foo(); - if (!foo->Init()) { - delete foo; - return nullptr; - } - return foo; -} - -// ? Good: RAII with unique_ptr -std::unique_ptr<Foo> make_foo() { - auto foo = std::make_unique<Foo>(); - if (!foo->Init()) { - return {}; - } - return foo; -} -``` - -### Wrap C resources - -```cpp -// ? Good: wrap FILE* with unique_ptr -using FilePtr = std::unique_ptr<FILE, decltype(&fclose)>; - -FilePtr open_file(const char* path) { - return FilePtr(fopen(path, "rb"), &fclose); -} -``` - ---- - -## Lifetime and References - -### Avoid dangling references and views - -`std::string_view` and `std::span` do not own data. Make sure the owner outlives the view. - -```cpp -// ? Bad: returning string_view to a temporary -std::string_view bad_view() { - std::string s = make_name(); - return s; // dangling -} - -// ? Good: return owning string -std::string good_name() { - return make_name(); -} - -// ? Good: view tied to caller-owned data -std::string_view good_view(const std::string& s) { - return s; -} -``` - -### Lambda captures - -```cpp -// ? Bad: capture reference that escapes -std::function<void()> make_task() { - int value = 42; - return [&]() { use(value); }; // dangling -} - -// ? Good: capture by value -std::function<void()> make_task() { - int value = 42; - return [value]() { use(value); }; -} -``` - ---- - -## Copy and Move Semantics - -### Rule of 0/3/5 - -Prefer the Rule of 0 by using RAII types. If you own a resource, define or delete copy and move operations. - -```cpp -// ? Bad: raw ownership with default copy -struct Buffer { - int* data; - size_t size; - explicit Buffer(size_t n) : data(new int[n]), size(n) {} - ~Buffer() { delete[] data; } - // copy ctor/assign are implicitly generated -> double delete -}; - -// ? Good: Rule of 0 with std::vector -struct Buffer { - std::vector<int> data; - explicit Buffer(size_t n) : data(n) {} -}; -``` - -### Delete unwanted copies - -```cpp -struct Socket { - Socket() = default; - ~Socket() { close(); } - - Socket(const Socket&) = delete; - Socket& operator=(const Socket&) = delete; - Socket(Socket&&) noexcept = default; - Socket& operator=(Socket&&) noexcept = default; -}; -``` - ---- - -## Const-Correctness and API Design - -### Use const and explicit - -```cpp -class User { -public: - const std::string& name() const { return name_; } - void set_name(std::string name) { name_ = std::move(name); } - -private: - std::string name_; -}; - -struct Millis { - explicit Millis(int v) : value(v) {} - int value; -}; -``` - -### Avoid object slicing - -```cpp -struct Shape { virtual ~Shape() = default; }; -struct Circle : Shape { void draw() const; }; - -// ? Bad: slices Circle into Shape -void draw(Shape shape); - -// ? Good: pass by reference -void draw(const Shape& shape); -``` - -### Use override and final - -```cpp -struct Base { - virtual void run() = 0; -}; - -struct Worker final : Base { - void run() override {} -}; -``` - ---- - -## Error Handling and Exception Safety - -### Prefer RAII for cleanup - -```cpp -// ? Good: RAII handles cleanup on exceptions -void process() { - std::vector<int> data = load_data(); // safe cleanup - do_work(data); -} -``` - -### Do not throw from destructors - -```cpp -struct File { - ~File() noexcept { close(); } - void close(); -}; -``` - -### Use expected results for normal failures - -```cpp -// ? Expected error: use optional or expected -std::optional<int> parse_int(const std::string& s) { - try { - return std::stoi(s); - } catch (...) { - return std::nullopt; - } -} -``` - ---- - -## Concurrency - -### Protect shared data - -```cpp -// ? Bad: data race -int counter = 0; -void inc() { counter++; } - -// ? Good: atomic -std::atomic<int> counter{0}; -void inc() { counter.fetch_add(1, std::memory_order_relaxed); } -``` - -### Use RAII locks - -```cpp -std::mutex mu; -std::vector<int> data; - -void add(int v) { - std::lock_guard<std::mutex> lock(mu); - data.push_back(v); -} -``` - ---- - -## Performance and Allocation - -### Avoid repeated allocations - -```cpp -// ? Bad: repeated reallocation -std::vector<int> build(int n) { - std::vector<int> out; - for (int i = 0; i < n; ++i) { - out.push_back(i); - } - return out; -} - -// ? Good: reserve upfront -std::vector<int> build(int n) { - std::vector<int> out; - out.reserve(static_cast<size_t>(n)); - for (int i = 0; i < n; ++i) { - out.push_back(i); - } - return out; -} -``` - -### String concatenation - -```cpp -// ? Bad: repeated allocation -std::string join(const std::vector<std::string>& parts) { - std::string out; - for (const auto& p : parts) { - out += p; - } - return out; -} - -// ? Good: reserve total size -std::string join(const std::vector<std::string>& parts) { - size_t total = 0; - for (const auto& p : parts) { - total += p.size(); - } - std::string out; - out.reserve(total); - for (const auto& p : parts) { - out += p; - } - return out; -} -``` - ---- - -## Templates and Type Safety - -### Prefer constrained templates (C++20) - -```cpp -// ? Bad: overly generic -template <typename T> -T add(T a, T b) { - return a + b; -} - -// ? Good: constrained -template <typename T> -requires std::is_integral_v<T> -T add(T a, T b) { - return a + b; -} -``` - -### Use static_assert for invariants - -```cpp -template <typename T> -struct Packet { - static_assert(std::is_trivially_copyable_v<T>, - "Packet payload must be trivially copyable"); - T payload; -}; -``` - ---- - -## Tooling and Build Checks - -```bash -# Warnings -clang++ -Wall -Wextra -Werror -Wconversion -Wshadow -std=c++20 ... - -# Sanitizers (debug builds) -clang++ -fsanitize=address,undefined -fno-omit-frame-pointer -g ... -clang++ -fsanitize=thread -fno-omit-frame-pointer -g ... - -# Static analysis -clang-tidy src/*.cpp -- -std=c++20 - -# Formatting -clang-format -i src/*.cpp include/*.h -``` - ---- - -## Review Checklist - -### Safety and Lifetime -- [ ] Ownership is explicit (RAII, unique_ptr by default) -- [ ] No dangling references or views -- [ ] Rule of 0/3/5 followed for resource-owning types -- [ ] No raw new/delete in business logic -- [ ] Destructors are noexcept and do not throw - -### API and Design -- [ ] const-correctness is applied consistently -- [ ] Constructors are explicit where needed -- [ ] Override/final used for virtual functions -- [ ] No object slicing (pass by ref or pointer) - -### Concurrency -- [ ] Shared data is protected (mutex or atomics) -- [ ] Locking order is consistent -- [ ] No blocking while holding locks - -### Performance -- [ ] Unnecessary allocations avoided (reserve, move) -- [ ] Copies avoided in hot paths -- [ ] Algorithmic complexity is reasonable - -### Tooling and Tests -- [ ] Builds clean with warnings enabled -- [ ] Sanitizers run on critical code paths -- [ ] Static analysis (clang-tidy) results are addressed diff --git a/.trellis/spec/review/reference/performance-review-guide.md b/.trellis/spec/review/reference/performance-review-guide.md deleted file mode 100644 index 87a8ba73..00000000 --- a/.trellis/spec/review/reference/performance-review-guide.md +++ /dev/null @@ -1,752 +0,0 @@ -# Performance Review Guide - -性能审查指南,覆盖前端、后端、数据库、算法复杂度和 API 性能。 - -## 目录 - -- [前端性能 (Core Web Vitals)](#前端性能-core-web-vitals) -- [JavaScript 性能](#javascript-性能) -- [内存管理](#内存管理) -- [数据库性能](#数据库性能) -- [API 性能](#api-性能) -- [算法复杂度](#算法复杂度) -- [性能审查清单](#性能审查清单) - ---- - -## 前端性能 (Core Web Vitals) - -### 2024 核心指标 - -| 指标 | 全称 | 目标值 | 含义 | -|------|------|--------|------| -| **LCP** | Largest Contentful Paint | ≤ 2.5s | 最大内容绘制时间 | -| **INP** | Interaction to Next Paint | ≤ 200ms | 交互响应时间(2024 年替代 FID)| -| **CLS** | Cumulative Layout Shift | ≤ 0.1 | 累积布局偏移 | -| **FCP** | First Contentful Paint | ≤ 1.8s | 首次内容绘制 | -| **TBT** | Total Blocking Time | ≤ 200ms | 主线程阻塞时间 | - -### LCP 优化检查 - -```javascript -// ❌ LCP 图片懒加载 - 延迟关键内容 -<img src="hero.jpg" loading="lazy" /> - -// ✅ LCP 图片立即加载 -<img src="hero.jpg" fetchpriority="high" /> - -// ❌ 未优化的图片格式 -<img src="hero.png" /> // PNG 文件过大 - -// ✅ 现代图片格式 + 响应式 -<picture> - <source srcset="hero.avif" type="image/avif" /> - <source srcset="hero.webp" type="image/webp" /> - <img src="hero.jpg" alt="Hero" /> -</picture> -``` - -**审查要点:** -- [ ] LCP 元素是否设置 `fetchpriority="high"`? -- [ ] 是否使用 WebP/AVIF 格式? -- [ ] 是否有服务端渲染或静态生成? -- [ ] CDN 是否配置正确? - -### FCP 优化检查 - -```html -<!-- ❌ 阻塞渲染的 CSS --> -<link rel="stylesheet" href="all-styles.css" /> - -<!-- ✅ 关键 CSS 内联 + 异步加载其余 --> -<style>/* 首屏关键样式 */</style> -<link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'" /> - -<!-- ❌ 阻塞渲染的字体 --> -@font-face { - font-family: 'CustomFont'; - src: url('font.woff2'); -} - -<!-- ✅ 字体显示优化 --> -@font-face { - font-family: 'CustomFont'; - src: url('font.woff2'); - font-display: swap; /* 先用系统字体,加载后切换 */ -} -``` - -### INP 优化检查 - -```javascript -// ❌ 长任务阻塞主线程 -button.addEventListener('click', () => { - // 耗时 500ms 的同步操作 - processLargeData(data); - updateUI(); -}); - -// ✅ 拆分长任务 -button.addEventListener('click', async () => { - // 让出主线程 - await scheduler.yield?.() ?? new Promise(r => setTimeout(r, 0)); - - // 分批处理 - for (const chunk of chunks) { - processChunk(chunk); - await scheduler.yield?.(); - } - updateUI(); -}); - -// ✅ 使用 Web Worker 处理复杂计算 -const worker = new Worker('heavy-computation.js'); -worker.postMessage(data); -worker.onmessage = (e) => updateUI(e.data); -``` - -### CLS 优化检查 - -```css -/* ❌ 未指定尺寸的媒体 */ -img { width: 100%; } - -/* ✅ 预留空间 */ -img { - width: 100%; - aspect-ratio: 16 / 9; -} - -/* ❌ 动态插入内容导致布局偏移 */ -.ad-container { } - -/* ✅ 预留固定高度 */ -.ad-container { - min-height: 250px; -} -``` - -**CLS 审查清单:** -- [ ] 图片/视频是否有 width/height 或 aspect-ratio? -- [ ] 字体加载是否使用 `font-display: swap`? -- [ ] 动态内容是否预留空间? -- [ ] 是否避免在现有内容上方插入内容? - ---- - -## JavaScript 性能 - -### 代码分割与懒加载 - -```javascript -// ❌ 一次性加载所有代码 -import { HeavyChart } from './charts'; -import { PDFExporter } from './pdf'; -import { AdminPanel } from './admin'; - -// ✅ 按需加载 -const HeavyChart = lazy(() => import('./charts')); -const PDFExporter = lazy(() => import('./pdf')); - -// ✅ 路由级代码分割 -const routes = [ - { - path: '/dashboard', - component: lazy(() => import('./pages/Dashboard')), - }, - { - path: '/admin', - component: lazy(() => import('./pages/Admin')), - }, -]; -``` - -### Bundle 体积优化 - -```javascript -// ❌ 导入整个库 -import _ from 'lodash'; -import moment from 'moment'; - -// ✅ 按需导入 -import debounce from 'lodash/debounce'; -import { format } from 'date-fns'; - -// ❌ 未使用 Tree Shaking -export default { - fn1() {}, - fn2() {}, // 未使用但被打包 -}; - -// ✅ 命名导出支持 Tree Shaking -export function fn1() {} -export function fn2() {} -``` - -**Bundle 审查清单:** -- [ ] 是否使用动态 import() 进行代码分割? -- [ ] 大型库是否按需导入? -- [ ] 是否分析过 bundle 大小?(webpack-bundle-analyzer) -- [ ] 是否有未使用的依赖? - -### 列表渲染优化 - -```javascript -// ❌ 渲染大列表 -function List({ items }) { - return ( - <ul> - {items.map(item => <li key={item.id}>{item.name}</li>)} - </ul> - ); // 10000 条数据 = 10000 个 DOM 节点 -} - -// ✅ 虚拟列表 - 只渲染可见项 -import { FixedSizeList } from 'react-window'; - -function VirtualList({ items }) { - return ( - <FixedSizeList - height={400} - itemCount={items.length} - itemSize={35} - > - {({ index, style }) => ( - <div style={style}>{items[index].name}</div> - )} - </FixedSizeList> - ); -} -``` - -**大数据审查要点:** -- [ ] 列表超过 100 项是否使用虚拟滚动? -- [ ] 表格是否支持分页或虚拟化? -- [ ] 是否有不必要的全量渲染? - ---- - -## 内存管理 - -### 常见内存泄漏 - -#### 1. 未清理的事件监听 - -```javascript -// ❌ 组件卸载后事件仍在监听 -useEffect(() => { - window.addEventListener('resize', handleResize); -}, []); - -// ✅ 清理事件监听 -useEffect(() => { - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); -}, []); -``` - -#### 2. 未清理的定时器 - -```javascript -// ❌ 定时器未清理 -useEffect(() => { - setInterval(fetchData, 5000); -}, []); - -// ✅ 清理定时器 -useEffect(() => { - const timer = setInterval(fetchData, 5000); - return () => clearInterval(timer); -}, []); -``` - -#### 3. 闭包引用 - -```javascript -// ❌ 闭包持有大对象引用 -function createHandler() { - const largeData = new Array(1000000).fill('x'); - - return function handler() { - // largeData 被闭包引用,无法被回收 - console.log(largeData.length); - }; -} - -// ✅ 只保留必要数据 -function createHandler() { - const largeData = new Array(1000000).fill('x'); - const length = largeData.length; // 只保留需要的值 - - return function handler() { - console.log(length); - }; -} -``` - -#### 4. 未清理的订阅 - -```javascript -// ❌ WebSocket/EventSource 未关闭 -useEffect(() => { - const ws = new WebSocket('wss://...'); - ws.onmessage = handleMessage; -}, []); - -// ✅ 清理连接 -useEffect(() => { - const ws = new WebSocket('wss://...'); - ws.onmessage = handleMessage; - return () => ws.close(); -}, []); -``` - -### 内存审查清单 - -```markdown -- [ ] useEffect 是否都有清理函数? -- [ ] 事件监听是否在组件卸载时移除? -- [ ] 定时器是否被清理? -- [ ] WebSocket/SSE 连接是否关闭? -- [ ] 大对象是否及时释放? -- [ ] 是否有全局变量累积数据? -``` - -### 检测工具 - -| 工具 | 用途 | -|------|------| -| Chrome DevTools Memory | 堆快照分析 | -| MemLab (Meta) | 自动化内存泄漏检测 | -| Performance Monitor | 实时内存监控 | - ---- - -## 数据库性能 - -### N+1 查询问题 - -```python -# ❌ N+1 问题 - 1 + N 次查询 -users = User.objects.all() # 1 次查询 -for user in users: - print(user.profile.bio) # N 次查询(每个用户一次) - -# ✅ Eager Loading - 2 次查询 -users = User.objects.select_related('profile').all() -for user in users: - print(user.profile.bio) # 无额外查询 - -# ✅ 多对多关系用 prefetch_related -posts = Post.objects.prefetch_related('tags').all() -``` - -```javascript -// TypeORM 示例 -// ❌ N+1 问题 -const users = await userRepository.find(); -for (const user of users) { - const posts = await user.posts; // 每次循环都查询 -} - -// ✅ Eager Loading -const users = await userRepository.find({ - relations: ['posts'], -}); -``` - -### 索引优化 - -```sql --- ❌ 全表扫描 -SELECT * FROM orders WHERE status = 'pending'; - --- ✅ 添加索引 -CREATE INDEX idx_orders_status ON orders(status); - --- ❌ 索引失效:函数操作 -SELECT * FROM users WHERE YEAR(created_at) = 2024; - --- ✅ 范围查询可用索引 -SELECT * FROM users -WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'; - --- ❌ 索引失效:LIKE 前缀通配符 -SELECT * FROM products WHERE name LIKE '%phone%'; - --- ✅ 前缀匹配可用索引 -SELECT * FROM products WHERE name LIKE 'phone%'; -``` - -### 查询优化 - -```sql --- ❌ SELECT * 获取不需要的列 -SELECT * FROM users WHERE id = 1; - --- ✅ 只查询需要的列 -SELECT id, name, email FROM users WHERE id = 1; - --- ❌ 大表无 LIMIT -SELECT * FROM logs WHERE type = 'error'; - --- ✅ 分页查询 -SELECT * FROM logs WHERE type = 'error' LIMIT 100 OFFSET 0; - --- ❌ 在循环中执行查询 -for id in user_ids: - cursor.execute("SELECT * FROM users WHERE id = %s", (id,)) - --- ✅ 批量查询 -cursor.execute("SELECT * FROM users WHERE id IN %s", (tuple(user_ids),)) -``` - -### 数据库审查清单 - -```markdown -🔴 必须检查: -- [ ] 是否存在 N+1 查询? -- [ ] WHERE 子句列是否有索引? -- [ ] 是否避免了 SELECT *? -- [ ] 大表查询是否有 LIMIT? - -🟡 建议检查: -- [ ] 是否使用了 EXPLAIN 分析查询计划? -- [ ] 复合索引列顺序是否正确? -- [ ] 是否有未使用的索引? -- [ ] 是否有慢查询日志监控? -``` - ---- - -## API 性能 - -### 分页实现 - -```javascript -// ❌ 返回全部数据 -app.get('/users', async (req, res) => { - const users = await User.findAll(); // 可能返回 100000 条 - res.json(users); -}); - -// ✅ 分页 + 限制最大数量 -app.get('/users', async (req, res) => { - const page = parseInt(req.query.page) || 1; - const limit = Math.min(parseInt(req.query.limit) || 20, 100); // 最大 100 - const offset = (page - 1) * limit; - - const { rows, count } = await User.findAndCountAll({ - limit, - offset, - order: [['id', 'ASC']], - }); - - res.json({ - data: rows, - pagination: { - page, - limit, - total: count, - totalPages: Math.ceil(count / limit), - }, - }); -}); -``` - -### 缓存策略 - -```javascript -// ✅ Redis 缓存示例 -async function getUser(id) { - const cacheKey = `user:${id}`; - - // 1. 检查缓存 - const cached = await redis.get(cacheKey); - if (cached) { - return JSON.parse(cached); - } - - // 2. 查询数据库 - const user = await db.users.findById(id); - - // 3. 写入缓存(设置过期时间) - await redis.setex(cacheKey, 3600, JSON.stringify(user)); - - return user; -} - -// ✅ HTTP 缓存头 -app.get('/static-data', (req, res) => { - res.set({ - 'Cache-Control': 'public, max-age=86400', // 24 小时 - 'ETag': 'abc123', - }); - res.json(data); -}); -``` - -### 响应压缩 - -```javascript -// ✅ 启用 Gzip/Brotli 压缩 -const compression = require('compression'); -app.use(compression()); - -// ✅ 只返回必要字段 -// 请求: GET /users?fields=id,name,email -app.get('/users', async (req, res) => { - const fields = req.query.fields?.split(',') || ['id', 'name']; - const users = await User.findAll({ - attributes: fields, - }); - res.json(users); -}); -``` - -### 限流保护 - -```javascript -// ✅ 速率限制 -const rateLimit = require('express-rate-limit'); - -const limiter = rateLimit({ - windowMs: 60 * 1000, // 1 分钟 - max: 100, // 最多 100 次请求 - message: { error: 'Too many requests, please try again later.' }, -}); - -app.use('/api/', limiter); -``` - -### API 审查清单 - -```markdown -- [ ] 列表接口是否有分页? -- [ ] 是否限制了每页最大数量? -- [ ] 热点数据是否有缓存? -- [ ] 是否启用了响应压缩? -- [ ] 是否有速率限制? -- [ ] 是否只返回必要字段? -``` - ---- - -## 算法复杂度 - -### 常见复杂度对比 - -| 复杂度 | 名称 | 10 条 | 1000 条 | 100 万条 | 示例 | -|--------|------|-------|---------|----------|------| -| O(1) | 常数 | 1 | 1 | 1 | 哈希查找 | -| O(log n) | 对数 | 3 | 10 | 20 | 二分查找 | -| O(n) | 线性 | 10 | 1000 | 100 万 | 遍历数组 | -| O(n log n) | 线性对数 | 33 | 10000 | 2000 万 | 快速排序 | -| O(n²) | 平方 | 100 | 100 万 | 1 万亿 | 嵌套循环 | -| O(2ⁿ) | 指数 | 1024 | ∞ | ∞ | 递归斐波那契 | - -### 代码审查中的识别 - -```javascript -// ❌ O(n²) - 嵌套循环 -function findDuplicates(arr) { - const duplicates = []; - for (let i = 0; i < arr.length; i++) { - for (let j = i + 1; j < arr.length; j++) { - if (arr[i] === arr[j]) { - duplicates.push(arr[i]); - } - } - } - return duplicates; -} - -// ✅ O(n) - 使用 Set -function findDuplicates(arr) { - const seen = new Set(); - const duplicates = new Set(); - for (const item of arr) { - if (seen.has(item)) { - duplicates.add(item); - } - seen.add(item); - } - return [...duplicates]; -} -``` - -```javascript -// ❌ O(n²) - 每次循环都调用 includes -function removeDuplicates(arr) { - const result = []; - for (const item of arr) { - if (!result.includes(item)) { // includes 是 O(n) - result.push(item); - } - } - return result; -} - -// ✅ O(n) - 使用 Set -function removeDuplicates(arr) { - return [...new Set(arr)]; -} -``` - -```javascript -// ❌ O(n) 查找 - 每次都遍历 -const users = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }, ...]; - -function getUser(id) { - return users.find(u => u.id === id); // O(n) -} - -// ✅ O(1) 查找 - 使用 Map -const userMap = new Map(users.map(u => [u.id, u])); - -function getUser(id) { - return userMap.get(id); // O(1) -} -``` - -### 空间复杂度考虑 - -```javascript -// ⚠️ O(n) 空间 - 创建新数组 -const doubled = arr.map(x => x * 2); - -// ✅ O(1) 空间 - 原地修改(如果允许) -for (let i = 0; i < arr.length; i++) { - arr[i] *= 2; -} - -// ⚠️ 递归深度过大可能栈溢出 -function factorial(n) { - if (n <= 1) return 1; - return n * factorial(n - 1); // O(n) 栈空间 -} - -// ✅ 迭代版本 O(1) 空间 -function factorial(n) { - let result = 1; - for (let i = 2; i <= n; i++) { - result *= i; - } - return result; -} -``` - -### 复杂度审查问题 - -```markdown -💡 "这个嵌套循环的复杂度是 O(n²),数据量大时会有性能问题" -🔴 "这里用 Array.includes() 在循环中,整体是 O(n²),建议用 Set" -🟡 "这个递归深度可能导致栈溢出,建议改为迭代或尾递归" -``` - ---- - -## 性能审查清单 - -### 🔴 必须检查(阻塞级) - -**前端:** -- [ ] LCP 图片是否懒加载?(不应该) -- [ ] 是否有 `transition: all`? -- [ ] 是否动画 width/height/top/left? -- [ ] 列表 >100 项是否虚拟化? - -**后端:** -- [ ] 是否存在 N+1 查询? -- [ ] 列表接口是否有分页? -- [ ] 是否有 SELECT * 查大表? - -**通用:** -- [ ] 是否有 O(n²) 或更差的嵌套循环? -- [ ] useEffect/事件监听是否有清理? - -### 🟡 建议检查(重要级) - -**前端:** -- [ ] 是否使用代码分割? -- [ ] 大型库是否按需导入? -- [ ] 图片是否使用 WebP/AVIF? -- [ ] 是否有未使用的依赖? - -**后端:** -- [ ] 热点数据是否有缓存? -- [ ] WHERE 列是否有索引? -- [ ] 是否有慢查询监控? - -**API:** -- [ ] 是否启用响应压缩? -- [ ] 是否有速率限制? -- [ ] 是否只返回必要字段? - -### 🟢 优化建议(建议级) - -- [ ] 是否分析过 bundle 大小? -- [ ] 是否使用 CDN? -- [ ] 是否有性能监控? -- [ ] 是否做过性能基准测试? - ---- - -## 性能度量阈值 - -### 前端指标 - -| 指标 | 好 | 需改进 | 差 | -|------|-----|--------|-----| -| LCP | ≤ 2.5s | 2.5-4s | > 4s | -| INP | ≤ 200ms | 200-500ms | > 500ms | -| CLS | ≤ 0.1 | 0.1-0.25 | > 0.25 | -| FCP | ≤ 1.8s | 1.8-3s | > 3s | -| Bundle Size (JS) | < 200KB | 200-500KB | > 500KB | - -### 后端指标 - -| 指标 | 好 | 需改进 | 差 | -|------|-----|--------|-----| -| API 响应时间 | < 100ms | 100-500ms | > 500ms | -| 数据库查询 | < 50ms | 50-200ms | > 200ms | -| 页面加载 | < 3s | 3-5s | > 5s | - ---- - -## 工具推荐 - -### 前端性能 - -| 工具 | 用途 | -|------|------| -| [Lighthouse](https://developer.chrome.com/docs/lighthouse/) | Core Web Vitals 测试 | -| [WebPageTest](https://www.webpagetest.org/) | 详细性能分析 | -| [webpack-bundle-analyzer](https://github.com/webpack-contrib/webpack-bundle-analyzer) | Bundle 分析 | -| [Chrome DevTools Performance](https://developer.chrome.com/docs/devtools/performance/) | 运行时性能分析 | - -### 内存检测 - -| 工具 | 用途 | -|------|------| -| [MemLab](https://github.com/facebookincubator/memlab) | 自动化内存泄漏检测 | -| Chrome Memory Tab | 堆快照分析 | - -### 后端性能 - -| 工具 | 用途 | -|------|------| -| EXPLAIN | 数据库查询计划分析 | -| [pganalyze](https://pganalyze.com/) | PostgreSQL 性能监控 | -| [New Relic](https://newrelic.com/) / [Datadog](https://www.datadoghq.com/) | APM 监控 | - ---- - -## 参考资源 - -- [Core Web Vitals - web.dev](https://web.dev/articles/vitals) -- [Optimizing Core Web Vitals - Vercel](https://vercel.com/guides/optimizing-core-web-vitals-in-2024) -- [MemLab - Meta Engineering](https://engineering.fb.com/2022/09/12/open-source/memlab/) -- [Big O Cheat Sheet](https://www.bigocheatsheet.com/) -- [N+1 Query Problem - Stack Overflow](https://stackoverflow.com/questions/97197/what-is-the-n1-selects-problem-in-orm-object-relational-mapping) -- [API Performance Optimization](https://algorithmsin60days.com/blog/optimizing-api-performance/) diff --git a/.trellis/spec/review/reference/qt.md b/.trellis/spec/review/reference/qt.md deleted file mode 100644 index 24fef2b5..00000000 --- a/.trellis/spec/review/reference/qt.md +++ /dev/null @@ -1,186 +0,0 @@ -# Qt Code Review Guide - -> Code review guidelines focusing on object model, signals/slots, event loop, and GUI performance. Examples based on Qt 5.15 / Qt 6. - -## Table of Contents - -- [Object Model & Memory Management](#object-model--memory-management) -- [Signals & Slots](#signals--slots) -- [Containers & Strings](#containers--strings) -- [Threads & Concurrency](#threads--concurrency) -- [GUI & Widgets](#gui--widgets) -- [Meta-Object System](#meta-object-system) -- [Review Checklist](#review-checklist) - ---- - -## Object Model & Memory Management - -### Use Parent-Child Ownership Mechanism -Qt's `QObject` hierarchy automatically manages memory. For `QObject`, prefer setting a parent object over manual `delete` or smart pointers. - -```cpp -// ❌ Manual management prone to memory leaks -QWidget* w = new QWidget(); -QLabel* l = new QLabel(); -l->setParent(w); -// ... If w is deleted, l is automatically deleted. But if w leaks, l also leaks. - -// ✅ Specify parent in constructor -QWidget* w = new QWidget(this); // Owned by 'this' -QLabel* l = new QLabel(w); // Owned by 'w' -``` - -### Use Smart Pointers with QObject -If a `QObject` has no parent, use `QScopedPointer` or `std::unique_ptr` with a custom deleter (use `deleteLater` if cross-thread). Avoid `std::shared_ptr` for `QObject` unless necessary, as it confuses the parent-child ownership system. - -```cpp -// ✅ Scoped pointer for local/member QObject without parent -QScopedPointer<MyObject> obj(new MyObject()); - -// ✅ Safe pointer to prevent dangling pointers -QPointer<MyObject> safePtr = obj.data(); -if (safePtr) { - safePtr->doSomething(); -} -``` - -### Use `deleteLater()` -For asynchronous deletion, especially in slots or event handlers, use `deleteLater()` instead of `delete` to ensure pending events in the event loop are processed. - ---- - -## Signals & Slots - -### Prefer Function Pointer Syntax -Use compile-time checked syntax (Qt 5+). - -```cpp -// ❌ String-based (runtime check only, slower) -connect(sender, SIGNAL(valueChanged(int)), receiver, SLOT(updateValue(int))); - -// ✅ Compile-time check -connect(sender, &Sender::valueChanged, receiver, &Receiver::updateValue); -``` - -### Connection Types -Be explicit or aware of connection types when crossing threads. -- `Qt::AutoConnection` (Default): Direct if same thread, Queued if different thread. -- `Qt::QueuedConnection`: Always posts event (thread-safe across threads). -- `Qt::DirectConnection`: Immediate call (dangerous if accessing non-thread-safe data across threads). - -### Avoid Loops -Check logic that might cause infinite signal loops (e.g., `valueChanged` -> `setValue` -> `valueChanged`). Block signals or check for equality before setting values. - -```cpp -void MyClass::setValue(int v) { - if (m_value == v) return; // ? Good: Break loop - m_value = v; - emit valueChanged(v); -} -``` - ---- - -## Containers & Strings - -### QString Efficiency -- Use `QStringLiteral("...")` for compile-time string creation to avoid runtime allocation. -- Use `QLatin1String` for comparison with ASCII literals (in Qt 5). -- Prefer `arg()` for formatting (or `QStringBuilder`'s `%` operator). - -```cpp -// ❌ Runtime conversion -if (str == "test") ... - -// ✅ Prefer QLatin1String for comparison with ASCII literals (in Qt 5) -if (str == QLatin1String("test")) ... // Qt 5 -if (str == u"test"_s) ... // Qt 6 -``` - -### Container Selection -- **Qt 6**: `QList` is now the default choice (unified with `QVector`). -- **Qt 5**: Prefer `QVector` over `QList` for contiguous memory and cache performance, unless stable references are needed. -- Be aware of Implicit Sharing (Copy-on-Write). Passing containers by value is cheap *until* modified. Use `const &` for read-only access. - -```cpp -// ❌ Forces deep copy if function modifies 'list' -void process(QVector<int> list) { - list[0] = 1; -} - -// ✅ Read-only reference -void process(const QVector<int>& list) { ... } -``` - ---- - -## Threads & Concurrency - -### Subclassing QThread vs Worker Object -Prefer the "Worker Object" pattern over subclassing `QThread` implementation details. - -```cpp -// ❌ Business logic inside QThread::run() -class MyThread : public QThread { - void run() override { ... } -}; - -// ✅ Worker object moved to thread -QThread* thread = new QThread; -Worker* worker = new Worker; -worker->moveToThread(thread); -connect(thread, &QThread::started, worker, &Worker::process); -thread->start(); -``` - -### GUI Thread Safety -**NEVER** access UI widgets (`QWidget` and subclasses) from a background thread. Use signals/slots to communicate updates to the main thread. - ---- - -## GUI & Widgets - -### Logic Separation -Keep business logic out of UI classes (`MainWindow`, `Dialog`). UI classes should only handle display and user input forwarding. - -### Layouts -Avoid fixed sizes (`setGeometry`, `resize`). Use layouts (`QVBoxLayout`, `QGridLayout`) to handle different DPIs and window resizing gracefully. - -### Blocking Event Loop -Never execute long-running operations on the main thread (freezes GUI). -- **Bad**: `Sleep()`, `while(busy)`, synchronous network calls. -- **Good**: `QProcess`, `QThread`, `QtConcurrent`, or asynchronous APIs (`QNetworkAccessManager`). - ---- - -## Meta-Object System - -### Properties & Enums -Use `Q_PROPERTY` for values exposed to QML or needing introspection. -Use `Q_ENUM` to enable string conversion for enums. - -```cpp -class MyObject : public QObject { - Q_OBJECT - Q_PROPERTY(int value READ value WRITE setValue NOTIFY valueChanged) -public: - enum State { Idle, Running }; - Q_ENUM(State) - // ... -}; -``` - -### qobject_cast -Use `qobject_cast<T*>` for QObjects instead of `dynamic_cast`. It is faster and doesn't require RTTI. - ---- - -## Review Checklist - -- [ ] **Memory**: Is parent-child relationship correct? Are dangling pointers avoided (using `QPointer`)? -- [ ] **Signals**: Are connections checked? Do lambdas use safe captures (context object)? -- [ ] **Threads**: Is UI accessed only from main thread? Are long tasks offloaded? -- [ ] **Strings**: Are `QStringLiteral` or `tr()` used appropriately? -- [ ] **Style**: Naming conventions (camelCase for methods, PascalCase for classes). -- [ ] **Resources**: Are resources (images, styles) loaded from `.qrc`? \ No newline at end of file diff --git a/.trellis/spec/review/reference/security-review-guide.md b/.trellis/spec/review/reference/security-review-guide.md deleted file mode 100644 index 80d10bc0..00000000 --- a/.trellis/spec/review/reference/security-review-guide.md +++ /dev/null @@ -1,265 +0,0 @@ -# Security Review Guide - -Security-focused code review checklist based on OWASP Top 10 and best practices. - -## Authentication & Authorization - -### Authentication -- [ ] Passwords hashed with strong algorithm (bcrypt, argon2) -- [ ] Password complexity requirements enforced -- [ ] Account lockout after failed attempts -- [ ] Secure password reset flow -- [ ] Multi-factor authentication for sensitive operations -- [ ] Session tokens are cryptographically random -- [ ] Session timeout implemented - -### Authorization -- [ ] Authorization checks on every request -- [ ] Principle of least privilege applied -- [ ] Role-based access control (RBAC) properly implemented -- [ ] No privilege escalation paths -- [ ] Direct object reference checks (IDOR prevention) -- [ ] API endpoints protected appropriately - -### JWT Security -```typescript -// ❌ Insecure JWT configuration -jwt.sign(payload, 'weak-secret'); - -// ✅ Secure JWT configuration -jwt.sign(payload, process.env.JWT_SECRET, { - algorithm: 'RS256', - expiresIn: '15m', - issuer: 'your-app', - audience: 'your-api' -}); - -// ❌ Not verifying JWT properly -const decoded = jwt.decode(token); // No signature verification! - -// ✅ Verify signature and claims -const decoded = jwt.verify(token, publicKey, { - algorithms: ['RS256'], - issuer: 'your-app', - audience: 'your-api' -}); -``` - -## Input Validation - -### SQL Injection Prevention -```python -# ❌ Vulnerable to SQL injection -query = f"SELECT * FROM users WHERE id = {user_id}" - -# ✅ Use parameterized queries -cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,)) - -# ✅ Use ORM with proper escaping -User.objects.filter(id=user_id) -``` - -### XSS Prevention -```typescript -// ❌ Vulnerable to XSS -element.innerHTML = userInput; - -// ✅ Use textContent for plain text -element.textContent = userInput; - -// ✅ Use DOMPurify for HTML -element.innerHTML = DOMPurify.sanitize(userInput); - -// ✅ React automatically escapes (but watch dangerouslySetInnerHTML) -return <div>{userInput}</div>; // Safe -return <div dangerouslySetInnerHTML={{__html: userInput}} />; // Dangerous! -``` - -### Command Injection Prevention -```python -# ❌ Vulnerable to command injection -os.system(f"convert {filename} output.png") - -# ✅ Use subprocess with list arguments -subprocess.run(['convert', filename, 'output.png'], check=True) - -# ✅ Validate and sanitize input -import shlex -safe_filename = shlex.quote(filename) -``` - -### Path Traversal Prevention -```typescript -// ❌ Vulnerable to path traversal -const filePath = `./uploads/${req.params.filename}`; - -// ✅ Validate and sanitize path -const path = require('path'); -const safeName = path.basename(req.params.filename); -const filePath = path.join('./uploads', safeName); - -// Verify it's still within uploads directory -if (!filePath.startsWith(path.resolve('./uploads'))) { - throw new Error('Invalid path'); -} -``` - -## Data Protection - -### Sensitive Data Handling -- [ ] No secrets in source code -- [ ] Secrets stored in environment variables or secret manager -- [ ] Sensitive data encrypted at rest -- [ ] Sensitive data encrypted in transit (HTTPS) -- [ ] PII handled according to regulations (GDPR, etc.) -- [ ] Sensitive data not logged -- [ ] Secure data deletion when required - -### Configuration Security -```yaml -# ❌ Secrets in config files -database: - password: "super-secret-password" - -# ✅ Reference environment variables -database: - password: ${DATABASE_PASSWORD} -``` - -### Error Messages -```typescript -// ❌ Leaking sensitive information -catch (error) { - return res.status(500).json({ - error: error.stack, // Exposes internal details - query: sqlQuery // Exposes database structure - }); -} - -// ✅ Generic error messages -catch (error) { - logger.error('Database error', { error, userId }); // Log internally - return res.status(500).json({ - error: 'An unexpected error occurred' - }); -} -``` - -## API Security - -### Rate Limiting -- [ ] Rate limiting on all public endpoints -- [ ] Stricter limits on authentication endpoints -- [ ] Per-user and per-IP limits -- [ ] Graceful handling when limits exceeded - -### CORS Configuration -```typescript -// ❌ Overly permissive CORS -app.use(cors({ origin: '*' })); - -// ✅ Restrictive CORS -app.use(cors({ - origin: ['https://your-app.com'], - methods: ['GET', 'POST'], - credentials: true -})); -``` - -### HTTP Headers -```typescript -// Security headers to set -app.use(helmet({ - contentSecurityPolicy: { - directives: { - defaultSrc: ["'self'"], - scriptSrc: ["'self'"], - styleSrc: ["'self'", "'unsafe-inline'"], - } - }, - hsts: { maxAge: 31536000, includeSubDomains: true }, - noSniff: true, - xssFilter: true, - frameguard: { action: 'deny' } -})); -``` - -## Cryptography - -### Secure Practices -- [ ] Using well-established algorithms (AES-256, RSA-2048+) -- [ ] Not implementing custom cryptography -- [ ] Using cryptographically secure random number generation -- [ ] Proper key management and rotation -- [ ] Secure key storage (HSM, KMS) - -### Common Mistakes -```typescript -// ❌ Weak random generation -const token = Math.random().toString(36); - -// ✅ Cryptographically secure random -const crypto = require('crypto'); -const token = crypto.randomBytes(32).toString('hex'); - -// ❌ MD5/SHA1 for passwords -const hash = crypto.createHash('md5').update(password).digest('hex'); - -// ✅ Use bcrypt or argon2 -const bcrypt = require('bcrypt'); -const hash = await bcrypt.hash(password, 12); -``` - -## Dependency Security - -### Checklist -- [ ] Dependencies from trusted sources only -- [ ] No known vulnerabilities (npm audit, cargo audit) -- [ ] Dependencies kept up to date -- [ ] Lock files committed (package-lock.json, Cargo.lock) -- [ ] Minimal dependency usage -- [ ] License compliance verified - -### Audit Commands -```bash -# Node.js -npm audit -npm audit fix - -# Python -pip-audit -safety check - -# Rust -cargo audit - -# General -snyk test -``` - -## Logging & Monitoring - -### Secure Logging -- [ ] No sensitive data in logs (passwords, tokens, PII) -- [ ] Logs protected from tampering -- [ ] Appropriate log retention -- [ ] Security events logged (login attempts, permission changes) -- [ ] Log injection prevented - -```typescript -// ❌ Logging sensitive data -logger.info(`User login: ${email}, password: ${password}`); - -// ✅ Safe logging -logger.info('User login attempt', { email, success: true }); -``` - -## Security Review Severity Levels - -| Severity | Description | Action | -|----------|-------------|--------| -| **Critical** | Immediate exploitation possible, data breach risk | Block merge, fix immediately | -| **High** | Significant vulnerability, requires specific conditions | Block merge, fix before release | -| **Medium** | Moderate risk, defense in depth concern | Should fix, can merge with tracking | -| **Low** | Minor issue, best practice violation | Nice to fix, non-blocking | -| **Info** | Suggestion for improvement | Optional enhancement | diff --git a/.trellis/spec/shared/cpp-conventions.md b/.trellis/spec/shared/cpp-conventions.md deleted file mode 100644 index 97ecb5b5..00000000 --- a/.trellis/spec/shared/cpp-conventions.md +++ /dev/null @@ -1,605 +0,0 @@ -# C++ 编码约定 - -> DDE 应用的 C++17 编码规范和最佳实践。 - ---- - -## 概览 - -本指南定义 DDE 应用的 C++ 编码标准,遵循 C++17 特性和 Qt/DTK 集成。 - ---- - -## C++17 特性使用 - -### 结构化绑定 - -```cpp -// ✅ 推荐 -auto [success, error] = parseResult(); - -// ✅ 推荐 -for (const auto &[key, value] : map) { - use(key, value); -} -``` - -### If 初始化 - -```cpp -// ✅ 推荐 -if (auto result = function(); result.isValid()) { - process(result); -} - -// 等价于 -auto result = function(); -if (result.isValid()) { - process(result); -} -``` - -### Optional - -```cpp -// ✅ 推荐 -std::optional<int> parseNumber(const QString &str) { - bool ok; - int value = str.toInt(&ok); - return ok ? std::optional<int>(value) : std::nullopt; -} - -// 使用 -if (auto value = parseNumber(input)) { - process(*value); -} -``` - ---- - -## RAII 资源管理 - -### 智能指针选择 - -```cpp -// ✅ 推荐: QSharedPointer 共享所有权 -QSharedPointer<Resource> resource = QSharedPointer<Resource>::create(); - -// ✅ 推荐: QScopedPointer 独占所有权 -QScopedPointer<Worker> worker(new Worker()); - -// ✅ 推荐: QWeakPointer 非拥有引用 -QWeakPointer<Resource> weakRef = resource; - -// ❌ 避免: 除非特殊需要,不用 std::unique_ptr 与 Qt 对象混用 -``` - -### Qt 对象父子关系 - -```cpp -// ✅ 推荐: 父子关系自动管理 -DWidget *parent = new DWindow; -DLabel *child = new DLabel("Text", parent); - -// ✅ 推荐: 使用 deleteLater 安全删除 -widget->deleteLater(); -``` - -### 文件资源 - -```cpp -// ✅ 推荐: 使用 QFile 的 RAII -void processFile(const QString &path) { - QFile file(path); - if (!file.open(QIODevice::ReadOnly)) { - return; - } - // 文件自动关闭 -} - -// ✅ 推荐: 临时文件 -QTemporaryFile tempFile; -if (tempFile.open()) { - // 自动删除 -} -``` - ---- - -## Pimpl 模式 - -### 标准 Pimpl - -```cpp -// MyClass.h -class MyClass : public QObject { - Q_OBJECT -public: - explicit MyClass(QObject *parent = nullptr); - ~MyClass(); - - void setValue(int value); - int value() const; - -private: - class Private; - QScopedPointer<Private> d; -}; - -// MyClass.cpp -class MyClass::Private { -public: - int value = 0; - DLabel *label = nullptr; - QTimer *timer = nullptr; -}; - -MyClass::MyClass(QObject *parent) - : QObject(parent) - , d(new Private()) -{ -} - -MyClass::~MyClass() = default; -``` - -### 何时使用 Pimpl - -- **需要 ABI 稳定性** - 公共 API -- **降低编译依赖** - 减少头文件包含 -- **隐藏实现细节** - 不暴露内部结构 - ---- - -## 异常处理 - -### 策略 - -DDE 应用优先使用错误码而非异常,因为 Qt 主要使用错误码模式。 - -```cpp -// ✅ 推荐: 使用 Result 模式 -struct Result { - bool success = false; - QString error; - QVariant data; - - static Result ok(const QVariant &data = {}) { - return {true, QString(), data}; - } - - static Result fail(const QString &error) { - return {false, error, QVariant()}; - } -}; -``` - -### 异常捕获 - -```cpp -// ✅ 推荐: 捕获所有异常 -try { - // 可能抛出异常的代码 -} catch (const std::exception &e) { - qWarning() << "Exception:" << e.what(); - showError(tr("Operation failed")); -} catch (...) { - qWarning() << "Unknown exception"; - showError(tr("Unknown error")); -} -``` - ---- - -## 命名约定 - -> 本规范遵循 Google C++ 风格指南,deepin/DDE 特例外见下文。 - -### 文件命名 - -**遵循 deepin-styleguide 规范:** - -```cpp -// ✅ 正确 - 文件名全小写,使用下划线连接 -class MyWidget; // mywidget.h .cpp -class FileManager; // file_manager.h .cpp -class DMainWindow; // d_main_window.h .cpp - -// ✅ 正确 - 头文件使用 .h,源文件使用 .cpp -mywidget.h / mywidget.cpp - -// ✅ 备选 - 只有在下划线无法使用时才考虑连字符 -my-useful-class.cpp // 适用于某些构建系统不支持下划线的情况 - -// ❌ 错误 - 无连接符,难以阅读 -mywidget.cpp // 不适用 -myusefulclass.cpp - -// ❌ 错误 - 名称不清晰 -MyWidget.h // 大驼峰 -``` - -### 类和函数 - -```cpp -// 类: 大驼峰 -class MyPlugin : public QObject { }; - -// 函数: 小驼峰 -void initializePlugin(); -QString filePath(); -bool isValid(); - -// 成员变量: 见下方 "D-Pointer 模式特殊命名" - -// 静态常量: 下划线分隔 + k 前缀 -static const int kMaxRetries = 3; -static const QString kDefaultPath = "/tmp"; -``` - -### D-Pointer 模式特殊命名 - -> 基于 deepin-styleguide Qt 命名规范。 - -**保留名称**:`d_ptr`, `q_ptr`, `dd_ptr`, `qq_ptr` 保留给 D-Pointer 模式使用。 - -**成员变量命名规则**: - -```cpp -// ✅ 正确 - Private 类中成员变量不加任何前缀 -class MyClassPrivate { -public: - QString name; // 不加 m_ 前缀 - int value; // 不加 m_ 前缀 - DLabel *label; // 不加 m_ 前缀 - static int count; // 不加 m_ 前缀 -}; - -// ✅ 正确 - 普通类中的成员变量使用 m_ 前缀 -class MyClass : public QObject { -private: - QString m_name; // 普通类使用 m_ 前缀 - int m_value; // 普通类使用 m_ 前缀 -}; - -// ✅ 正确 - 结构体成员变量不加前缀(与 Private 类风格一致) -struct ConfigData { - QString name; // 不加前缀 - int value; // 不加前缀 -}; - -// ❌ 错误 - Private 类中错误地使用了 m_ 前缀 -class MyClassPrivate { -public: - QString m_name; // 不应该在 Private 类中使用 m_ 前缀 -}; - -// ❌ 错误 - 普通类中错误地没有使用 m_ 前缀 -class MyClass { -private: - QString name; // 应该使用 m_ 前缀 -}; - -// ❌ 错误 - 使用了保留名称 -QObject *d_ptr; // d_ptr 是保留名称 -QObject *q_ptr; // q_ptr 是保留名称 -``` - -**何时使用 Private 类**: - -```cpp -// 使用 D-Pointer 模式(通过 d-> 访问 Private 类成员) -class MyClass : public QObject { - Q_OBJECT -public: - MyClass(QObject *parent = nullptr); - ~MyClass(); - - QString name() const; - void setName(const QString &name); - -private: - class Private; - QScopedPointer<Private> d; -}; - -// 优势: -// 1. 封装:隐藏实现细节 -// 2. ABI 稳定性:不暴露内部成员 -// 3. 编译速度:减少头文件依赖 - -// 注意:在 D-Pointer 模式中访问 Private 类成员: -// d->name(不加 m_ 前缀) -``` - ---- - -## Const 正确性 - -### 常量正确 - -```cpp -// ✅ 推荐: 不修改成员的函数标记为 const -int value() const { - return m_value; -} - -// ✅ 推荐: 参数可以是 const 的就标记为 const -void process(const QString &text); -void processData(const QByteArray &data); - -// ✅ 推荐: 优先使用 const 引用避免拷贝 -QString processLargeString(const QString &input); -// 而不是 -QString processLargeString(QString input); -``` - -### 避免 mutable - -```cpp -// ❌ 避免: 除非必要,不用 mutable -// mutable 破坏了 const 的语义 -// 如果需要,考虑重新设计 -int value() const { - return ++m_cachedValue; // ❌ mutable -} -``` - ---- - -## 头文件管理 - -> 基于 deepin-styleguide Qt 头文件规范。 - -### 自包含头文件 - -**每个 `.h` 文件必须能够自给自足(self-contained)。** - -```cpp -// ✅ 正确 - 自包含头文件 -// myclass.h -#ifndef MYCLASS_H_ -#define MYCLASS_H_ - -#include <QObject> // 包含所有需要的头文件 - -class MyClass : public QObject { - Q_OBJECT -public: - explicit MyClass(QObject *parent = nullptr); -}; - -#endif // MYCLASS_H_ - -// myclass.cpp -#include "myclass.h" // 可以直接使用,无需额外的 include - -// ❌ 错误 - 头文件依赖其他头文件 -// myclass.h(假设没有 #include <QObject>) -class MyClass : public QObject { // 编译失败!QObject 未定义 - // ... -}; - -// myclass.cpp -#include <QObject> // 需要先包含才能使用 -#include "myclass.h" -``` - -### 头文件保护宏 - -**格式:`<PROJECT>_<PATH>_<FILE>_H_`** - -```cpp -// ✅ 正确 - 基于项目路径生成 -// 项目: myapp -// 路径: src/core -// 文件: myclass - -#ifndef MYAPP_CORE_MYCLASS_H_ -#define MYAPP_CORE_MYCLASS_H_ - -// 代码 - -#endif // MYAPP_CORE_MYCLASS_H_ - -// ✅ 正确 - 更简单的格式 -#ifndef MYCLASS_H_ -#define MYCLASS_H_ - -// 代码 - -#endif // MYCLASS_H_ -``` - -### 前置声明策略 - -**避免使用前置声明,直接使用 `#include`。** - -```cpp -// ❌ 避免 - 前置声明 -class MyClass; // 前置声明 - -void process(MyClass *obj); // 使用前置声明 - -// ✅ 推荐 - 直接 include -#include "myclass.h" - -void process(MyClass *obj); // 直接使用 -``` - -**原因**: -1. 避免隐藏依赖关系 -2. 减少重构时的编译错误 -3. 保持代码可维护性 - -### 内联函数限制 - -**只有不超过 10 行的函数才应定义为内联函数。** - -```cpp -// ✅ 推荐 - 简短函数可以内联 -inline int square(int x) { - return x * x; -} - -// ✅ 推荐 - getter/setter 可以内联 -class MyClass { -public: - int value() const { return m_value; } // 简短,可以内联 - void setValue(int value) { m_value = value; } -}; - -// ❌ 避免 - 复杂函数不应内联 -inline void complexFunction() { // 太长了! - // ... 10+ 行代码 ... -} -``` - -### #include 顺序 - -**使用标准顺序:相关头文件 → C 库 → C++ 库 → 其他库 → 本项目。** - -```cpp -// myclass.cpp - -// 1. 相关头文件(优先位置) -#include "myclass.h" - -// 2. C 系统库 -#include <sys/types.h> -#include <unistd.h> - -// 3. C++ 标准库 -#include <memory> -#include <vector> -#include <algorithm> - -// 4. 其他库(Qt 等) -#include <QObject> -#include <QList> -#include <QDebug> - -// 5. 本项目内头文件 -#include "otherclass.h" -#include "utils.h" -``` - -**好处**: -1. 相关头文件在前,如果缺少依赖会立即发现 -2. 按类别分组,易于阅读和维护 -3. 避免隐藏依赖 - ---- - -## 禁用特性 - -### 禁止使用 - -```cpp -// ❌ 禁止: 原始指针直接 new 没有父对象 -QObject *obj = new QObject(); - -// ❌ 禁止: 裸指针 delete -delete widget; -delete[] array; - -// ❌ 禁止: malloc/free -void *ptr = malloc(100); -free(ptr); - -// ❌ 禁止: C 风格转换 -QObject *obj = (QObject*)ptr; - -// ❌ 禁止: 数组作为原始数组 -char buffer[1024]; -``` - -### 推荐替代 - -```cpp -// ✅ 推荐: 智能指针 -QSharedPointer<Object> obj = QSharedPointer<Object>::create(); - -// ✅ 推荐: Qt 容器 -QVector<int> numbers; -QMap<QString, int> mapping; -QList<QObject*> objects; - -// ✅ 推荐: QByteArray/QByteArray -QByteArray data = QByteArray::fromRawData(ptr, size); -QDataBuffer buffer; -``` - ---- - -## 内存管理 - -### Parent-Child 模式 - -```cpp -// ✅ 推荐: 始终为没有明确所有者的对象设置父对象 -class MyWidget : public DWidget { -public: - explicit MyWidget(QWidget *parent = nullptr) : DWidget(parent) { - m_label = new DLabel(this); // 自动删除 - m_button = new DPushButton(this); // 自动删除 - } -private: - DLabel *m_label; - DPushButton *m_button; -}; -``` - -### 避免循环引用 - -```cpp -// ❌ 避免: Parent 和 Child 相互引用 -class Parent : public QObject { - Q_OBJECT -public: - void setChild(Child *child) { - m_child = child; - child->setParent(this); // 可能导致问题 - } -private: - Child *m_child; -}; - -// ✅ 推荐: 只由 Parent 拥有 Child -class Parent : public QObject { - Q_OBJECT -public: - void setChild(Child *child) { - child->setParent(this); - // 不保存指针 - } -}; - -// 或使用 QWeakPointer -``` - ---- - -## 快速参考 - -| 特性 | 使用 | -|------|------| -| 结构化绑定 | `auto [a, b] = pair;` | -| Optional | `std::optional<int>` | -| 共享所有权 | `QSharedPointer` | -| 独占所有权 | `QScopedPointer` | -| 非拥有引用 | `QWeakPointer` | -| 文件资源 | `QFile` RAII | -| 错误处理 | `Result` 模式 | -| 类命名 | 大驼峰 | -| 函数命名 | 小驼峰 | -| 成员变量 | 普通类 `m_` 前缀,Private 类无前缀 | -| 文件命名 | 小写 + 下划线(`my_widget.h`) | -| 头文件保护 | `<PROJECT>_<PATH>_<FILE>_H_` | - ---- - -## 参考资源 - -- [Google C++ Style Guide](https://google.github.io/styleguide/cppguide.html) - 通用 C++ 规范 -- [deepin-styleguide](https://gitlab.deepin.org/styleguide) - deepin/DDE 特有规范 diff --git a/.trellis/spec/shared/dbus-conventions.md b/.trellis/spec/shared/dbus-conventions.md deleted file mode 100644 index 386adde6..00000000 --- a/.trellis/spec/shared/dbus-conventions.md +++ /dev/null @@ -1,437 +0,0 @@ -# DBus 接口命名规范 - -> DDE 桌面环境 DBus 接口的命名和设计规范。 - ---- - -## 概览 - -本规范定义 DDE 组件间 DBus 通信的标准命名格式,确保接口的一致性和可维护性。 - -遵循 [DBus API Design Guidelines](https://dbus.freedesktop.org/doc/dbus-api-design.html) 并在此基础上定义 deepin/DDE 特有的约定。 - ---- - -## 命名结构 - -### 标准格式 - -DBus 接口由四个部分组成:**服务名**、**对象路径**、**接口**、**方法/属性/信号**。 - -``` -服务名: org.deepin.DDE1.Accounts -对象路径: /org/deepin/dde/accounts # 注意路径中的转换 -接口: org.deepin.DDE1.Accounts -方法: SetBrightness -属性: Volume -信号: BrightnessChanged -``` - -### 命名规则分解 - -| 部分 | 格式 | 示例 | -|------|------|------| -| **域名** | 倒置域名 | `org.deepin`, `org.desktopspec` | -| **项目名** | 大小写混合 + 版本号 | `DDE1`, `Manual1` | -| **组件名** | 大小写混合 | `Accounts`, `Search` | - ---- - -## 服务命名规范 - -### 新格式(推荐) - -```cpp -// ✅ 正确 - 明确的版本号 -org.deepin.DDE1.Accounts -org.deepin.Manual1.Search -org.desktopspec.ConfigManager - -// ✅ 正确 - 多级组件 -org.deepin.DDE1.Calendar.Event -org.deepin.DDE1.FileManager.Mount -``` - -### 旧格式(已废弃) - -```cpp -// ❌ 废弃 - daemon 意义不明确 -com.deepin.daemon.Accounts -com.deepin.daemon.Display - -// ⚠️ 迁移路径 -// 旧: com.deepin.daemon.Accounts -// 新: org.deepin.DDE1.Accounts -``` - ---- - -## 对象路径规范 - -### 命名转换 - -对象路径将服务名中的点号`.`替换为斜杠`/`,并将大写字母转换为小写(除路径中的单词边界)。 - -```cpp -// ✅ 正确 - 标准转换规则 -// 服务名: org.deepin.DDE1.Accounts -// 路径: /org/deepin/dde/accounts - -// 服务名: org.deepin.DDE1.FileManager.Mount -// 路径: /org/deepin/dde/file-manager/mount - -// 服务名: org.deepin.Manual1.Search -// 路径: /org/deepin/manual1/search -``` - -### 路径变体 - -对于有多个实例的对象,可以在路径末尾添加唯一标识。 - -```cpp -// ✅ 正确 - 单个实例 -/org/deepin/dde/accounts - -// ✅ 正确 - 多个实例 -/org/deepin/dde/accounts/user1 -/org/deepin/dde/accounts/user2 - -// ✅ 正确 - 使用 ID -/org/deepin/dde/mount/sd-1234 -``` - ---- - -## 接口命名规范 - -### 基本规则 - -接口名与服务名使用相同的命名格式(大小写混合 + 版本号)。 - -```cpp -// ✅ 正确 -org.deepin.DDE1.Accounts -org.desktopspec.ConfigManager - -// ❌ 错误 - 缺少版本号 -org.deepin.dde.Accounts // 应该是 DDE1 -org.freedesktop.portal // 缺少版本号 -``` - -### 接口分段 - -一个服务可以提供多个接口,接口应该按照功能进行分段。 - -```cpp -// ✅ 正确 - 多接口服务 -org.deepin.DDE1.Calendar.Event // 事件相关 -org.deepin.DDE1.Calendar.Schedule // 日程相关 -org.deepin.DDE1.Calendar.Widget // 窗口部件 -``` - ---- - -## 方法命名规范 - -### 基本规则 - -方法名使用**大小写混合**格式,每个单词首字母大写(PascalCase)。 - -```cpp -// ✅ 正确 -SetBrightness(int brightness) -GetUserInfo(const QString &username) -CreateFolder(const QString &path) - -// ❌ 错误 -set_brightness // 下划线命名 -get_user_info // 下划线命名 -createfolder // 不区分大小写 -``` - -### 命名模式 - -| 模式 | 示例 | 说明 | -|------|------|------| -| Get | `GetVolume()` | 获取值 | -| Set | `SetVolume(int)` | 设置值 | -| Is | `IsValid()` | 检查状态(返回 bool) | -| Has | `HasPermission()` | 检查拥有(返回 bool) | -| Create | `CreateFolder()` | 创建对象 | -| Delete | `DeleteFolder()` | 删除对象 | -| Add | `AddItem()` | 添加元素 | -| Remove | `RemoveItem()` | 移除元素 | - -### 异步方法 - -异步方法应使用明确的命名约定。 - -```cpp -// ✅ 正确 - 异步操作 -void CreateFolderAsync(const QString &path) -void RequestPermissionsAsync() - -// ✅ 正确 - 信号方式 -QDBusPendingCall reply = interface.asyncCall("CreateFolder", path); -``` - ---- - -## 属性命名规范 - -### 基本规则 - -属性名使用**大小写混合**格式,与方法命名风格一致。 - -```cpp -// ✅ 正确 -Volume // 音量 -Brightness // 亮度 -UserName // 用户名 -FilePath // 文件路径 - -// ❌ 错误 -volume // 全小写 -file_path // 下划线 -``` - -### 只读属性 - -只读属性应明确标记。 - -```cpp -// ✅ 正确 - DBus XML 中定义 -<property name="UserName" type="s" access="read" /> - -// ✅ 正确 - QDBusAbstractAdaptor 中 -Q_PROPERTY(QString UserName READ userName) -``` - -### 读写属性 - -读写属性使用标准的 Get/Set 方法。 - -```cpp -// ✅ 正确 -<property name="Volume" type="i" access="readwrite" /> - -// 对应的方法 -int Volume() const; -void SetVolume(int volume); -``` - ---- - -## 信号命名规范 - -### 基本规则 - -信号名使用**大小写混合**格式,通常表示状态变化。 - -```cpp -// ✅ 正确 -VolumeChanged(int newVolume) -BrightnessChanged(int newBrightness) -StatusChanged(int newStatus) - -// ❌ 错误 -volume_changed // 下划线 -OnVolumeChanged // 不要加 On 前缀 -``` - -### 命名模式 - -| 模式 | 示例 | 说明 | -|------|------|------| -| Changed | `VolumeChanged()` | 值变化 | -| Added | `ItemAdded(QString item)` | 添加元素 | -| Removed | `ItemRemoved(QString item)` | 移除元素 | -| Started | `ProcessStarted()` | 开始 | -| Finished | `ProcessFinished()` | 完成 | -| Failed | `ProcessFailed(QString error)` | 失败 | - -### 信号参数 - -信号应包含变化的相关信息。 - -```cpp -// ✅ 正确 - 提供变化后的值 -void VolumeChanged(int volume) -void ItemAdded(const QString &item, int index) - -// ✅ 正确 - 提供完整状态 -void StatusChanged(int oldStatus, int newStatus) - -// ⚠️ 备选 - 简化版本(适用于频繁触发的信号) -void DataChanged() -``` - ---- - -## 完整示例 - -### 服务端代码 - -```cpp -// VolumeService.h -class VolumeService : public QObject, public QDBusAbstractAdaptor { - Q_OBJECT - Q_CLASSINFO("D-Bus Interface", "org.deepin.DDE1.Volume") - Q_PROPERTY(int Volume READ volume WRITE setVolume NOTIFY volumeChanged) - -public: - explicit VolumeService(QObject *parent = nullptr); - - // ✅ 正确 - 方法命名 - int volume() const; - void setVolume(int volume); - bool isMuted() const; - void setMuted(bool muted); - -signals: - // ✅ 正确 - 信号命名 - void VolumeChanged(int volume); - void MutedChanged(bool muted); -}; -``` - -### DBus XML 描述 - -```xml -<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" -"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd"> - -<node> - <!-- ✅ 正确 - 接口定义 --> - <interface name="org.deepin.DDE1.Volume"> - - <!-- ✅ 正确 - 属性定义 --> - <property name="Volume" type="i" access="readwrite" /> - <property name="Muted" type="b" access="readwrite" /> - - <!-- ✅ 正确 - 方法定义 --> - <method name="SetVolume"> - <arg name="volume" type="i" direction="in" /> - </method> - - <method name="GetVolume"> - <arg name="volume" type="i" direction="out" /> - </method> - - <method name="IsMuted"> - <arg name="muted" type="b" direction="out" /> - </method> - - <!-- ✅ 正确 - 信号定义 --> - <signal name="VolumeChanged"> - <arg name="volume" type="i" /> - </signal> - - <signal name="MutedChanged"> - <arg name="muted" type="b" /> - </signal> - - </interface> -</node> -``` - -### 客户端代码 - -```cpp -// 客户端使用 -QDBusInterface iface( - "org.deepin.DDE1.Volume", // ✅ 服务名 - "/org/deepin/dde/volume", // ✅ 对象路径 - "org.deepin.DDE1.Volume", // ✅ 接口 - QDBusConnection::sessionBus() -); - -// ✅ 正确 - 方法调用 -iface.call("SetVolume", 50); -QDBusReply<int> reply = iface.call("GetVolume"); - -// ✅ 正确 - 连接信号 -QDBusConnection::sessionBus().connect( - "org.deepin.DDE1.Volume", - "/org/deepin/dde/volume", - "org.deepin.DDE1.Volume", - "VolumeChanged", - this, - SLOT(onVolumeChanged(int)) -); -``` - ---- - -## 迁移指南 - -### 从旧格式迁移 - -```cpp -// ❌ 旧格式(废弃) -服务名: com.deepin.daemon.Accounts -路径: /com/deepin/daemon/Accounts -接口: com.deepin.daemon.Accounts -方法: SetUserName - -// ✅ 新格式(推荐) -服务名: org.deepin.DDE1.Accounts -路径: /org/deepin/dde/accounts -接口: org.deepin.DDE1.Accounts -方法: SetUserName -``` - -### 迁移步骤 - -1. **评估影响范围**:确认哪些服务仍在使用旧格式 -2. **更新服务实现**:修改服务注册时的名称 -3. **更新客户端代码**:修改接口调用 -4. **提供兼容层**:在过渡期同时支持新旧格式 -5. **通知所有使用者**:发布变更日志 - ---- - -## 快速参考 - -| 元素 | 格式规则 | 正确示例 | -|------|---------|----------| -| 服务名 | `org.domain.Project1.Version` | `org.deepin.DDE1.Accounts` | -| 对象路径 | `/org/domain/project/version/component` | `/org/deepin/dde/accounts` | -| 接口 | 与服务名相同 | `org.deepin.DDE1.Accounts` | -| 方法 | PascalCase | `SetUserName` | -| 属性 | PascalCase | `UserName` | -| 信号 | PascalCase + Changed/Added/Removed | `UserNameChanged` | - ---- - -## 反模式 - -```cpp -// ❌ 反模式 - 不要这样做 - -// 1. 混用命名风格 -void set_volume(int); // 不要用下划线 -void SetuserName(int); // 不要混合大小写 - -// 2. 描述不清的名称 -void DoSomething(int x); // 应该用具体名称 -void Method1(); // 应该有明确含义 - -// 3. 缺少版本号 -org.deepin.dde.accounts // 应该是 DDE1 - -// 4. 不一致的命名 -void SetUserName(); // PascalCase -void get_password(); // 混合了驼峰和下划线 - -// 5. 不必要的前缀 -void OnVolumeChanged(); // 不需要 On 前缀 -``` - ---- - -## 参考资源 - -- [DBus API Design Guidelines](https://dbus.freedesktop.org/doc/dbus-api-design.html) -- [dbus-specification](https://dbus.freedesktop.org/doc/dbus-specification.html) -- [Qt D-Bus Documentation](https://doc.qt.io/qt-6/qdbus.html) diff --git a/.trellis/spec/shared/deepin-terminology.md b/.trellis/spec/shared/deepin-terminology.md deleted file mode 100644 index 8dd5ef80..00000000 --- a/.trellis/spec/shared/deepin-terminology.md +++ /dev/null @@ -1,215 +0,0 @@ -# deepin/DDE 品牌术语规范 - -> 规范 deepin 和 DDE 品牌名称在文档、代码中的正确使用方式。 - ---- - -## 概览 - -本规范定义 deepin 和 DDE 品牌术语的标准使用方式,确保项目内文档和代码的一致性。 - ---- - -## deepin 品牌规范 - -### 基本规则 - -**deepin** 品牌名称在任何文档、图片、代码注释中都应使用**全小写**,即使是段落首字母。 - -```cpp -// ✅ 正确 -// deepin 开源项目 -// deepin desktop environment - -// ❌ 错误 -// Deepin 开源项目 -// DEEPIN 开源项目 -``` - -### 代码中的使用 - -在代码中,deepin 也应使用全小写,除非所在语言的编码规范强制要求全大写或首字母大写。 - -```cpp -// ✅ 正确 -#define DEEPIN_MACRO XXXX // 宏命名遵循编码规范 -const int kDeepinNumber = 1; // 常量命名遵循编码规范 - -// ✅ 正确 - 版权信息 -// Copyright (c) 2021. deepin All rights reserved. -``` - -### 文件名中的使用 - -在文件路径和文件名中,deepin 必须使用全小写。 - -```bash -# ✅ 正确 -/usr/lib/deepin-daemon/dde-system-daemon -/usr/share/deepin/msc/res - -# ❌ 错误 -/usr/share/Deepin/msc/res -/usr/lib/DeepinDaemon -``` - ---- - -## DDE 品牌规范 - -### 基本规则 - -**DDE** 是 Deepin Desktop Environment 的缩写。 - -- **文档中**:使用全大写 `DDE` -- **文件名中**:使用全小写 `dde` - -```cpp -// ✅ 正确 - 文档中 -The DDE is comprised of the Desktop Environment, deepin Window Manager, Control Center, Launcher and Dock. - -// ❌ 错误 - 文档中 -Use dde in other os. // 文档中不允许全小写 -Login to Dde. // 文档中不允许混合大小写 - -// ✅ 正确 - 文件名中 -/usr/lib/deepin-daemon/dde-system-daemon -org.deepin.DDE1.Accounts - -// ❌ 错误 - 文件名中 -/usr/lib/deepin-daemon/DDE-System-Daemon -com.deepin.daemon // 旧命名风格,已废弃 -``` - -### 专有名词 - -**Deepin Desktop Environment** 是专有名词,不要拆开使用或修改大小写。 - -```cpp -// ✅ 正确 -Deepin Desktop Environment - -// ❌ 错误 -deepin Desktop Environment -deepin desktop environment -``` - ---- - -## 文档和注释格式 - -### deepin 风格注释 - -在文档中,应该使用标准化的注释格式标记重要信息。 - -```markdown -<!-- ✅ 正确 - 重要信息标记 --> -> **注意**: deepin 桌面环境使用轻量级设计,避免过度装饰。 -> **警告**: 此操作会导致数据丢失,请谨慎使用。 -> **提示**: 快捷键 Ctrl+S 可以保存当前配置。 - -<!-- ✅ 正确 - 代码注释 --> -// 注意 deepin 品牌名称必须全小写 -// 警告此函数非线程安全 -// 提示缓存会自动清理,无需手动删除 -``` - ---- - -## 项目和文件创建 - -### 新项目创建 - -在创建新项目前,应当检查是否可以扩展现有的 deepin 项目。 - -```bash -# ✅ 正确 - 扩展现有项目 -# 不创建新的项目,而是在现有的组件中添加新功能 -# 例如:在 dde-dock 中添加新插件,而不是创建 d-dock-extensions - -# ✅ 正确 - 创建新项目 -# 当功能无法在现有项目中扩展时,创建新项目 -deepin-new-feature # 全小写 + 连字符 -``` - -### 项目命名 - -deepin 项目应使用**全小写**,单词使用连字符(`-`)连接。 - -```bash -# ✅ 正确 -plymouth-theme-deepin -deepin-font-manager - -# ❌ 错误 -Roboto-Autotest # 大写 -deepin_font_manager # 使用下划线 -``` - -### 应用程序 - -应用程序(桌面文件中)应使用**倒置域名**格式。 - -```ini -# ✅ 正确 - desktop 文件 -[Desktop Entry] -Name=Deepin Music -# 文件: org.deepin.lianliankan.desktop - -# ✅ 正确 - DBus 服务名 -org.deepin.dde-launcher -org.deepin.dde-file-manager - -# ❌ 错误 -deepin-music # 应用程序应该使用倒置域名 -com.deepin.daemon # 旧命名风格,已废弃 -``` - -### 专有名词例外 - -当 deepin/DDE 与其他名词组成专有名词时,可以使用大小写混合。 - -```ini -# ✅ 正确 - 专有名词 -[Desktop Entry] -Name=Deepin Music # 这是一个专有名词,不能拆开 - -# ⚠️ 注意 -这是一个专有名词,在任何情况下都不可以拆开使用。 -``` - ---- - -## DBus 命名规范 - -详见 [DBus 接口命名规范](./dbus-conventions.md)。 - -### 快速参考 - -| 元素 | 格式 | 示例 | -|------|------|------| -| 服务名 | `org.deepin.DDE1.Component` | `org.deepin.DDE1.Accounts` | -| 对象路径 | `/org/deepin/dde/Component1` | `/org/deepin/dde/accounts` | -| 接口 | `org.deepin.DDE1.Component` | `org.deepin.DDE1.Accounts` | -| 方法 | 大小写混合 | `SetBrightness` | -| 属性 | 大小写混合 | `Volume` | -| 信号 | 大小写混合 | `BrightnessChanged` | - ---- - -## 快速参考 - -| 场景 | deepin 格式 | DDE 格式 | -|------|------------|----------| -| 文档/注释 | 全小写 `deepin` | 全大写 `DDE` | -| 文件名 | 全小写 `deepin` | 全小写 `dde` | -| 宏/常量 | 编码规范优先 | 编码规范优先 | -| 专有名词 | `Deepin Desktop Environment` | `Deepin Desktop Environment` | -| 项目命名 | `deepin-font-manager` | `dde-dock` | - ---- - -## 参考资源 - -- [DBus API Design Guidelines](https://dbus.freedesktop.org/doc/dbus-api-design.html) -- [Filesystem Hierarchy Standard (FHS)](https://refspecs.linuxfoundation.org/FHS_3.0/fhs-3.0.html) diff --git a/.trellis/spec/shared/git-conventions.md b/.trellis/spec/shared/git-conventions.md deleted file mode 100644 index 9e6b11e3..00000000 --- a/.trellis/spec/shared/git-conventions.md +++ /dev/null @@ -1,272 +0,0 @@ -# Git 约定 - -> DDE 项目的 Git 提交和分支管理约定。 - ---- - -## 概览 - -本指南定义 DDE 项目的 Git 工作流约定,参考现有 DDE 项目规范。 - ---- - -## 提交信息格式 - -### Conventional Commits - -``` -<type>(<scope>): <subject> - -[body] - -[footer] -``` - -### Type 类型 - -| Type | 描述 | 示例 | -|------|------|------| -| `feat` | 新功能 | feat(filemanager): 添加搜索功能 | -| `fix` | Bug 修复 | fix(dialog): 修复对话框内存泄漏 | -| `docs` | 文档更新 | docs(readme): 更新安装说明 | -| `style` | 代码格式 | style(cpplint): 修复格式问题 | -| `refactor` | 重构 | refactor(model): 简化数据模型 | -| `test` | 测试 | test(unit): 添加文件操作测试 | -| `chore` | 构建/工具 | chore(cmake): 更新 Qt6 支持 | - -### Subject 规则 - -- 使用中文描述 -- 不超过 50 字符 -- 句子末尾不加句号 -- 使用祈使语气开头 - -``` -✅ 好: -feat(filemanager): 添加文件重命名功能 -fix(dialog): 修复空指针崩溃 -style(cmake): 格式化代码 - -❌ 不好: -feat(filemanager): 添加文件重命名功能。 // 有句号 -feat(filemanager): 这是一个新功能 // 过于冗长 -feat(filemanager): new feature added // 应该用中文 -``` - -### Body 规则 - -- 描述做了什么以及为什么 -- 每行不超过 72 字符 -- 可以包含多行 - -``` -feat(filemanager): 添加文件重命名功能 - -- 实现右键菜单重命名选项 -- 支持批量重命名 -- 添加重命名验证 - -相关: issue#123 -``` - ---- - -## 分支命名 - -### 功能分支 - -``` -feature/<short-feature-name> - -示例: -feature/file-search -feature/dark-theme -feature/plugin-api -``` - -### 修复分支 - -``` -fix/<short-bug-description> - -示例: -fix/crash-on-window-close -fix/memory-leak-dialog -fix/dbus-connection-fail -``` - -### 发布分支 - -``` -release/<version> - -示例: -release/1.0.0 -release/1.1.0 -``` - ---- - -## 工作流 - -### 功能开发流程 - -``` -1. 从 main 创建功能分支 - git checkout -b feature/feature-name - -2. 开发功能 - -3. 提交代码 - git add . - git commit -m "feat(scope): description" - -4. 推送到远程 - git push origin feature/feature-name - -5. 创建 Merge Request (MR) - -6. 代码审查通过后合并 -``` - -### Bug 修复流程 - -``` -1. 从 main 创建修复分支 - git checkout -b fix/bug-description - -2. 修复 Bug - -3. 提交代码 - git add . - git commit -m "fix(scope): description" - -4. 推送和创建 MR - -5. 审查通过后合并 -``` - ---- - -## Merge Request 模板 - -### 功能 MR - -```markdown -## 功能描述 - -简要描述这个功能。 - -## 变更内容 - -- 添加了 ... -- 修改了 ... -- 删除了 ... - -## 相关 Issue - -关联 Issue: #123 - -## 测试 - -- [ ] 单元测试通过 -- [ ] 手动测试通过 -- [ ] 代码审查通过 - -## 截图 - -<如果适用,添加截图> -``` - -### Bug 修复 MR - -```markdown -## Bug 描述 - -简要描述 Bug。 - -## 复现步骤 - -1. 步骤 1 -2. 步骤 2 -3. ... - -## 修复方案 - -描述修复方案。 - -## 验证 - -- [ ] Bug 已修复 -- [ ] 没有引入新问题 -- [ ] 代码审查通过 -``` - ---- - -## 版本号 - -### 语义化版本 - -``` -MAJOR.MINOR.PATCH - -示例: -1.0.0 - 初始版本 -1.1.0 - 新功能 (minor) -1.1.1 - Bug 修复 (patch) -2.0.0 - 重大变更 (major) -``` - -### 版本规则 - -| 变更类型 | 版本变更 | -|---------|---------| -| 新功能,向后兼容 | MINOR + 1 | -| Bug 修复 | PATCH + 1 | -| 不兼容的 API 变更 | MAJOR + 1 | - ---- - -## 忽略文件 - -### .gitignore - -```gitignore -# 构建产物 -build/ -*.o -*.so -*.a - -# Qt -*.autosave -*.ui.qml -*.pro.user - -# IDE -.vscode/ -.idea/ -*.swp -*~ - -# 临时文件 -tmp/ -temp/ -*.tmp -*.log -``` - ---- - -## 快速参考 - -| 任务 | 命令 | -|------|------| -| 创建功能分支 | `git checkout -b feature/name` | -| 修改提交 | `git commit -m "fix(scope): desc"` | -| 推送分支 | `git push origin feature/name` | -| 拉取最新 | `git pull origin main` | -| 撤销提交 | `git reset HEAD~1` | -| 修改最近提交 | `git commit --amend` | -| 查看历史 | `git log --oneline -10` | diff --git a/.trellis/spec/shared/index.md b/.trellis/spec/shared/index.md deleted file mode 100644 index 5b3998d0..00000000 --- a/.trellis/spec/shared/index.md +++ /dev/null @@ -1,59 +0,0 @@ -# 共享规范层 - -> 跨层共享的开发规范和约定。 - ---- - -## 规范索引 - -| 文档 | 描述 | 何时阅读 | -|------|------|----------| -| [cpp-conventions.md](cpp-conventions.md) | C++17 编码约定、RAII、智能指针、D-Pointer 命名 | 编写 C++ 代码 | -| [git-conventions.md](git-conventions.md) | Git 提交和分支管理约定 | 提交代码 | -| [internationalization.md](internationalization.md) | 国际化规范、tr() 使用、品牌名称规范 | 添加用户文本 | -| [deepin-terminology.md](deepin-terminology.md) | deepin/DDE 品牌术语规范 | 编写文档/代码 | -| [dbus-conventions.md](dbus-conventions.md) | DBus 接口命名规范 | 使用 DBus | - ---- - -## 开发前检查清单 - -在编写共享代码前: - -- [x] 阅读 [cpp-conventions.md](cpp-conventions.md) 了解 C++17 特性和 RAII -- [ ] 项目是否需要 Qt6/Qt5 双版本支持 -- [ ] 是否涉及国际化 -- [ ] 检查提交信息格式约定 -- [ ] 如果使用 DBus,阅读 [dbus-conventions.md](dbus-conventions.md) -- [ ] 查阅品牌名称规范 [deepin-terminology.md](deepin-terminology.md) - ---- - -## 质量检查 - -共享代码完成后: - -- [ ] C++17 特性正确使用 -- [ ] RAII 资源管理 -- [ ] 智能指针正确使用 -- [ ] D-Pointer 模式命名正确(Private 类不加 m_ 前缀) -- [ ] 文件命名使用小写+下划线 -- [ ] Git 提交信息符合规范 -- [ ] tr() 用于所有面向用户的文本 -- [ ] 代码注释使用英文 -- [ ] 品牌名称使用正确(deepin 小写、DDE 文档大写) - ---- - -## 核心规则摘要 - -| 规则 | 要求 | -|------|------| -| C++ 标准 | C++17 | -| 资源管理 | RAII、smart pointer | -| 文件命名 | 小写+下划线(`my_widget.h`) | -| D-Pointer 命名 | Private 类无前缀、普通类 `m_` 前缀 | -| Git 提交 | conventional commits | -| 国际化 | tr() 用于用户文本 | -| 品牌名称 | deepin 小写、DDE 文档大写 | -| 代码注释 | 英文 | diff --git a/.trellis/spec/shared/internationalization.md b/.trellis/spec/shared/internationalization.md deleted file mode 100644 index fdf16ef7..00000000 --- a/.trellis/spec/shared/internationalization.md +++ /dev/null @@ -1,327 +0,0 @@ -# 国际化规范 - -> DDE 应用的国际化 (i18n) 规范。 - ---- - -## 概览 - -本指南定义 DDE 应用的国际化标准,包括翻译字符串管理、翻译文件组织和处理流程。 - ---- - -## tr() 调用规则 - -### 基本使用 - -```cpp -// ✅ 推荐: 所有面向用户的文本使用 tr() -button->setText(tr("Save")); -dialog->setTitle(tr("Confirm Action")); -label->setText(tr("Hello World")); - -// ❌ 禁止: 硬编码用户可见文本 -button->setText("保存"); // 不会翻译 -``` - -### tr() 参数 - -```cpp -// 无参数 -tr("Save") - -// 上下文参数(用于消除歧义) -tr("File", "Noun - File to open") -tr("File", "Verb - File a document") - -// 数量参数 -tr("%n file(s)", "", count) // 自动处理单复数 -``` - -### 动态文本 - -```cpp -// ✅ 推荐: 使用参数化字符串 -label->setText(tr("%1 files selected").arg(count)); - -// ❌ 避免字符串拼接(可能导致顺序问题) -label->setText(tr("Files selected: ") + QString::number(count)); - -// ✅ 推荐: 复数形式 -label->setText(tr("%n file(s)", "", count)); -// 英文: "1 file" / "2 files" -// 中文: 指定复数形式 -``` - ---- - -## 上下文前缀 - -### DTK 组件 - -`DWIDGET_USE_NAMESPACE` 宏已定义 `tr()` 为 DTK 命名空间,可以使用 `Dtk::Widget::tr()`。 - -```cpp -#include <DApplication> -DWIDGET_USE_NAMESPACE - -// 等价于 Dtk::Widget::tr() -QString text = tr("Save"); -``` - ---- - -## 翻译文件 - -### 文件命名 - -``` -<appname>_<language>.ts - -示例: -myapp_zh_CN.ts // 简体中文 -myapp_en.ts // 英语 -myapp_ja.ts // 日语 -``` - -### CMake 配置 - -```cmake -# 查找翻译工具 -find_package(Qt6LinguistTools REQUIRED) - -# 添加翻译文件 -set(TS_FILES - translations/myapp_zh_CN.ts - translations/myapp_en.ts -) - -# 创建翻译目标 -qt_add_translations(myapp - TS_FILES ${TS_FILES} - SOURCE_TARGETS myapp -) - -# 更新翻译命令 -add_custom_target(update_ts - COMMAND ${Qt6_LUPDATE_EXECUTABLE} - ${CMAKE_CURRENT_SOURCE_DIR}/src - -ts ${TS_FILES} - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} - COMMENT "Updating translation files..." -) - -# 创建翻译命令 -add_custom_target(l_release - COMMAND ${Qt6_LRELEASE_EXECUTABLE} - ${TS_FILES} - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} - COMMENT "Creating translation files..." -) -``` - ---- - -## 翻译组织 - -### 按模块组织 - -``` -translations/ -├── core/ -│ ├── core_zh_CN.ts -│ └── core_en.ts -├── widgets/ -│ ├── widgets_zh_CN.ts -│ └── widgets_en.ts -└── plugin/ - ├── plugin_zh_CN.ts - └── plugin_en.ts -``` - -### 加载翻译 - -```cpp -int main(int argc, char *argv[]) { - DApplication a(argc, argv); - - // 加载翻译 - a.loadTranslator(); - - // 或加载特定翻译 - QTranslator translator; - if (translator.load(":/translations/myapp_zh_CN")) { - a.installTranslator(&translator); - } - - // ... -} -``` - ---- - -## 翻译字符串规则 - -### DO - -- **面向用户的文本** 使用 `tr()` -- **菜单项** 使用 `tr()` -- **对话框** 使用 `tr()` -- **错误消息** 使用 `tr()` -- **按钮文本** 使用 `tr()` - -### DON'T - -```cpp -// ❌ 不要翻译的内容 -// 1. 代码注释 -// Initialize the widget -m_widget = new DWidget(this); - -// 2. 日志 -qDebug() << "Connection failed"; // 日志可以用英文 - -// 3. 技术术语(如果行业通用) -USB, RGB, API, JSON - -// 4. 只在开发环境显示的内容 -DEBUG: "Connection established" -``` - ---- - -## 特殊字符处理 - -### 格式化字符串 - -```cpp -// ✅ 推荐: 使用 %1 占位符 -tr("File: %1").arg(filename); - -// ✅ 推荐: HTML 实体 -tr("Save & Close"); - -// ✅ 推荐: 避免在文本中嵌入变量 -tr("The operation failed: %1").arg(error); -// 而不是 -tr("The operation failed: ") + error; -``` - -### 快捷键 - -```cpp -// ✅ 推荐: 使用 & 标记快捷键 -menu->addAction(tr("&File")); -menu->addAction(tr("&Edit")); -menu->addAction(tr("&View")); - -// 快捷键会显示为带下划线 -``` - ---- - -## 翻译清单 - -### 翻译前 - -- [ ] 所有 `tr()` 调用已添加 -- [ ] 复数形式正确处理 -- [ ] 参数化字符串使用 `%1` 占位符 -- [ ] 上下文需要的地方添加了注释 - -### 翻译后 - -- [ ] 翻译文本符合文化习惯 -- [ ] 变量占位符完整保留 -- [ ] 字符串结束符 `\n` 正确处理 -- [ ] 标签 XML/HTML 正确处理 - ---- - -## 代码注释 - -### 用于翻译上下文 - -```cpp -// Translators: "File" as noun (document), not verb (action) -tr("File"); - -// Translators: Context for ambiguous terms -tr("Server", "Computer that provides services"); -tr("Server", "To serve clients"); - -// Translators: Explain placeholders -// %1 is the number of files -tr("%1 file(s)", "", count); -``` - ---- - -## 品牌名称规范 - -> 详见 [deepin/DDE 品牌术语规范](./deepin-terminology.md)。 - -### deepin 品牌名称 - -**deepin** 品牌名称在任何文档、图片、代码注释中都应使用**全小写**,即使是段落首字母。 - -```cpp -// ✅ 正确 - 文档和注释 -// deepin 桌面环境 -// deepin 开源项目 - -// ✅ 正确 - 版权信息 -// Copyright (c) 2021. deepin All rights reserved. - -// ❌ 错误 -// Deepin 桌面环境 -// DEEPIN 项目 -``` - -### DDE 品牌名称 - -**DDE** 是 Deepin Desktop Environment 的缩写。 - -- **文档中**:使用全大写 `DDE` -- **文件名中**:使用全小写 `dde` - -```cpp -// ✅ 正确 - 文档中 -DDE 桌面环境 (Deepin Desktop Environment) -DDE 启动器、DDE 任务栏 - -// ✅ 正确 - 文件名中 -dde-dock -dde-launcher -org.deepin.DDE1.Accounts - -// ❌ 错误 -dde 桌面环境 // 文档中不应该小写 -DDE-System-Daemon // 文件名不应该大写 -``` - -### 翻译中的品牌名称 - -```cpp -// ✅ 正确 - 品牌名称不应翻译 -tr("deepin desktop environment") -tr("DDE Settings") -tr("deepin Music") - -// ❌ 错误 - 品牌名称不应翻译 -tr("深度桌面环境") // 应该使用英文品牌名 -``` - ---- - -## 快速参考 - -| 任务 | 方法 | -|------|------| -| 用户可见文本 | `tr("Text")` | -| 上下文 | `tr("Text", "Context")` | -| 复数形式 | `tr("%n item(s)", "", count)` | -| 参数 | `tr("Text %1").arg(value)` | -| 创建 TS 文件 | `lupdate src -ts app_zh_CN.ts` | -| 创建 QM 文件 | `lrelease app_zh_CN.ts` | -| 加载翻译 | `a.loadTranslator()` | diff --git a/.trellis/tasks/archive/2026-03/00-bootstrap-guidelines/prd.md b/.trellis/tasks/archive/2026-03/00-bootstrap-guidelines/prd.md deleted file mode 100644 index 8f0976d9..00000000 --- a/.trellis/tasks/archive/2026-03/00-bootstrap-guidelines/prd.md +++ /dev/null @@ -1,127 +0,0 @@ -# Bootstrap: Fill Project Development Guidelines - -## 目的 - -为 util-dfm 项目创建库特定的开发规范。由于项目包含多个独立库(dfm-io、dfm-mount、dfm-search、dfm-burn),每个库有不同的技术栈和编码约定,需要创建特定于每个库的规范文档。 - ---- - -## 已完成工作 - -### 1. 创建库特定规范目录 - -``` -.trellis/spec/ -├── dfm-io/ # dfm-io 库规范 -├── dfm-mount/ # dfm-mount 库规范 -├── dfm-search/ # dfm-search 库规范 -└── dfm-burn/ # dfm-burn 库规范 -``` - -### 2. 创建的规范文档 - -每个库创建了以下关键文档: - -#### dfm-io (GIO/GLib + Qt) -- `index.md` - 规范索引 -- `error-handling.md` - GError + DFMIOErrorCode 错误处理 -- `naming-conventions.md` - 类名、方法名、变量名约定 - -#### dfm-mount (UDisks2 + GDBus + GIO) -- `index.md` - 规范索引 -- `error-handling.md` - UDisks2/GIO/GDBus 错误转换 -- `naming-conventions.md` - 类名、方法名、变量名约定 - -#### dfm-search (Lucene++ + Qt + std::error_code) -- `index.md` - 规范索引 -- `error-handling.md` - std::error_code + ErrorCategory 模式 -- `naming-conventions.md` - m_ 前缀成员变量命名 - -#### dfm-burn (xorriso + Qt) -- `index.md` - 规范索引 -- `error-handling.md` - lastError() 模式 -- `naming-conventions.md` - 类名、方法名约定 - -### 3. 更新主 README - -更新 `.trellis/spec/README.md`,明确说明: -- 四个库的不同技术栈 -- 每个库的错误处理方式 -- 开发前必须选择正确的库规范 - ---- - -## 核心要点 - -### 差异总结 - -| 方面 | dfm-io | dfm-mount | dfm-search | dfm-burn | -|------|--------|-----------|------------|----------| -| **错误处理** | GError + DFMIOErrorCode | DeviceError (多来源转换) | std::error_code | lastError() | -| **智能指针** | QSharedDataPointer, QSharedPointer | UDisksX_autoptr | std::unique_ptr | QScopedPointer, QSharedData | -| **成员变量** | d/q 指针模式 | d/q 指针模式 | m_ 前缀 | 小驼峰 | -| **GLib 集成** | 大量使用 g_autoptr | UDisks2 autoptr | 无 | 无 | -| **线程模型** | GIO 异步回调 | GDBus 异步回调 | QThread + 信号槽 | DirectConnection | - -### 共同规则 - -1. **禁用 signals/slots 关键字**:所有库使用 `Q_SIGNALS`/`Q_SLOTS` 宏 -2. **Qt5/Qt6 双版本支持** -3. **国际化**:用户文本使用 `tr()` - ---- - -## 待完成 - -以下文档可以后续根据需要补充: - -### dfm-io -- `memory-management.md` - P-impl 模式、智能指针、GLib 对象管理 -- `code-patterns.md` - 信号槽、回调、文件组织 -- `signals-and-callbacks.md` - Q_SIGNALS、GIO 信号桥接 - -### dfm-mount -- `memory-management.md` - GLib autoptr、UDisksX_autoptr -- `code-patterns.md` - DBus 集成、GIO 信号桥接 -- `dbus-integration.md` - UDisks2 调用模式 - -### dfm-search -- `memory-management.md` - std::unique_ptr、PIMPL -- `code-patterns.md` - 策略模式、线程模型 -- `signal-threading.md` - 线程间信号通信 - -### dfm-burn -- `memory-management.md` - QScopedPointer、QSharedData -- `code-patterns.md` - 信号连接、DirectConnection - ---- - -## 完成检查清单 - -- [x] 创建库特定规范目录 -- [x] 为每个库创建 index.md -- [x] 为每个库创建 error-handling.md -- [x] 为每个库创建 naming-conventions.md -- [x] 更新主 README 说明库差异 -- [ ] 额外的内存管理文档(可选) -- [ ] 额外的代码模式文档(可选) - ---- - -## 使用方式 - -开发前,根据目标库阅读相应规范: - -```bash -# 开发 dfm-io -cat .trellis/spec/dfm-io/index.md - -# 开发 dfm-mount -cat .trellis/spec/dfm-mount/index.md - -# 开发 dfm-search -cat .trellis/spec/dfm-search/index.md - -# 开发 dfm-burn -cat .trellis/spec/dfm-burn/index.md -``` diff --git a/.trellis/tasks/archive/2026-03/00-bootstrap-guidelines/task.json b/.trellis/tasks/archive/2026-03/00-bootstrap-guidelines/task.json deleted file mode 100644 index 6abed256..00000000 --- a/.trellis/tasks/archive/2026-03/00-bootstrap-guidelines/task.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "id": "00-bootstrap-guidelines", - "name": "Bootstrap Guidelines", - "description": "Fill in project development guidelines for AI agents", - "status": "completed", - "dev_type": "docs", - "priority": "P1", - "creator": "Zhang Sheng", - "assignee": "Zhang Sheng", - "createdAt": "2026-03-31", - "completedAt": "2026-03-31", - "commit": null, - "subtasks": [ - { - "name": "Fill backend guidelines", - "status": "pending" - }, - { - "name": "Fill frontend guidelines", - "status": "pending" - }, - { - "name": "Add code examples", - "status": "pending" - } - ], - "children": [], - "parent": null, - "relatedFiles": [ - ".trellis/spec/backend/", - ".trellis/spec/frontend/" - ], - "notes": "First-time setup task created by trellis init (fullstack project)", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/check.jsonl b/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/check.jsonl deleted file mode 100644 index 26c2ed68..00000000 --- a/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/check.jsonl +++ /dev/null @@ -1,2 +0,0 @@ -{"file": ".claude/commands/trellis/finish-work.md", "reason": "Finish work checklist"} -{"file": ".claude/commands/trellis/check-backend.md", "reason": "Backend check spec"} diff --git a/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/debug.jsonl b/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/debug.jsonl deleted file mode 100644 index 8d2740ff..00000000 --- a/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/debug.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"file": ".claude/commands/trellis/check-backend.md", "reason": "Backend check spec"} diff --git a/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/implement.jsonl b/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/implement.jsonl deleted file mode 100644 index c0abad0a..00000000 --- a/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/implement.jsonl +++ /dev/null @@ -1,5 +0,0 @@ -{"file": ".trellis/workflow.md", "reason": "Project workflow and conventions"} -{"file": ".trellis/spec/backend/index.md", "reason": "Backend development guide"} -{"file": ".trellis/spec/dfm-search/error-handling.md", "reason": "错误处理规范"} -{"file": ".trellis/spec/dfm-search/naming-conventions.md", "reason": "命名规范"} -{"file": ".trellis/spec/shared/cpp-conventions.md", "reason": "C++编码约定"} diff --git a/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/prd.md b/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/prd.md deleted file mode 100644 index ce05a60f..00000000 --- a/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/prd.md +++ /dev/null @@ -1,110 +0,0 @@ -# Add OCR Search Type to dfm-search - -## Goal - -在 dfm-search 库中新增 OCR 搜索类型,支持对图片中识别出的文字进行全文搜索。OCR 搜索作为 Content 搜索的简化版,只支持索引策略,不支持高亮返回。 - -## Requirements - -### 1. 枚举和常量定义 - -- [ ] 在 `SearchType` 枚举中新增 `Ocr` 类型 -- [ ] 在 `lucene_field_names.h` 中新增 `LuceneFieldNames::OcrText` 命名空间 -- [ ] 在 `searcherror.h` 中新增 `OcrSearchErrorCode` 枚举 - -### 2. OCR 搜索模块结构 - -参考 `contentsearch` 目录结构,创建 `ocrtextsearch` 模块: - -``` -src/dfm-search/dfm-search-lib/ocrtextsearch/ -├── ocrtextsearchengine.h/.cpp # OCR 搜索引擎 -├── ocrtextsearchapi.cpp # API 实现 -└── ocrtextstrategies/ - ├── basestrategy.h # 策略基类 - └── indexedstrategy.h/.cpp # 索引策略实现 - -include/dfm-search/dfm-search/ -└── ocrtextsearchapi.h # 公共 API 头文件 -``` - -### 3. 功能实现 - -#### OcrTextIndexedStrategy - -- 参考 `ContentIndexedStrategy` 实现 -- 使用已定义的 OCR 索引路径(`Global::ocrTextIndexDirectory()`) -- 支持 `isFilenameContentMixedAndSearchEnabled` 混合搜索 -- 复用现有的 Lucene 查询工具和分析器 - -#### OcrTextSearchEngine - -- 继承 `GenericSearchEngine` -- 仅支持索引策略 -- 注册到 `SearchFactory` - -#### OcrTextOptionsAPI - -- 参考 `ContentOptionsAPI` 实现 -- 支持混合搜索选项 - -### 4. Lucene 字段定义 - -新增 OCR 字段(与 Content 结构一致): - -```cpp -namespace OcrText { - constexpr const wchar_t kOcrContents[] = L"ocr_contents"; - constexpr const wchar_t kFilename[] = L"filename"; - constexpr const wchar_t kPath[] = L"path"; - constexpr const wchar_t kIsHidden[] = L"is_hidden"; - constexpr const wchar_t kAncestorPaths[] = L"ancestor_paths"; -} -``` - -### 5. 错误码定义 - -新增 OCR 搜索错误码范围:3000-3999 - -```cpp -enum OcrSearchErrorCode { - OcrIndexNotAvailable = 3000, - OcrIndexPathError = 3001, - OcrQueryError = 3002, - // ... -}; -``` - -## Acceptance Criteria - -- [ ] `SearchType::Ocr` 枚举值已添加 -- [ ] OCR 搜索模块目录结构完整 -- [ ] `OcrTextIndexedStrategy` 可执行搜索 -- [ ] 支持文件名+OCR内容混合搜索 -- [ ] 通过工厂创建 OCR 搜索引擎 -- [ ] 编译无错误 -- [ ] 代码风格符合项目规范 - -## Technical Notes - -### OCR 与 Content 的差异 - -| 项目 | Content | OCR | -|------|---------|-----| -| 索引目录 | fulltext-index | ocrtext-index | -| 内容字段 | `contents` | `ocr_contents` | -| 文件类型 | 文档 | 图片 | -| 高亮 | 支持 | 不需要 | - -### 已有接口(可复用) - -- `Global::ocrTextIndexDirectory()` - OCR 索引目录 -- `Global::isOcrTextIndexAvailable()` - 索引可用性检查 -- `Global::isPathInOcrTextIndexDirectory()` - 路径检查 -- `Global::ocrTextIndexVersion()` - 索引版本 - -### 设计原则 - -1. **DRY**: 复用 Content 搜索的查询构建逻辑 -2. **KISS**: OCR 作为简化版,不需要高亮等复杂特性 -3. **YAGNI**: 仅实现索引策略,不预留实时策略扩展 diff --git a/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/task.json b/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/task.json deleted file mode 100644 index 40337be0..00000000 --- a/.trellis/tasks/archive/2026-04/04-08-add-ocr-search/task.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "id": "add-ocr-search", - "name": "add-ocr-search", - "title": "Add OCR search type to dfm-search", - "description": "", - "status": "completed", - "dev_type": null, - "scope": null, - "priority": "P2", - "creator": "Zhang Sheng", - "assignee": "Zhang Sheng", - "createdAt": "2026-04-08", - "completedAt": "2026-04-08", - "branch": null, - "base_branch": "feat_ocr", - "worktree_path": null, - "current_phase": 0, - "next_action": [ - { - "phase": 1, - "action": "implement" - }, - { - "phase": 2, - "action": "check" - }, - { - "phase": 3, - "action": "finish" - }, - { - "phase": 4, - "action": "create-pr" - } - ], - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": null, - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/workflow.md b/.trellis/workflow.md deleted file mode 100644 index d1fe61ea..00000000 --- a/.trellis/workflow.md +++ /dev/null @@ -1,416 +0,0 @@ -# Development Workflow - -> Based on [Effective Harnesses for Long-Running Agents](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents) - ---- - -## Table of Contents - -1. [Quick Start (Do This First)](#quick-start-do-this-first) -2. [Workflow Overview](#workflow-overview) -3. [Session Start Process](#session-start-process) -4. [Development Process](#development-process) -5. [Session End](#session-end) -6. [File Descriptions](#file-descriptions) -7. [Best Practices](#best-practices) - ---- - -## Quick Start (Do This First) - -### Step 0: Initialize Developer Identity (First Time Only) - -> **Multi-developer support**: Each developer/Agent needs to initialize their identity first - -```bash -# Check if already initialized -python3 ./.trellis/scripts/get_developer.py - -# If not initialized, run: -python3 ./.trellis/scripts/init_developer.py <your-name> -# Example: python3 ./.trellis/scripts/init_developer.py cursor-agent -``` - -This creates: -- `.trellis/.developer` - Your identity file (gitignored, not committed) -- `.trellis/workspace/<your-name>/` - Your personal workspace directory - -**Naming suggestions**: -- Human developers: Use your name, e.g., `john-doe` -- Cursor AI: `cursor-agent` or `cursor-<task>` -- Claude Code: `claude-agent` or `claude-<task>` -- iFlow cli: `iflow-agent` or `iflow-<task>` - -### Step 1: Understand Current Context - -```bash -# Get full context in one command -python3 ./.trellis/scripts/get_context.py - -# Or check manually: -python3 ./.trellis/scripts/get_developer.py # Your identity -python3 ./.trellis/scripts/task.py list # Active tasks -git status && git log --oneline -10 # Git state -``` - -### Step 2: Read Project Guidelines [MANDATORY] - -**CRITICAL**: Read guidelines before writing any code: - -```bash -# Read frontend guidelines index (if applicable) -cat .trellis/spec/frontend/index.md - -# Read backend guidelines index (if applicable) -cat .trellis/spec/backend/index.md -``` - -**Why read both?** -- Understand the full project architecture -- Know coding standards for the entire codebase -- See how frontend and backend interact -- Learn the overall code quality requirements - -### Step 3: Before Coding - Read Specific Guidelines (Required) - -Based on your task, read the **detailed** guidelines: - -**Frontend Task**: -```bash -cat .trellis/spec/frontend/hook-guidelines.md # For hooks -cat .trellis/spec/frontend/component-guidelines.md # For components -cat .trellis/spec/frontend/type-safety.md # For types -``` - -**Backend Task**: -```bash -cat .trellis/spec/backend/database-guidelines.md # For DB operations -cat .trellis/spec/backend/type-safety.md # For types -cat .trellis/spec/backend/logging-guidelines.md # For logging -``` - ---- - -## Workflow Overview - -### Core Principles - -1. **Read Before Write** - Understand context before starting -2. **Follow Standards** - [!] **MUST read `.trellis/spec/` guidelines before coding** -3. **Incremental Development** - Complete one task at a time -4. **Record Promptly** - Update tracking files immediately after completion -5. **Document Limits** - [!] **Max 2000 lines per journal document** - -### File System - -``` -.trellis/ -|-- .developer # Developer identity (gitignored) -|-- scripts/ -| |-- __init__.py # Python package init -| |-- common/ # Shared utilities (Python) -| | |-- __init__.py -| | |-- paths.py # Path utilities -| | |-- developer.py # Developer management -| | +-- git_context.py # Git context implementation -| |-- multi_agent/ # Multi-agent pipeline scripts -| | |-- __init__.py -| | |-- start.py # Start worktree agent -| | |-- status.py # Monitor agent status -| | |-- create_pr.py # Create PR -| | +-- cleanup.py # Cleanup worktree -| |-- init_developer.py # Initialize developer identity -| |-- get_developer.py # Get current developer name -| |-- task.py # Manage tasks -| |-- get_context.py # Get session context -| +-- add_session.py # One-click session recording -|-- workspace/ # Developer workspaces -| |-- index.md # Workspace index + Session template -| +-- {developer}/ # Per-developer directories -| |-- index.md # Personal index (with @@@auto markers) -| +-- journal-N.md # Journal files (sequential numbering) -|-- tasks/ # Task tracking -| +-- {MM}-{DD}-{name}/ -| +-- task.json -|-- spec/ # [!] MUST READ before coding -| |-- frontend/ # Frontend guidelines (if applicable) -| | |-- index.md # Start here - guidelines index -| | +-- *.md # Topic-specific docs -| |-- backend/ # Backend guidelines (if applicable) -| | |-- index.md # Start here - guidelines index -| | +-- *.md # Topic-specific docs -| +-- guides/ # Thinking guides -| |-- index.md # Guides index -| |-- cross-layer-thinking-guide.md # Pre-implementation checklist -| +-- *.md # Other guides -+-- workflow.md # This document -``` - ---- - -## Session Start Process - -### Step 1: Get Session Context - -Use the unified context script: - -```bash -# Get all context in one command -python3 ./.trellis/scripts/get_context.py - -# Or get JSON format -python3 ./.trellis/scripts/get_context.py --json -``` - -### Step 2: Read Development Guidelines [!] REQUIRED - -**[!] CRITICAL: MUST read guidelines before writing any code** - -Based on what you'll develop, read the corresponding guidelines: - -**Frontend Development** (if applicable): -```bash -# Read index first, then specific docs based on task -cat .trellis/spec/frontend/index.md -``` - -**Backend Development** (if applicable): -```bash -# Read index first, then specific docs based on task -cat .trellis/spec/backend/index.md -``` - -**Cross-Layer Features**: -```bash -# For features spanning multiple layers -cat .trellis/spec/guides/cross-layer-thinking-guide.md -``` - -### Step 3: Select Task to Develop - -Use the task management script: - -```bash -# List active tasks -python3 ./.trellis/scripts/task.py list - -# Create new task (creates directory with task.json) -python3 ./.trellis/scripts/task.py create "<title>" --slug <task-name> -``` - ---- - -## Development Process - -### Task Development Flow - -``` -1. Create or select task - --> python3 ./.trellis/scripts/task.py create "<title>" --slug <name> or list - -2. Write code according to guidelines - --> Read .trellis/spec/ docs relevant to your task - --> For cross-layer: read .trellis/spec/guides/ - -3. Self-test - --> Run project's lint/test commands (see spec docs) - --> Manual feature testing - -4. Commit code - --> git add <files> - --> git commit -m "type(scope): description" - Format: feat/fix/docs/refactor/test/chore - -5. Record session (one command) - --> python3 ./.trellis/scripts/add_session.py --title "Title" --commit "hash" -``` - -### Code Quality Checklist - -**Must pass before commit**: -- [OK] Lint checks pass (project-specific command) -- [OK] Type checks pass (if applicable) -- [OK] Manual feature testing passes - -**Project-specific checks**: -- See `.trellis/spec/frontend/quality-guidelines.md` for frontend -- See `.trellis/spec/backend/quality-guidelines.md` for backend - ---- - -## Session End - -### One-Click Session Recording - -After code is committed, use: - -```bash -python3 ./.trellis/scripts/add_session.py \ - --title "Session Title" \ - --commit "abc1234" \ - --summary "Brief summary" -``` - -This automatically: -1. Detects current journal file -2. Creates new file if 2000-line limit exceeded -3. Appends session content -4. Updates index.md (sessions count, history table) - -### Pre-end Checklist - -Use `/trellis:finish-work` command to run through: -1. [OK] All code committed, commit message follows convention -2. [OK] Session recorded via `add_session.py` -3. [OK] No lint/test errors -4. [OK] Working directory clean (or WIP noted) -5. [OK] Spec docs updated if needed - ---- - -## File Descriptions - -### 1. workspace/ - Developer Workspaces - -**Purpose**: Record each AI Agent session's work content - -**Structure** (Multi-developer support): -``` -workspace/ -|-- index.md # Main index (Active Developers table) -+-- {developer}/ # Per-developer directory - |-- index.md # Personal index (with @@@auto markers) - +-- journal-N.md # Journal files (sequential: 1, 2, 3...) -``` - -**When to update**: -- [OK] End of each session -- [OK] Complete important task -- [OK] Fix important bug - -### 2. spec/ - Development Guidelines - -**Purpose**: Documented standards for consistent development - -**Structure** (Multi-doc format): -``` -spec/ -|-- frontend/ # Frontend docs (if applicable) -| |-- index.md # Start here -| +-- *.md # Topic-specific docs -|-- backend/ # Backend docs (if applicable) -| |-- index.md # Start here -| +-- *.md # Topic-specific docs -+-- guides/ # Thinking guides - |-- index.md # Start here - +-- *.md # Guide-specific docs -``` - -**When to update**: -- [OK] New pattern discovered -- [OK] Bug fixed that reveals missing guidance -- [OK] New convention established - -### 3. Tasks - Task Tracking - -Each task is a directory containing `task.json`: - -``` -tasks/ -|-- 01-21-my-task/ -| +-- task.json -+-- archive/ - +-- 2026-01/ - +-- 01-15-old-task/ - +-- task.json -``` - -**Commands**: -```bash -python3 ./.trellis/scripts/task.py create "<title>" [--slug <name>] # Create task directory -python3 ./.trellis/scripts/task.py archive <name> # Archive to archive/{year-month}/ -python3 ./.trellis/scripts/task.py list # List active tasks -python3 ./.trellis/scripts/task.py list-archive # List archived tasks -``` - ---- - -## Best Practices - -### [OK] DO - Should Do - -1. **Before session start**: - - Run `python3 ./.trellis/scripts/get_context.py` for full context - - [!] **MUST read** relevant `.trellis/spec/` docs - -2. **During development**: - - [!] **Follow** `.trellis/spec/` guidelines - - For cross-layer features, use `/trellis:check-cross-layer` - - Develop only one task at a time - - Run lint and tests frequently - -3. **After development complete**: - - Use `/trellis:finish-work` for completion checklist - - After fix bug, use `/trellis:break-loop` for deep analysis - - Human commits after testing passes - - Use `add_session.py` to record progress - -### [X] DON'T - Should Not Do - -1. [!] **Don't** skip reading `.trellis/spec/` guidelines -2. [!] **Don't** let journal single file exceed 2000 lines -3. **Don't** develop multiple unrelated tasks simultaneously -4. **Don't** commit code with lint/test errors -5. **Don't** forget to update spec docs after learning something -6. [!] **Don't** execute `git commit` - AI should not commit code - ---- - -## Quick Reference - -### Must-read Before Development - -| Task Type | Must-read Document | -|-----------|-------------------| -| Frontend work | `frontend/index.md` → relevant docs | -| Backend work | `backend/index.md` → relevant docs | -| Cross-Layer Feature | `guides/cross-layer-thinking-guide.md` | - -### Commit Convention - -```bash -git commit -m "type(scope): description" -``` - -**Type**: feat, fix, docs, refactor, test, chore -**Scope**: Module name (e.g., auth, api, ui) - -### Common Commands - -```bash -# Session management -python3 ./.trellis/scripts/get_context.py # Get full context -python3 ./.trellis/scripts/add_session.py # Record session - -# Task management -python3 ./.trellis/scripts/task.py list # List tasks -python3 ./.trellis/scripts/task.py create "<title>" # Create task - -# Slash commands -/trellis:finish-work # Pre-commit checklist -/trellis:break-loop # Post-debug analysis -/trellis:check-cross-layer # Cross-layer verification -``` - ---- - -## Summary - -Following this workflow ensures: -- [OK] Continuity across multiple sessions -- [OK] Consistent code quality -- [OK] Trackable progress -- [OK] Knowledge accumulation in spec docs -- [OK] Transparent team collaboration - -**Core Philosophy**: Read before write, follow standards, record promptly, capture learnings diff --git a/.trellis/workspace/Zhang Sheng/index.md b/.trellis/workspace/Zhang Sheng/index.md deleted file mode 100644 index bc2ff651..00000000 --- a/.trellis/workspace/Zhang Sheng/index.md +++ /dev/null @@ -1,41 +0,0 @@ -# Workspace Index - Zhang Sheng - -> Journal tracking for AI development sessions. - ---- - -## Current Status - -<!-- @@@auto:current-status --> -- **Active File**: `journal-1.md` -- **Total Sessions**: 1 -- **Last Active**: 2026-03-31 -<!-- @@@/auto:current-status --> - ---- - -## Active Documents - -<!-- @@@auto:active-documents --> -| File | Lines | Status | -|------|-------|--------| -| `journal-1.md` | ~108 | Active | -<!-- @@@/auto:active-documents --> - ---- - -## Session History - -<!-- @@@auto:session-history --> -| # | Date | Title | Commits | -|---|------|-------|---------| -| 1 | 2026-03-31 | Bootstrap: Create Library-Specific Guidelines | `d846cbc` | -<!-- @@@/auto:session-history --> - ---- - -## Notes - -- Sessions are appended to journal files -- New journal file created when current exceeds 2000 lines -- Use `add_session.py` to record sessions \ No newline at end of file diff --git a/.trellis/workspace/Zhang Sheng/journal-1.md b/.trellis/workspace/Zhang Sheng/journal-1.md deleted file mode 100644 index 2786824e..00000000 --- a/.trellis/workspace/Zhang Sheng/journal-1.md +++ /dev/null @@ -1,108 +0,0 @@ -# Journal - Zhang Sheng (Part 1) - -> AI development session journal -> Started: 2026-03-31 - ---- - - - -## Session 1: Bootstrap: Create Library-Specific Guidelines - -**Date**: 2026-03-31 -**Task**: Bootstrap: Create Library-Specific Guidelines - -### Summary - -(Add summary) - -### Main Changes - -# Bootstrap Guidelines - 创建库特定开发规范 - -## 任务目标 -为 util-dfm 项目创建库特定的开发规范,替换通用的 DDE 桌面应用规范。 - -## 为什么需要库特定规范 -util-dfm 包含 4 个独立库,每个库有不同的技术栈和编码约定: -- **dfm-io**: GIO/GLib + Qt -- **dfm-mount**: UDisks2 + GDBus + GIO -- **dfm-search**: Lucene++ + Qt + std::error_code -- **dfm-burn**: xorriso + Qt - -## 完成的工作 - -### 1. 创建库特定规范目录 -``` -.trellis/spec/ -├── dfm-io/ # dfm-io 库规范 -├── dfm-mount/ # dfm-mount 库规范 -├── dfm-search/ # dfm-search 库规范 -└── dfm-burn/ # dfm-burn 库规范 -``` - -### 2. 为每个库创建规范文档 - -#### dfm-io (GIO/GLib + Qt) -- `index.md` - 规范索引 -- `error-handling.md` - GError 转换、DFMIOErrorCode 使用 -- `naming-conventions.md` - D 前缀类名、k 前缀枚举、d/q 指针模式 - -#### dfm-mount (UDisks2 + GDBus + GIO) -- `index.md` - 规范索引 -- `error-handling.md` - DeviceError 多来源转换、GError 处理 -- `naming-conventions.md` - d/q 指针模式、static 回调函数 - -#### dfm-search (Lucene++ + Qt + std::error_code) -- `index.md` - 规范索引 -- `error-handling.md` - std::error_code + ErrorCategory 模式 -- `naming-conventions.md` - m_ 前缀成员变量、策略模式命名 - -#### dfm-burn (xorriso + Qt) -- `index.md` - 规范索引 -- `error-handling.md` - lastError() + errorMsg 字符串模式 -- `naming-conventions.md` - is/has 布尔前缀、小驼峰成员变量 - -### 3. 更新主 README -更新 `.trellis/spec/README.md`,明确说明: -- 四个库的不同技术栈 -- 每个库的错误处理方式 -- 开发前必须选择正确的库规范 - -## 关键差异总结 - -| 方面 | dfm-io | dfm-mount | dfm-search | dfm-burn | -|------|--------|-----------|------------|----------| -| **错误处理** | GError / DFMIOErrorCode | DeviceError 转换 | std::error_code | lastError() 字符串 | -| **智能指针** | QSharedDataPointer | UDisksX_autoptr | std::unique_ptr | QScopedPointer | -| **成员变量** | d/q 指针 | d/q 指针 | m_ 前缀 | 小驼峰 | -| **GLib 集成** | g_autoptr 大量使用 | UDisks2 autoptr | 无 | 无 | - -## 共同规则 -- 禁用 signals/slots 关键字,使用 Q_SIGNALS/Q_SLOTS 宏 -- Qt5/Qt6 双版本支持 -- 国际化:用户文本使用 tr() - -## 待完成(可选) -- memory-management.md - 详细的内存管理规范 -- code-patterns.md - 策略模式、线程模型等代码模式 -- dbus-integration.md (dfm-mount) - UDisks2 调用模式 - - -### Git Commits - -| Hash | Message | -|------|---------| -| `d846cbc` | (see git log) | - -### Testing - -- [OK] (Add test results) - -### Status - -[OK] **Completed** - -### Next Steps - -- None - task complete diff --git a/.trellis/workspace/index.md b/.trellis/workspace/index.md deleted file mode 100644 index 427947fc..00000000 --- a/.trellis/workspace/index.md +++ /dev/null @@ -1,123 +0,0 @@ -# Workspace Index - -> Records of all AI Agent work records across all developers - ---- - -## Overview - -This directory tracks records for all developers working with AI Agents on this project. - -### File Structure - -``` -workspace/ -|-- index.md # This file - main index -+-- {developer}/ # Per-developer directory - |-- index.md # Personal index with session history - |-- tasks/ # Task files - | |-- *.json # Active tasks - | +-- archive/ # Archived tasks by month - +-- journal-N.md # Journal files (sequential: 1, 2, 3...) -``` - ---- - -## Active Developers - -| Developer | Last Active | Sessions | Active File | -|-----------|-------------|----------|-------------| -| (none yet) | - | - | - | - ---- - -## Getting Started - -### For New Developers - -Run the initialization script: - -```bash -python3 ./.trellis/scripts/init_developer.py <your-name> -``` - -This will: -1. Create your identity file (gitignored) -2. Create your progress directory -3. Create your personal index -4. Create initial journal file - -### For Returning Developers - -1. Get your developer name: - ```bash - python3 ./.trellis/scripts/get_developer.py - ``` - -2. Read your personal index: - ```bash - cat .trellis/workspace/$(python3 ./.trellis/scripts/get_developer.py)/index.md - ``` - ---- - -## Guidelines - -### Journal File Rules - -- **Max 2000 lines** per journal file -- When limit is reached, create `journal-{N+1}.md` -- Update your personal `index.md` when creating new files - -### Session Record Format - -Each session should include: -- Summary: One-line description -- Main Changes: What was modified -- Git Commits: Commit hashes and messages -- Next Steps: What to do next - ---- - -## Session Template - -Use this template when recording sessions: - -```markdown -## Session {N}: {Title} - -**Date**: YYYY-MM-DD -**Task**: {task-name} - -### Summary - -{One-line summary} - -### Main Changes - -- {Change 1} -- {Change 2} - -### Git Commits - -| Hash | Message | -|------|---------| -| `abc1234` | {commit message} | - -### Testing - -- [OK] {Test result} - -### Status - -[OK] **Completed** / # **In Progress** / [P] **Blocked** - -### Next Steps - -- {Next step 1} -- {Next step 2} -``` - ---- - -**Language**: All documentation must be written in **English**. diff --git a/.trellis/worktree.yaml b/.trellis/worktree.yaml deleted file mode 100644 index 26485608..00000000 --- a/.trellis/worktree.yaml +++ /dev/null @@ -1,47 +0,0 @@ -# Worktree Configuration for Multi-Agent Pipeline -# Used for worktree initialization in multi-agent workflows -# -# All paths are relative to project root - -#------------------------------------------------------------------------------- -# Paths -#------------------------------------------------------------------------------- - -# Worktree storage directory (relative to project root) -worktree_dir: ../trellis-worktrees - -#------------------------------------------------------------------------------- -# Files to Copy -#------------------------------------------------------------------------------- - -# Files to copy to each worktree (each worktree needs independent copy) -# These files contain sensitive info or need worktree-independent config -copy: - # Environment variables (uncomment and customize as needed) - # - .env - # - .env.local - # Workflow config - - .trellis/.developer - -#------------------------------------------------------------------------------- -# Post-Create Hooks -#------------------------------------------------------------------------------- - -# Commands to run after creating worktree -# Executed in worktree directory, in order, abort on failure -post_create: - # Install dependencies (uncomment based on your package manager) - # - npm install - # - pnpm install --frozen-lockfile - # - yarn install --frozen-lockfile - -#------------------------------------------------------------------------------- -# Check Agent Verification (Ralph Loop) -#------------------------------------------------------------------------------- - -# Commands to verify code quality before allowing check agent to finish -# If configured, Ralph Loop will run these commands - all must pass to allow completion -# If not configured or empty, trusts agent's completion markers -verify: - # - pnpm lint - # - pnpm typecheck diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index fdf6ff50..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,18 +0,0 @@ -<!-- TRELLIS:START --> -# Trellis Instructions - -These instructions are for AI assistants working in this project. - -Use the `/trellis:start` command when starting a new session to: -- Initialize your developer identity -- Understand current project context -- Read relevant guidelines - -Use `@/.trellis/` to learn: -- Development workflow (`workflow.md`) -- Project structure guidelines (`spec/`) -- Developer workspace (`workspace/`) - -Keep this managed block so 'trellis update' can refresh the instructions. - -<!-- TRELLIS:END --> From 1145b0c94db3a9aaa98e371133eb772cb824b9fa Mon Sep 17 00:00:00 2001 From: Zhang Sheng <zhangsheng@uniontech.com> Date: Thu, 9 Apr 2026 13:02:55 +0800 Subject: [PATCH 09/21] fix: improve path matching logic for content indexing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing path matching logic using simple string prefix matching was insufficient for accurately determining if a path belongs to indexed directories. This could lead to false positives when paths share common prefixes but are not actually subdirectories. Changes made: 1. Added new helper function isPathInAnyDirectory with proper path normalization 2. Implemented exact path matching for when the path is the indexed directory itself 3. Added proper path separator handling to ensure accurate subdirectory detection 4. Replaced duplicate logic in three different index checking functions with the new utility function The fix ensures that path matching considers both exact directory matches and proper subdirectory relationships with correct path separation. Influence: 1. Test path matching with exact directory paths 2. Verify subdirectory detection with various path depths 3. Test paths that share common prefixes but are not subdirectories 4. Validate path normalization handles trailing slashes correctly 5. Confirm blacklist functionality still works properly fix: 改进内容索引路径匹配逻辑 原有使用简单字符串前缀匹配的逻辑不足以准确判断路径是否属于索引目录,可能 导致路径共享相同前缀但实际并非子目录时出现误判。 具体修改: 1. 新增辅助函数 isPathInAnyDirectory,实现路径规范化处理 2. 当路径就是索引目录本身时实现精确路径匹配 3. 添加正确的路径分隔符处理以确保准确的子目录检测 4. 将三个不同索引检查函数中的重复逻辑替换为新工具函数 此修复确保路径匹配同时考虑精确目录匹配和具有正确路径分隔符的子目录关系。 Influence: 1. 测试精确目录路径的匹配情况 2. 验证不同路径深度的子目录检测 3. 测试共享相同前缀但并非子目录的路径 4. 确认路径规范化正确处理尾部斜杠 5. 验证黑名单功能仍正常工作 --- .../dfm-search-lib/utils/searchutility.cpp | 44 +++++++++++++++---- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/src/dfm-search/dfm-search-lib/utils/searchutility.cpp b/src/dfm-search/dfm-search-lib/utils/searchutility.cpp index 93aa37a0..c72ef707 100644 --- a/src/dfm-search/dfm-search-lib/utils/searchutility.cpp +++ b/src/dfm-search/dfm-search-lib/utils/searchutility.cpp @@ -578,14 +578,44 @@ QStringList defaultBlacklistPaths() return pathsFromDConfig; } +/** + * @brief Check if a path is within any of the specified directories. + * @param path The path to check + * @param dirs The directories to check against + * @return true if the path is within any of the directories, false otherwise + */ +static bool isPathInAnyDirectory(const QString &path, const QStringList &dirs) +{ + // Normalize the path once (it doesn't change during iteration) + QString normalizedPath = path; + if (normalizedPath.endsWith('/') && normalizedPath.length() > 1) { + normalizedPath.chop(1); + } + + return std::any_of(dirs.cbegin(), dirs.cend(), [&normalizedPath](const QString &dir) { + QString normalizedDir = dir; + + if (normalizedDir.endsWith('/') && normalizedDir.length() > 1) { + normalizedDir.chop(1); + } + + // Exact match - the path is the indexed directory itself + if (normalizedPath == normalizedDir) { + return true; + } + + // Check if path is within the directory by ensuring proper path separation + const QString dirWithSeparator = normalizedDir + '/'; + return normalizedPath.startsWith(dirWithSeparator); + }); +} + bool isPathInContentIndexDirectory(const QString &path) { if (!isContentIndexAvailable()) return false; - const QStringList &dirs = defaultIndexedDirectory(); - return std::any_of(dirs.cbegin(), dirs.cend(), - [&path](const QString &dir) { return path.startsWith(dir); }); + return isPathInAnyDirectory(path, defaultIndexedDirectory()); } bool isContentIndexAvailable() @@ -642,9 +672,7 @@ bool isPathInOcrTextIndexDirectory(const QString &path) if (!isOcrTextIndexAvailable()) return false; - const QStringList &dirs = defaultIndexedDirectory(); - return std::any_of(dirs.cbegin(), dirs.cend(), - [&path](const QString &dir) { return path.startsWith(dir); }); + return isPathInAnyDirectory(path, defaultIndexedDirectory()); } bool isOcrTextIndexAvailable() @@ -687,9 +715,7 @@ bool isPathInFileNameIndexDirectory(const QString &path) if (BlacklistMatcher::isPathBlacklisted(path, defaultBlacklistPaths())) return false; - const QStringList &dirs = defaultIndexedDirectory(); - return std::any_of(dirs.cbegin(), dirs.cend(), - [&path](const QString &dir) { return path.startsWith(dir); }); + return isPathInAnyDirectory(path, defaultIndexedDirectory()); } bool isFileNameIndexDirectoryAvailable() From 6afea9ff13fd34f2af8458f9681c9328d4a08647 Mon Sep 17 00:00:00 2001 From: Zhang Sheng <zhangsheng@uniontech.com> Date: Sat, 11 Apr 2026 11:01:37 +0800 Subject: [PATCH 10/21] feat: add birth time field name constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Added kBirthTimeTime constant to Lucene field names 2. Included the new field in three namespaces: default, Content and OcrText 3. Enables tracking and searching by file creation/birth time in search functionality Influence: 1. Test search functionality using birth time field 2. Verify file creation time indexing works correctly 3. Check compatibility with existing search queries feat: 添加文件创建时间字段常量 1. 在Lucene字段名称中添加了kBirthTimeTime常量 2. 包含了default、Content和OcrText三个命名空间的字段名称 3. 支持通过文件创建/诞生时间进行搜索的功能 Influence: 1. 测试使用创建时间字段的搜索功能 2. 验证文件创建时间索引是否正确工作 3. 检查与现有搜索查询的兼容性 --- include/dfm-search/dfm-search/field_names.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/include/dfm-search/dfm-search/field_names.h b/include/dfm-search/dfm-search/field_names.h index e033a56f..0acc9692 100644 --- a/include/dfm-search/dfm-search/field_names.h +++ b/include/dfm-search/dfm-search/field_names.h @@ -20,6 +20,7 @@ constexpr const wchar_t kFileNameLower[] = L"file_name_lower"; constexpr const wchar_t kFullPath[] = L"full_path"; constexpr const wchar_t kIsHidden[] = L"is_hidden"; constexpr const wchar_t kModifyTimeStr[] = L"modify_time_str"; +constexpr const wchar_t kBirthTimeTime[] = L"birth_time"; constexpr const wchar_t kFileSizeStr[] = L"file_size_str"; constexpr const wchar_t kPinyin[] = L"pinyin"; constexpr const wchar_t kPinyinAcronym[] = L"pinyin_acronym"; @@ -33,6 +34,7 @@ constexpr const wchar_t kFilename[] = L"filename"; constexpr const wchar_t kPath[] = L"path"; constexpr const wchar_t kIsHidden[] = L"is_hidden"; constexpr const wchar_t kAncestorPaths[] = L"ancestor_paths"; +constexpr const wchar_t kBirthTimeTime[] = L"birth_time"; } // namespace Content // OCR text index field names @@ -42,6 +44,7 @@ constexpr const wchar_t kFilename[] = L"filename"; constexpr const wchar_t kPath[] = L"path"; constexpr const wchar_t kIsHidden[] = L"is_hidden"; constexpr const wchar_t kAncestorPaths[] = L"ancestor_paths"; +constexpr const wchar_t kBirthTimeTime[] = L"birth_time"; } // namespace OcrText } // namespace LuceneFieldNames From e5022733bd84aff2c1f6505861f3885fe8c92a9e Mon Sep 17 00:00:00 2001 From: Zhang Sheng <zhangsheng@uniontech.com> Date: Sat, 11 Apr 2026 15:06:41 +0800 Subject: [PATCH 11/21] refactor: correct field names and add modify_time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Fixed typo in birth_time field name (changed from kBirthTimeTime to kBirthTime) 2. Added new kModifyTime field (numeric timestamp) alongside existing kModifyTimeStr 3. Ensured consistency in field names across different namespaces (Content and OcrText) 4. Maintained backward compatibility while improving naming clarity Log: Modified search index field names for better consistency Influence: 1. Verify existing search queries still work with the corrected field names 2. Test that new modify_time field is properly indexed and searchable 3. Check that time-based searches work correctly with both birth_time and modify_time 4. Validate backward compatibility with existing indexed data refactor: 修正字段名称并添加 modify_time 1. 修正了 birth_time 字段名的拼写错误(从 kBirthTimeTime 改为 kBirthTime) 2. 新增了 kModifyTime 字段(数值时间戳),与现有的 kModifyTimeStr 并存 3. 确保不同命名空间(Content 和 OcrText)中的字段名称保持一致 4. 在提高命名清晰度的同时保持了向后兼容性 Log: 修改搜索索引字段名称以提高一致性 Influence: 1. 验证现有搜索查询仍能使用修正后的字段名正常工作 2. 测试新添加的 modify_time 字段能否正确被索引和搜索 3. 检查时间相关的搜索功能是否能正确处理 birth_time 和 modify_time 4. 验证与已有索引数据的向后兼容性 --- include/dfm-search/dfm-search/field_names.h | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/include/dfm-search/dfm-search/field_names.h b/include/dfm-search/dfm-search/field_names.h index 0acc9692..02c2cdc5 100644 --- a/include/dfm-search/dfm-search/field_names.h +++ b/include/dfm-search/dfm-search/field_names.h @@ -19,8 +19,9 @@ constexpr const wchar_t kFileName[] = L"file_name"; constexpr const wchar_t kFileNameLower[] = L"file_name_lower"; constexpr const wchar_t kFullPath[] = L"full_path"; constexpr const wchar_t kIsHidden[] = L"is_hidden"; +constexpr const wchar_t kModifyTime[] = L"modify_time"; constexpr const wchar_t kModifyTimeStr[] = L"modify_time_str"; -constexpr const wchar_t kBirthTimeTime[] = L"birth_time"; +constexpr const wchar_t kBirthTime[] = L"birth_time"; constexpr const wchar_t kFileSizeStr[] = L"file_size_str"; constexpr const wchar_t kPinyin[] = L"pinyin"; constexpr const wchar_t kPinyinAcronym[] = L"pinyin_acronym"; @@ -34,7 +35,8 @@ constexpr const wchar_t kFilename[] = L"filename"; constexpr const wchar_t kPath[] = L"path"; constexpr const wchar_t kIsHidden[] = L"is_hidden"; constexpr const wchar_t kAncestorPaths[] = L"ancestor_paths"; -constexpr const wchar_t kBirthTimeTime[] = L"birth_time"; +constexpr const wchar_t kBirthTime[] = L"birth_time"; +constexpr const wchar_t kModifyTime[] = L"modify_time"; } // namespace Content // OCR text index field names @@ -44,7 +46,8 @@ constexpr const wchar_t kFilename[] = L"filename"; constexpr const wchar_t kPath[] = L"path"; constexpr const wchar_t kIsHidden[] = L"is_hidden"; constexpr const wchar_t kAncestorPaths[] = L"ancestor_paths"; -constexpr const wchar_t kBirthTimeTime[] = L"birth_time"; +constexpr const wchar_t kBirthTime[] = L"birth_time"; +constexpr const wchar_t kModifyTime[] = L"modify_time"; } // namespace OcrText } // namespace LuceneFieldNames From 2a67f510a215fe14ae8c2fc8a25af96b8345ba53 Mon Sep 17 00:00:00 2001 From: Zhang Sheng <zhangsheng@uniontech.com> Date: Mon, 13 Apr 2026 09:46:00 +0800 Subject: [PATCH 12/21] feat: implement time range filtering for search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Added TimeRangeFilter class with fluent interface for time-based queries 2. Added support for time filtering in indexed and real-time search strategies 3. Implemented TimeField (birth/modify time) and TimeUnit enumerations 4. Added test cases covering all time range combinations and boundary conditions 5. Added time filtering to content search, file name search and OCR text search Log: Added time range filtering support for file searching Influence: 1. Test file search with different time ranges (today, last week, custom range) 2. Verify both creation time and modification time filtering 3. Test boundary conditions for inclusive/exclusive ranges 4. Test combined keyword and time range searches 5. Verify real-time search updates with time filter changes feat: 实现搜索的时间范围过滤功能 1. 添加TimeRangeFilter类,支持流畅接口的时间查询 2. 在索引和实时搜索策略中添加时间过滤支持 3. 实现TimeField(创建/修改时间)和TimeUnit枚举类型 4. 添加覆盖所有时间范围组合和边界条件的测试用例 5. 为内容搜索、文件名搜索和OCR文本搜索增加时间过滤功能 Log: 为文件搜索添加时间范围过滤支持 Influence: 1. 测试不同时间范围的文件搜索(今日、上周、自定义范围) 2. 验证创建时间和修改时间过滤 3. 测试包含/排除边界的条件 4. 测试关键词和时间范围组合搜索 5. 验证实时搜索在时间过滤器更改时的更新 --- autotests/dfm-search-tests/main.cpp | 5 + .../dfm-search-tests/tst_timerangefilter.cpp | 328 ++++++++++++++++++ .../dfm-search/dfm-search/dsearch_global.h | 18 + include/dfm-search/dfm-search/searchoptions.h | 36 ++ .../dfm-search/dfm-search/timerangefilter.h | 260 ++++++++++++++ .../contentstrategies/indexedstrategy.cpp | 33 +- .../dfm-search-lib/core/searchoptions.cpp | 20 ++ .../dfm-search-lib/core/searchoptionsdata.h | 2 + .../dfm-search-lib/core/timerangefilter.cpp | 310 +++++++++++++++++ .../filenamestrategies/indexedstrategy.cpp | 24 ++ .../filenamestrategies/realtimestrategy.cpp | 37 +- .../ocrtextstrategies/indexedstrategy.cpp | 33 +- .../dfm-search-lib/utils/timerangeutils.cpp | 43 +++ .../dfm-search-lib/utils/timerangeutils.h | 47 +++ 14 files changed, 1193 insertions(+), 3 deletions(-) create mode 100644 autotests/dfm-search-tests/tst_timerangefilter.cpp create mode 100644 include/dfm-search/dfm-search/timerangefilter.h create mode 100644 src/dfm-search/dfm-search-lib/core/timerangefilter.cpp create mode 100644 src/dfm-search/dfm-search-lib/utils/timerangeutils.cpp create mode 100644 src/dfm-search/dfm-search-lib/utils/timerangeutils.h diff --git a/autotests/dfm-search-tests/main.cpp b/autotests/dfm-search-tests/main.cpp index a3c00906..2728f95a 100644 --- a/autotests/dfm-search-tests/main.cpp +++ b/autotests/dfm-search-tests/main.cpp @@ -11,6 +11,7 @@ int main(int argc, char *argv[]) // Test object creation functions are defined in their respective .cpp files extern QObject *create_tst_DfmSearch(); extern QObject *create_tst_SearchUtils(); + extern QObject *create_tst_TimeRangeFilter(); // Run all test objects QObject *testObj1 = create_tst_DfmSearch(); @@ -21,5 +22,9 @@ int main(int argc, char *argv[]) result |= QTest::qExec(testObj2, argc, argv); delete testObj2; + QObject *testObj3 = create_tst_TimeRangeFilter(); + result |= QTest::qExec(testObj3, argc, argv); + delete testObj3; + return result; } diff --git a/autotests/dfm-search-tests/tst_timerangefilter.cpp b/autotests/dfm-search-tests/tst_timerangefilter.cpp new file mode 100644 index 00000000..3a61c518 --- /dev/null +++ b/autotests/dfm-search-tests/tst_timerangefilter.cpp @@ -0,0 +1,328 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later +#include <QTest> +#include <QDateTime> +#include <QDate> +#include <QTime> + +#include <dfm-search/timerangefilter.h> + +using namespace DFMSEARCH; + +class tst_TimeRangeFilter : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void initTestCase(); + void cleanupTestCase(); + void default_constructor_test(); + void set_time_field_test(); + void set_last_test(); + void set_today_test(); + void set_yesterday_test(); + void set_this_week_test(); + void set_last_week_test(); + void set_this_month_test(); + void set_last_month_test(); + void set_this_year_test(); + void set_last_year_test(); + void set_range_test(); + void boundary_control_test(); + void clear_test(); + void is_valid_test(); + void resolve_time_range_test(); + void fluent_interface_test(); +}; + +void tst_TimeRangeFilter::initTestCase() +{ +} + +void tst_TimeRangeFilter::cleanupTestCase() +{ +} + +void tst_TimeRangeFilter::default_constructor_test() +{ + TimeRangeFilter filter; + QVERIFY(!filter.isValid()); + QCOMPARE(filter.timeField(), TimeField::ModifyTime); + QVERIFY(filter.includeLower()); + QVERIFY(!filter.includeUpper()); +} + +void tst_TimeRangeFilter::set_time_field_test() +{ + TimeRangeFilter filter; + filter.setTimeField(TimeField::BirthTime); + QCOMPARE(filter.timeField(), TimeField::BirthTime); + + filter.setTimeField(TimeField::ModifyTime); + QCOMPARE(filter.timeField(), TimeField::ModifyTime); +} + +void tst_TimeRangeFilter::set_last_test() +{ + TimeRangeFilter filter; + filter.setLast(3, TimeUnit::Days); + QVERIFY(filter.isValid()); + + auto [start, end] = filter.resolveTimeRange(); + QVERIFY(start.isValid()); + QVERIFY(end.isValid()); + + // "Last 3 days" for setLast: rolling range from 3 days ago (00:00:00) to now + QDateTime now = QDateTime::currentDateTime(); + QDateTime expectedStart = QDateTime(now.date().addDays(-3), QTime(0, 0, 0)); + QCOMPARE(start, expectedStart); + // End should be close to now (within 2 seconds) + QVERIFY(qAbs(end.secsTo(now)) < 2); + + // Test minutes - this is precise + filter.setLast(30, TimeUnit::Minutes); + auto [start2, end2] = filter.resolveTimeRange(); + QVERIFY(start2.isValid()); + QVERIFY(end2.isValid()); + // Minutes should be precise (within 2 seconds tolerance) + QVERIFY(qAbs(start2.secsTo(now.addSecs(-30 * 60))) < 2); + + // Test hours - this is precise + filter.setLast(2, TimeUnit::Hours); + auto [start3, end3] = filter.resolveTimeRange(); + QVERIFY(start3.isValid()); + QVERIFY(end3.isValid()); + // Hours should be precise (within 2 seconds tolerance) + QVERIFY(qAbs(start3.secsTo(now.addSecs(-2 * 3600))) < 2); +} + +void tst_TimeRangeFilter::set_today_test() +{ + TimeRangeFilter filter; + filter.setToday(); + QVERIFY(filter.isValid()); + + auto [start, end] = filter.resolveTimeRange(); + QVERIFY(start.isValid()); + QVERIFY(end.isValid()); + + // Today should start at 00:00:00 + QCOMPARE(start.time(), QTime(0, 0, 0)); + QCOMPARE(start.date(), QDate::currentDate()); + + // End should be tomorrow 00:00:00 + QCOMPARE(end.date(), QDate::currentDate().addDays(1)); + QCOMPARE(end.time(), QTime(0, 0, 0)); +} + +void tst_TimeRangeFilter::set_yesterday_test() +{ + TimeRangeFilter filter; + filter.setYesterday(); + QVERIFY(filter.isValid()); + + auto [start, end] = filter.resolveTimeRange(); + QVERIFY(start.isValid()); + QVERIFY(end.isValid()); + + // Yesterday should start at 00:00:00 + QCOMPARE(start.time(), QTime(0, 0, 0)); + QCOMPARE(start.date(), QDate::currentDate().addDays(-1)); + + // End should be today 00:00:00 + QCOMPARE(end.date(), QDate::currentDate()); + QCOMPARE(end.time(), QTime(0, 0, 0)); +} + +void tst_TimeRangeFilter::set_this_week_test() +{ + TimeRangeFilter filter; + filter.setThisWeek(); + QVERIFY(filter.isValid()); + + auto [start, end] = filter.resolveTimeRange(); + QVERIFY(start.isValid()); + QVERIFY(end.isValid()); + + // Should start on Monday 00:00:00 + QCOMPARE(start.time(), QTime(0, 0, 0)); + QCOMPARE(start.date().dayOfWeek(), 1); // Monday is day 1 +} + +void tst_TimeRangeFilter::set_last_week_test() +{ + TimeRangeFilter filter; + filter.setLastWeek(); + QVERIFY(filter.isValid()); + + auto [start, end] = filter.resolveTimeRange(); + QVERIFY(start.isValid()); + QVERIFY(end.isValid()); + + // Should be a 7-day range + qint64 diff = start.daysTo(end); + QCOMPARE(diff, 7); +} + +void tst_TimeRangeFilter::set_this_month_test() +{ + TimeRangeFilter filter; + filter.setThisMonth(); + QVERIFY(filter.isValid()); + + auto [start, end] = filter.resolveTimeRange(); + QVERIFY(start.isValid()); + QVERIFY(end.isValid()); + + // Should start on the 1st + QCOMPARE(start.date().day(), 1); + QCOMPARE(start.time(), QTime(0, 0, 0)); +} + +void tst_TimeRangeFilter::set_last_month_test() +{ + TimeRangeFilter filter; + filter.setLastMonth(); + QVERIFY(filter.isValid()); + + auto [start, end] = filter.resolveTimeRange(); + QVERIFY(start.isValid()); + QVERIFY(end.isValid()); + + // Should start on the 1st of last month + QCOMPARE(start.date().day(), 1); + QCOMPARE(start.time(), QTime(0, 0, 0)); +} + +void tst_TimeRangeFilter::set_this_year_test() +{ + TimeRangeFilter filter; + filter.setThisYear(); + QVERIFY(filter.isValid()); + + auto [start, end] = filter.resolveTimeRange(); + QVERIFY(start.isValid()); + QVERIFY(end.isValid()); + + // Should start on Jan 1st + QCOMPARE(start.date().month(), 1); + QCOMPARE(start.date().day(), 1); + QCOMPARE(start.time(), QTime(0, 0, 0)); +} + +void tst_TimeRangeFilter::set_last_year_test() +{ + TimeRangeFilter filter; + filter.setLastYear(); + QVERIFY(filter.isValid()); + + auto [start, end] = filter.resolveTimeRange(); + QVERIFY(start.isValid()); + QVERIFY(end.isValid()); + + // Should be previous year + QCOMPARE(start.date().year(), QDate::currentDate().year() - 1); +} + +void tst_TimeRangeFilter::set_range_test() +{ + TimeRangeFilter filter; + QDateTime startDate(QDate(2025, 10, 1), QTime(0, 0, 0)); + QDateTime endDate(QDate(2025, 10, 31), QTime(23, 59, 59)); + + filter.setRange(startDate, endDate); + QVERIFY(filter.isValid()); + + QCOMPARE(filter.startTime(), startDate); + QCOMPARE(filter.endTime(), endDate); + + auto [resolvedStart, resolvedEnd] = filter.resolveTimeRange(); + QCOMPARE(resolvedStart, startDate); + QCOMPARE(resolvedEnd, endDate); +} + +void tst_TimeRangeFilter::boundary_control_test() +{ + TimeRangeFilter filter; + filter.setToday(); + + // Default: includeLower=true, includeUpper=false + QVERIFY(filter.includeLower()); + QVERIFY(!filter.includeUpper()); + + // Test fluent interface + filter.setIncludeLower(false).setIncludeUpper(true); + QVERIFY(!filter.includeLower()); + QVERIFY(filter.includeUpper()); +} + +void tst_TimeRangeFilter::clear_test() +{ + TimeRangeFilter filter; + filter.setToday(); + QVERIFY(filter.isValid()); + + filter.clear(); + QVERIFY(!filter.isValid()); + QCOMPARE(filter.timeField(), TimeField::ModifyTime); + QVERIFY(filter.includeLower()); + QVERIFY(!filter.includeUpper()); +} + +void tst_TimeRangeFilter::is_valid_test() +{ + TimeRangeFilter filter; + QVERIFY(!filter.isValid()); + + filter.setToday(); + QVERIFY(filter.isValid()); + + filter.clear(); + QVERIFY(!filter.isValid()); + + QDateTime start, end; + filter.setRange(start, end); + // Range with invalid datetimes is still "valid" in terms of being set + QVERIFY(filter.isValid()); +} + +void tst_TimeRangeFilter::resolve_time_range_test() +{ + // Test static method + auto [start, end] = TimeRangeFilter::resolveRelativeTimeRange(0, TimeUnit::Days); + QVERIFY(start.isValid()); + QVERIFY(end.isValid()); + QCOMPARE(start.time(), QTime(0, 0, 0)); + + // Test with different units + auto [start2, end2] = TimeRangeFilter::resolveRelativeTimeRange(30, TimeUnit::Minutes); + QVERIFY(start2.isValid()); + QVERIFY(end2.isValid()); +} + +void tst_TimeRangeFilter::fluent_interface_test() +{ + // Test method chaining + TimeRangeFilter filter; + filter.setTimeField(TimeField::BirthTime) + .setLast(7, TimeUnit::Days) + .setIncludeLower(true) + .setIncludeUpper(true); + + QCOMPARE(filter.timeField(), TimeField::BirthTime); + QVERIFY(filter.isValid()); + QVERIFY(filter.includeLower()); + QVERIFY(filter.includeUpper()); + + // Test clear returns reference + filter.clear(); + QVERIFY(!filter.isValid()); +} + +QObject *create_tst_TimeRangeFilter() +{ + return new tst_TimeRangeFilter(); +} + +#include "tst_timerangefilter.moc" diff --git a/include/dfm-search/dfm-search/dsearch_global.h b/include/dfm-search/dfm-search/dsearch_global.h index 4a148c0a..f18892cd 100644 --- a/include/dfm-search/dfm-search/dsearch_global.h +++ b/include/dfm-search/dfm-search/dsearch_global.h @@ -225,6 +225,24 @@ enum SearchMethod { }; Q_ENUM_NS(SearchMethod) +// Enumeration for time field type +enum class TimeField { + BirthTime, // File creation time + ModifyTime // File modification time +}; +Q_ENUM_NS(TimeField) + +// Enumeration for time unit +enum class TimeUnit { + Minutes, // Minute unit + Hours, // Hour unit + Days, // Day unit + Weeks, // Week unit + Months, // Month unit + Years // Year unit +}; +Q_ENUM_NS(TimeUnit) + DFM_SEARCH_END_NS Q_DECLARE_METATYPE(DFMSEARCH::SearchType); diff --git a/include/dfm-search/dfm-search/searchoptions.h b/include/dfm-search/dfm-search/searchoptions.h index 017171ff..6d22b5c9 100644 --- a/include/dfm-search/dfm-search/searchoptions.h +++ b/include/dfm-search/dfm-search/searchoptions.h @@ -9,6 +9,7 @@ #include <QVariant> #include <dfm-search/dsearch_global.h> +#include <dfm-search/timerangefilter.h> DFM_SEARCH_BEGIN_NS @@ -218,6 +219,41 @@ class SearchOptions */ int batchTime() const; + /** + * @brief Sets the time range filter for search operations. + * + * The time range filter allows filtering search results based on file + * creation time or modification time. Both preset ranges (like "Today", + * "Last 7 days") and custom datetime ranges are supported. + * + * @param filter The TimeRangeFilter to apply + * @sa timeRangeFilter(), hasTimeRangeFilter(), clearTimeRangeFilter() + */ + void setTimeRangeFilter(const TimeRangeFilter &filter); + + /** + * @brief Returns the current time range filter. + * + * @return The current TimeRangeFilter + * @sa setTimeRangeFilter() + */ + TimeRangeFilter timeRangeFilter() const; + + /** + * @brief Checks if a time range filter is set. + * + * @return true if a valid time range filter is set, false otherwise + * @sa setTimeRangeFilter(), clearTimeRangeFilter() + */ + bool hasTimeRangeFilter() const; + + /** + * @brief Clears the time range filter. + * + * @sa setTimeRangeFilter(), hasTimeRangeFilter() + */ + void clearTimeRangeFilter(); + private: std::unique_ptr<SearchOptionsData> d; // PIMPL }; diff --git a/include/dfm-search/dfm-search/timerangefilter.h b/include/dfm-search/dfm-search/timerangefilter.h new file mode 100644 index 00000000..c941022f --- /dev/null +++ b/include/dfm-search/dfm-search/timerangefilter.h @@ -0,0 +1,260 @@ +// SPDX-FileCopyrightText: 2025 - 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later +#ifndef TIMERANGEFILTER_H +#define TIMERANGEFILTER_H + +#include <QDateTime> +#include <QPair> + +#include <dfm-search/dsearch_global.h> + +DFM_SEARCH_BEGIN_NS + +/** + * @brief The TimeRangeFilter class provides time range filtering for search operations. + * + * This class provides a fluent interface for specifying time ranges: + * + * Example usage: + * @code + * // Files modified in last 3 days + * TimeRangeFilter filter; + * filter.setTimeField(TimeField::ModifyTime).setLast(3, TimeUnit::Days); + * + * // Files created today + * TimeRangeFilter filter; + * filter.setTimeField(TimeField::BirthTime).setToday(); + * + * // Custom time range + * TimeRangeFilter filter; + * filter.setTimeField(TimeField::ModifyTime).setRange(startDate, endDate); + * @endcode + */ +class TimeRangeFilter +{ +public: + /** + * @brief Default constructor + * Creates an invalid filter (no time range set) + */ + TimeRangeFilter(); + + /** + * @brief Copy constructor + */ + TimeRangeFilter(const TimeRangeFilter &other) = default; + + /** + * @brief Move constructor + */ + TimeRangeFilter(TimeRangeFilter &&other) noexcept = default; + + /** + * @brief Destructor + */ + ~TimeRangeFilter() = default; + + /** + * @brief Assignment operator + */ + TimeRangeFilter &operator=(const TimeRangeFilter &other) = default; + + /** + * @brief Move assignment operator + */ + TimeRangeFilter &operator=(TimeRangeFilter &&other) noexcept = default; + + // ---------- Time Field ---------- + + /** + * @brief Set the time field to filter on + * @param field The time field (BirthTime or ModifyTime) + * @return Reference to this filter for method chaining + */ + TimeRangeFilter &setTimeField(TimeField field); + + /** + * @brief Get the time field being filtered on + * @return The current time field + */ + TimeField timeField() const; + + // ---------- Relative Time Range (Fluent Interface) ---------- + + /** + * @brief Set a relative time range from now + * @param value The number of time units + * @param unit The time unit (Minutes, Hours, Days, Weeks, Months, Years) + * @return Reference to this filter for method chaining + * + * Example: setLast(3, TimeUnit::Days) means files from 3 days ago to now + */ + TimeRangeFilter &setLast(int value, TimeUnit unit); + + // ---------- Fixed Presets ---------- + + /** + * @brief Set range to today (from 00:00:00 today to 00:00:00 tomorrow) + * @return Reference to this filter for method chaining + */ + TimeRangeFilter &setToday(); + + /** + * @brief Set range to yesterday + * @return Reference to this filter for method chaining + */ + TimeRangeFilter &setYesterday(); + + /** + * @brief Set range to this week (from Monday 00:00:00 to next Monday 00:00:00) + * @return Reference to this filter for method chaining + */ + TimeRangeFilter &setThisWeek(); + + /** + * @brief Set range to last week + * @return Reference to this filter for method chaining + */ + TimeRangeFilter &setLastWeek(); + + /** + * @brief Set range to this month (from 1st day 00:00:00 to 1st day of next month) + * @return Reference to this filter for method chaining + */ + TimeRangeFilter &setThisMonth(); + + /** + * @brief Set range to last month + * @return Reference to this filter for method chaining + */ + TimeRangeFilter &setLastMonth(); + + /** + * @brief Set range to this year (from Jan 1st to Jan 1st of next year) + * @return Reference to this filter for method chaining + */ + TimeRangeFilter &setThisYear(); + + /** + * @brief Set range to last year + * @return Reference to this filter for method chaining + */ + TimeRangeFilter &setLastYear(); + + // ---------- Custom Time Range ---------- + + /** + * @brief Set a custom time range + * @param start The start datetime of the range + * @param end The end datetime of the range + * @return Reference to this filter for method chaining + */ + TimeRangeFilter &setRange(const QDateTime &start, const QDateTime &end); + + /** + * @brief Get the custom start time + * @return The start datetime + */ + QDateTime startTime() const; + + /** + * @brief Get the custom end time + * @return The end datetime + */ + QDateTime endTime() const; + + // ---------- Boundary Control ---------- + + /** + * @brief Set whether the lower bound is inclusive + * @param include true to include the lower bound + * @return Reference to this filter for method chaining + */ + TimeRangeFilter &setIncludeLower(bool include); + + /** + * @brief Set whether the upper bound is inclusive + * @param include true to include the upper bound + * @return Reference to this filter for method chaining + */ + TimeRangeFilter &setIncludeUpper(bool include); + + /** + * @brief Check if lower bound is inclusive + * @return true if lower bound is inclusive + */ + bool includeLower() const; + + /** + * @brief Check if upper bound is inclusive + * @return true if upper bound is inclusive + */ + bool includeUpper() const; + + // ---------- Filter State ---------- + + /** + * @brief Clear the filter (make it invalid) + * @return Reference to this filter for method chaining + */ + TimeRangeFilter &clear(); + + /** + * @brief Check if the filter is valid + * @return true if a time range is set + */ + bool isValid() const; + + /** + * @brief Resolve the actual time range + * Calculates the actual start/end times based on current time for relative ranges. + * @return A pair of (start, end) datetimes + */ + QPair<QDateTime, QDateTime> resolveTimeRange() const; + + /** + * @brief Resolve a relative time range to actual datetime range + * @param value The number of time units + * @param unit The time unit + * @return A pair of (start, end) datetimes + */ + static QPair<QDateTime, QDateTime> resolveRelativeTimeRange(int value, TimeUnit unit); + +private: + /** + * @brief Resolve a fixed unit time range to actual datetime range + * @param value The number of time units (0 = this unit, 1 = last unit, etc.) + * @param unit The time unit + * @return A pair of (start, end) datetimes + */ + static QPair<QDateTime, QDateTime> resolveFixedUnitTimeRange(int value, TimeUnit unit); + + /** + * @brief Internal enum for range mode + */ + enum class RangeMode { + Invalid, // No range set + Relative, // Relative time (setLast) - rolling range from N units ago to now + FixedUnit, // Fixed unit range (yesterday, last week, etc.) - complete unit + Custom // Custom start/end + }; + + TimeField m_field = TimeField::ModifyTime; + RangeMode m_mode = RangeMode::Invalid; + + // For relative/fixed mode + int m_relativeValue = 0; + TimeUnit m_relativeUnit = TimeUnit::Days; + + // For custom mode + QDateTime m_startTime; + QDateTime m_endTime; + + bool m_includeLower = true; + bool m_includeUpper = false; +}; + +DFM_SEARCH_END_NS + +#endif // TIMERANGEFILTER_H diff --git a/src/dfm-search/dfm-search-lib/contentsearch/contentstrategies/indexedstrategy.cpp b/src/dfm-search/dfm-search-lib/contentsearch/contentstrategies/indexedstrategy.cpp index 45ac9d5e..f9161eec 100644 --- a/src/dfm-search/dfm-search-lib/contentsearch/contentstrategies/indexedstrategy.cpp +++ b/src/dfm-search/dfm-search-lib/contentsearch/contentstrategies/indexedstrategy.cpp @@ -17,6 +17,7 @@ #include <lucene++/WildcardQuery.h> #include <dfm-search/field_names.h> +#include <dfm-search/timerangefilter.h> #include "3rdparty/fulltext/chineseanalyzer.h" #include "utils/cancellablecollector.h" @@ -24,6 +25,7 @@ #include "utils/lucenequeryutils.h" #include "utils/searchutility.h" #include "utils/lucene_cancellation_compat.h" +#include "utils/timerangeutils.h" using namespace Lucene; @@ -108,7 +110,36 @@ Lucene::QueryPtr ContentIndexedStrategy::buildLuceneQuery(const SearchQuery &que finalQuery->add(mainQuery, BooleanClause::MUST); finalQuery->add(pathPrefixQuery, BooleanClause::MUST); qInfo() << "Using path prefix query for content search optimization:" << searchPath; - return finalQuery; + mainQuery = finalQuery; + } + } + + // Add time range filter query + if (m_options.hasTimeRangeFilter()) { + TimeRangeFilter filter = m_options.timeRangeFilter(); + auto [start, end] = filter.resolveTimeRange(); + + qint64 startEpoch = TimeRangeUtils::toEpochSecs(start); + qint64 endEpoch = TimeRangeUtils::toEpochSecs(end); + + const wchar_t *fieldName = (filter.timeField() == TimeField::BirthTime) + ? LuceneFieldNames::Content::kBirthTime + : LuceneFieldNames::Content::kModifyTime; + + QueryPtr timeQuery = TimeRangeUtils::buildNumericRangeQuery( + fieldName, startEpoch, endEpoch, + filter.includeLower(), filter.includeUpper()); + + if (timeQuery) { + if (mainQuery) { + BooleanQueryPtr finalQuery = newLucene<BooleanQuery>(); + finalQuery->add(mainQuery, BooleanClause::MUST); + finalQuery->add(timeQuery, BooleanClause::MUST); + mainQuery = finalQuery; + } else { + // Time filter alone is a valid query + mainQuery = timeQuery; + } } } diff --git a/src/dfm-search/dfm-search-lib/core/searchoptions.cpp b/src/dfm-search/dfm-search-lib/core/searchoptions.cpp index 12bd9c36..8620b773 100644 --- a/src/dfm-search/dfm-search-lib/core/searchoptions.cpp +++ b/src/dfm-search/dfm-search-lib/core/searchoptions.cpp @@ -177,4 +177,24 @@ int SearchOptions::batchTime() const return d->batchTimeMs; } +void SearchOptions::setTimeRangeFilter(const TimeRangeFilter &filter) +{ + d->timeRangeFilter = filter; +} + +TimeRangeFilter SearchOptions::timeRangeFilter() const +{ + return d->timeRangeFilter; +} + +bool SearchOptions::hasTimeRangeFilter() const +{ + return d->timeRangeFilter.isValid(); +} + +void SearchOptions::clearTimeRangeFilter() +{ + d->timeRangeFilter.clear(); +} + DFM_SEARCH_END_NS diff --git a/src/dfm-search/dfm-search-lib/core/searchoptionsdata.h b/src/dfm-search/dfm-search-lib/core/searchoptionsdata.h index ddf28f97..b5813887 100644 --- a/src/dfm-search/dfm-search-lib/core/searchoptionsdata.h +++ b/src/dfm-search/dfm-search-lib/core/searchoptionsdata.h @@ -8,6 +8,7 @@ #include <QStringList> #include <dfm-search/dsearch_global.h> +#include <dfm-search/timerangefilter.h> DFM_SEARCH_BEGIN_NS @@ -37,6 +38,7 @@ class SearchOptionsData bool detailedResultsEnabled; ///< Whether to include detailed information in search results int syncSearchTimeoutSecs { 60 }; int batchTimeMs { 1000 }; ///< Batch processing time interval in milliseconds + TimeRangeFilter timeRangeFilter; ///< Time range filter for search }; DFM_SEARCH_END_NS diff --git a/src/dfm-search/dfm-search-lib/core/timerangefilter.cpp b/src/dfm-search/dfm-search-lib/core/timerangefilter.cpp new file mode 100644 index 00000000..c7a200ac --- /dev/null +++ b/src/dfm-search/dfm-search-lib/core/timerangefilter.cpp @@ -0,0 +1,310 @@ +// SPDX-FileCopyrightText: 2025 - 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later +#include <dfm-search/timerangefilter.h> + +#include <QDate> + +DFM_SEARCH_BEGIN_NS + +TimeRangeFilter::TimeRangeFilter() + : m_field(TimeField::ModifyTime) + , m_mode(RangeMode::Invalid) + , m_relativeValue(0) + , m_relativeUnit(TimeUnit::Days) + , m_includeLower(true) + , m_includeUpper(false) +{ +} + +TimeRangeFilter &TimeRangeFilter::setTimeField(TimeField field) +{ + m_field = field; + return *this; +} + +TimeField TimeRangeFilter::timeField() const +{ + return m_field; +} + +TimeRangeFilter &TimeRangeFilter::setLast(int value, TimeUnit unit) +{ + m_mode = RangeMode::Relative; // Rolling range from N units ago to now + m_relativeValue = value; + m_relativeUnit = unit; + m_startTime = QDateTime(); + m_endTime = QDateTime(); + return *this; +} + +TimeRangeFilter &TimeRangeFilter::setToday() +{ + m_mode = RangeMode::FixedUnit; // Complete unit (today) + m_relativeValue = 0; + m_relativeUnit = TimeUnit::Days; + return *this; +} + +TimeRangeFilter &TimeRangeFilter::setYesterday() +{ + m_mode = RangeMode::FixedUnit; // Complete unit (yesterday) + m_relativeValue = 1; + m_relativeUnit = TimeUnit::Days; + return *this; +} + +TimeRangeFilter &TimeRangeFilter::setThisWeek() +{ + m_mode = RangeMode::FixedUnit; + m_relativeValue = 0; + m_relativeUnit = TimeUnit::Weeks; + return *this; +} + +TimeRangeFilter &TimeRangeFilter::setLastWeek() +{ + m_mode = RangeMode::FixedUnit; + m_relativeValue = 1; + m_relativeUnit = TimeUnit::Weeks; + return *this; +} + +TimeRangeFilter &TimeRangeFilter::setThisMonth() +{ + m_mode = RangeMode::FixedUnit; + m_relativeValue = 0; + m_relativeUnit = TimeUnit::Months; + return *this; +} + +TimeRangeFilter &TimeRangeFilter::setLastMonth() +{ + m_mode = RangeMode::FixedUnit; + m_relativeValue = 1; + m_relativeUnit = TimeUnit::Months; + return *this; +} + +TimeRangeFilter &TimeRangeFilter::setThisYear() +{ + m_mode = RangeMode::FixedUnit; + m_relativeValue = 0; + m_relativeUnit = TimeUnit::Years; + return *this; +} + +TimeRangeFilter &TimeRangeFilter::setLastYear() +{ + m_mode = RangeMode::FixedUnit; + m_relativeValue = 1; + m_relativeUnit = TimeUnit::Years; + return *this; +} + +TimeRangeFilter &TimeRangeFilter::setRange(const QDateTime &start, const QDateTime &end) +{ + m_mode = RangeMode::Custom; + m_startTime = start; + m_endTime = end; + m_relativeValue = 0; + m_relativeUnit = TimeUnit::Days; + return *this; +} + +QDateTime TimeRangeFilter::startTime() const +{ + return m_startTime; +} + +QDateTime TimeRangeFilter::endTime() const +{ + return m_endTime; +} + +TimeRangeFilter &TimeRangeFilter::setIncludeLower(bool include) +{ + m_includeLower = include; + return *this; +} + +TimeRangeFilter &TimeRangeFilter::setIncludeUpper(bool include) +{ + m_includeUpper = include; + return *this; +} + +bool TimeRangeFilter::includeLower() const +{ + return m_includeLower; +} + +bool TimeRangeFilter::includeUpper() const +{ + return m_includeUpper; +} + +TimeRangeFilter &TimeRangeFilter::clear() +{ + m_mode = RangeMode::Invalid; + m_startTime = QDateTime(); + m_endTime = QDateTime(); + m_relativeValue = 0; + m_relativeUnit = TimeUnit::Days; + m_includeLower = true; + m_includeUpper = false; + return *this; +} + +bool TimeRangeFilter::isValid() const +{ + return m_mode != RangeMode::Invalid; +} + +QPair<QDateTime, QDateTime> TimeRangeFilter::resolveTimeRange() const +{ + if (m_mode == RangeMode::Custom) { + return qMakePair(m_startTime, m_endTime); + } + + if (m_mode == RangeMode::Relative) { + return resolveRelativeTimeRange(m_relativeValue, m_relativeUnit); + } + + if (m_mode == RangeMode::FixedUnit) { + return resolveFixedUnitTimeRange(m_relativeValue, m_relativeUnit); + } + + return qMakePair(QDateTime(), QDateTime()); +} + +QPair<QDateTime, QDateTime> TimeRangeFilter::resolveRelativeTimeRange(int value, TimeUnit unit) +{ + // Relative mode: from N units ago to now (rolling window) + QDateTime now = QDateTime::currentDateTime(); + QDateTime start; + QDateTime end = now; + + switch (unit) { + case TimeUnit::Minutes: + // "Last N minutes": from N minutes ago to now + start = now.addSecs(-value * 60); + break; + + case TimeUnit::Hours: + // "Last N hours": from N hours ago to now + start = now.addSecs(-value * 3600); + break; + + case TimeUnit::Days: + // "Last N days": from N days ago (00:00:00) to now + start = QDateTime(now.date().addDays(-value), QTime(0, 0, 0)); + break; + + case TimeUnit::Weeks: + // "Last N weeks": from N weeks ago (Monday 00:00:00) to now + { + int daysToMonday = now.date().dayOfWeek() - 1; + QDate thisMonday = now.date().addDays(-daysToMonday); + QDate startMonday = thisMonday.addDays(-value * 7); + start = QDateTime(startMonday, QTime(0, 0, 0)); + } + break; + + case TimeUnit::Months: + // "Last N months": from N months ago (1st day 00:00:00) to now + { + QDate firstOfThisMonth(now.date().year(), now.date().month(), 1); + QDate startMonth = firstOfThisMonth.addMonths(-value); + start = QDateTime(startMonth, QTime(0, 0, 0)); + } + break; + + case TimeUnit::Years: + // "Last N years": from N years ago (Jan 1st 00:00:00) to now + { + QDate startYear(now.date().year() - value, 1, 1); + start = QDateTime(startYear, QTime(0, 0, 0)); + } + break; + } + + return qMakePair(start, end); +} + +QPair<QDateTime, QDateTime> TimeRangeFilter::resolveFixedUnitTimeRange(int value, TimeUnit unit) +{ + // Fixed unit mode: complete unit range (today, yesterday, this week, etc.) + QDateTime now = QDateTime::currentDateTime(); + QDateTime start; + QDateTime end; + + switch (unit) { + case TimeUnit::Minutes: + case TimeUnit::Hours: + // For minutes/hours, fixed unit doesn't make much sense, treat as relative + return resolveRelativeTimeRange(value, unit); + + case TimeUnit::Days: + if (value == 0) { + // Today: from 00:00:00 today to 00:00:00 tomorrow + start = QDateTime(now.date(), QTime(0, 0, 0)); + end = QDateTime(now.date().addDays(1), QTime(0, 0, 0)); + } else { + // Yesterday/N days ago: complete day from 00:00:00 to 00:00:00 next day + start = QDateTime(now.date().addDays(-value), QTime(0, 0, 0)); + end = QDateTime(now.date().addDays(-value + 1), QTime(0, 0, 0)); + } + break; + + case TimeUnit::Weeks: + if (value == 0) { + // This week: from Monday 00:00:00 to next Monday 00:00:00 + int daysToMonday = now.date().dayOfWeek() - 1; + QDate monday = now.date().addDays(-daysToMonday); + start = QDateTime(monday, QTime(0, 0, 0)); + end = QDateTime(monday.addDays(7), QTime(0, 0, 0)); + } else { + // Last week/N weeks ago: complete week + int daysToMonday = now.date().dayOfWeek() - 1; + QDate thisMonday = now.date().addDays(-daysToMonday); + QDate startMonday = thisMonday.addDays(-value * 7); + start = QDateTime(startMonday, QTime(0, 0, 0)); + end = QDateTime(startMonday.addDays(7), QTime(0, 0, 0)); + } + break; + + case TimeUnit::Months: + if (value == 0) { + // This month: from 1st day 00:00:00 to 1st day of next month + QDate firstOfMonth(now.date().year(), now.date().month(), 1); + start = QDateTime(firstOfMonth, QTime(0, 0, 0)); + end = QDateTime(firstOfMonth.addMonths(1), QTime(0, 0, 0)); + } else { + // Last month/N months ago: complete month + QDate firstOfThisMonth(now.date().year(), now.date().month(), 1); + QDate startMonth = firstOfThisMonth.addMonths(-value); + start = QDateTime(startMonth, QTime(0, 0, 0)); + end = QDateTime(startMonth.addMonths(1), QTime(0, 0, 0)); + } + break; + + case TimeUnit::Years: + if (value == 0) { + // This year: from Jan 1st 00:00:00 to Jan 1st of next year + QDate firstOfYear(now.date().year(), 1, 1); + start = QDateTime(firstOfYear, QTime(0, 0, 0)); + end = QDateTime(QDate(now.date().year() + 1, 1, 1), QTime(0, 0, 0)); + } else { + // Last year/N years ago: complete year + QDate startYear(now.date().year() - value, 1, 1); + start = QDateTime(startYear, QTime(0, 0, 0)); + end = QDateTime(QDate(now.date().year() - value + 1, 1, 1), QTime(0, 0, 0)); + } + break; + } + + return qMakePair(start, end); +} + +DFM_SEARCH_END_NS diff --git a/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.cpp b/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.cpp index d16b6836..069f0dc6 100644 --- a/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.cpp +++ b/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.cpp @@ -13,11 +13,13 @@ #include <QElapsedTimer> #include <dfm-search/field_names.h> +#include <dfm-search/timerangefilter.h> #include "3rdparty/fulltext/chineseanalyzer.h" #include "utils/cancellablecollector.h" #include "utils/searchutility.h" #include "utils/lucenequeryutils.h" +#include "utils/timerangeutils.h" DFM_SEARCH_BEGIN_NS @@ -721,6 +723,28 @@ Lucene::QueryPtr FileNameIndexedStrategy::buildLuceneQuery(const IndexQuery &que break; } + // Add time range filter query + if (m_options.hasTimeRangeFilter()) { + TimeRangeFilter filter = m_options.timeRangeFilter(); + auto [start, end] = filter.resolveTimeRange(); + + qint64 startEpoch = TimeRangeUtils::toEpochSecs(start); + qint64 endEpoch = TimeRangeUtils::toEpochSecs(end); + + const wchar_t *fieldName = (filter.timeField() == TimeField::BirthTime) + ? LuceneFieldNames::FileName::kBirthTime + : LuceneFieldNames::FileName::kModifyTime; + + QueryPtr timeQuery = TimeRangeUtils::buildNumericRangeQuery( + fieldName, startEpoch, endEpoch, + filter.includeLower(), filter.includeUpper()); + + if (timeQuery) { + finalQuery->add(timeQuery, BooleanClause::MUST); + hasValidQuery = true; + } + } + // Add path prefix query optimization if (hasValidQuery && SearchUtility::isFilenameIndexAncestorPathsSupported() && SearchUtility::shouldUsePathPrefixQuery(searchPath)) { diff --git a/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/realtimestrategy.cpp b/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/realtimestrategy.cpp index 13631aab..dd131084 100644 --- a/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/realtimestrategy.cpp +++ b/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/realtimestrategy.cpp @@ -11,6 +11,8 @@ #include <QDebug> #include <QRegularExpression> +#include <dfm-search/timerangefilter.h> + DFM_SEARCH_BEGIN_NS FileNameRealTimeStrategy::FileNameRealTimeStrategy(const SearchOptions &options, QObject *parent) @@ -117,8 +119,13 @@ void FileNameRealTimeStrategy::search(const SearchQuery &query) QString fileName = info.fileName(); bool matches = false; + // 如果只有时间过滤没有关键词,直接匹配 + bool hasKeyword = !query.keyword().isEmpty() || query.type() == SearchQuery::Type::Boolean; + if (!hasKeyword && m_options.hasTimeRangeFilter()) { + matches = true; + } // 简单查询模式 - if (query.type() == SearchQuery::Type::Simple) { + else if (query.type() == SearchQuery::Type::Simple) { matches = fileName.contains(query.keyword(), caseSensitive ? Qt::CaseSensitive : Qt::CaseInsensitive); } @@ -139,6 +146,34 @@ void FileNameRealTimeStrategy::search(const SearchQuery &query) } } + // 时间范围过滤 + if (matches && m_options.hasTimeRangeFilter()) { + TimeRangeFilter filter = m_options.timeRangeFilter(); + auto [start, end] = filter.resolveTimeRange(); + + QDateTime fileTime = (filter.timeField() == TimeField::BirthTime) + ? info.birthTime() + : info.lastModified(); + + // 时间范围检查 + bool timeMatch = true; + if (start.isValid()) { + if (filter.includeLower()) { + timeMatch = timeMatch && (fileTime >= start); + } else { + timeMatch = timeMatch && (fileTime > start); + } + } + if (end.isValid()) { + if (filter.includeUpper()) { + timeMatch = timeMatch && (fileTime <= end); + } else { + timeMatch = timeMatch && (fileTime < end); + } + } + matches = timeMatch; + } + if (matches) { // 创建搜索结果 SearchResult result(info.filePath()); diff --git a/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextstrategies/indexedstrategy.cpp b/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextstrategies/indexedstrategy.cpp index a5a80e6a..d69ded44 100644 --- a/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextstrategies/indexedstrategy.cpp +++ b/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextstrategies/indexedstrategy.cpp @@ -15,12 +15,14 @@ #include <lucene++/WildcardQuery.h> #include <dfm-search/field_names.h> +#include <dfm-search/timerangefilter.h> #include "3rdparty/fulltext/chineseanalyzer.h" #include "utils/cancellablecollector.h" #include "utils/lucenequeryutils.h" #include "utils/searchutility.h" #include "utils/lucene_cancellation_compat.h" +#include "utils/timerangeutils.h" using namespace Lucene; @@ -103,7 +105,36 @@ Lucene::QueryPtr OcrTextIndexedStrategy::buildLuceneQuery(const SearchQuery &que finalQuery->add(mainQuery, BooleanClause::MUST); finalQuery->add(pathPrefixQuery, BooleanClause::MUST); qInfo() << "Using path prefix query for OCR text search optimization:" << searchPath; - return finalQuery; + mainQuery = finalQuery; + } + } + + // Add time range filter query + if (m_options.hasTimeRangeFilter()) { + TimeRangeFilter filter = m_options.timeRangeFilter(); + auto [start, end] = filter.resolveTimeRange(); + + qint64 startEpoch = TimeRangeUtils::toEpochSecs(start); + qint64 endEpoch = TimeRangeUtils::toEpochSecs(end); + + const wchar_t *fieldName = (filter.timeField() == TimeField::BirthTime) + ? LuceneFieldNames::OcrText::kBirthTime + : LuceneFieldNames::OcrText::kModifyTime; + + QueryPtr timeQuery = TimeRangeUtils::buildNumericRangeQuery( + fieldName, startEpoch, endEpoch, + filter.includeLower(), filter.includeUpper()); + + if (timeQuery) { + if (mainQuery) { + BooleanQueryPtr finalQuery = newLucene<BooleanQuery>(); + finalQuery->add(mainQuery, BooleanClause::MUST); + finalQuery->add(timeQuery, BooleanClause::MUST); + mainQuery = finalQuery; + } else { + // Time filter alone is a valid query + mainQuery = timeQuery; + } } } diff --git a/src/dfm-search/dfm-search-lib/utils/timerangeutils.cpp b/src/dfm-search/dfm-search-lib/utils/timerangeutils.cpp new file mode 100644 index 00000000..772c7146 --- /dev/null +++ b/src/dfm-search/dfm-search-lib/utils/timerangeutils.cpp @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2025 - 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later +#include "timerangeutils.h" + +#include <dfm-search/timerangefilter.h> +#include <lucene++/NumericRangeQuery.h> + +DFM_SEARCH_BEGIN_NS + +namespace TimeRangeUtils { + +qint64 toEpochSecs(const QDateTime &dt) +{ + if (!dt.isValid()) { + return 0; + } + return dt.toSecsSinceEpoch(); +} + +Lucene::QueryPtr buildNumericRangeQuery( + const wchar_t *fieldName, + qint64 startEpoch, + qint64 endEpoch, + bool includeLower, + bool includeUpper) +{ + // Use INT64_MIN and INT64_MAX for unbounded ranges + int64_t minVal = (startEpoch == 0) ? INT64_MIN : startEpoch; + int64_t maxVal = (endEpoch == 0) ? INT64_MAX : endEpoch; + + // Use the default precisionStep (4) + return Lucene::NumericRangeQuery::newLongRange( + Lucene::String(fieldName), + minVal, + maxVal, + includeLower, + includeUpper); +} + +} // namespace TimeRangeUtils + +DFM_SEARCH_END_NS diff --git a/src/dfm-search/dfm-search-lib/utils/timerangeutils.h b/src/dfm-search/dfm-search-lib/utils/timerangeutils.h new file mode 100644 index 00000000..3d6f5b7c --- /dev/null +++ b/src/dfm-search/dfm-search-lib/utils/timerangeutils.h @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2025 - 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later +#ifndef TIMERANGEUTILS_H +#define TIMERANGEUTILS_H + +#include <QDateTime> + +#include <lucene++/LuceneHeaders.h> + +#include <dfm-search/dsearch_global.h> + +DFM_SEARCH_BEGIN_NS + +/** + * @brief TimeRangeUtils provides utility functions for time range operations + */ +namespace TimeRangeUtils { + +/** + * @brief Convert QDateTime to Unix timestamp in seconds + * @param dt The datetime to convert + * @return Unix timestamp in seconds (0 if invalid) + */ +qint64 toEpochSecs(const QDateTime &dt); + +/** + * @brief Build a Lucene NumericRangeQuery for time range filtering + * @param fieldName The field name to query (e.g., L"birth_time", L"modify_time") + * @param startEpoch The start timestamp (in seconds), use 0 for no lower bound + * @param endEpoch The end timestamp (in seconds), use INT64_MAX for no upper bound + * @param includeLower Whether to include the lower bound + * @param includeUpper Whether to include the upper bound + * @return A NumericRangeQuery pointer, or nullptr if range is invalid + */ +Lucene::QueryPtr buildNumericRangeQuery( + const wchar_t *fieldName, + qint64 startEpoch, + qint64 endEpoch, + bool includeLower, + bool includeUpper); + +} // namespace TimeRangeUtils + +DFM_SEARCH_END_NS + +#endif // TIMERANGEUTILS_H From bbdaf3380be8609790f80c4b72c083b67e842fa2 Mon Sep 17 00:00:00 2001 From: Zhang Sheng <zhangsheng@uniontech.com> Date: Mon, 13 Apr 2026 10:05:22 +0800 Subject: [PATCH 13/21] feat: add CLI options and output formatters for search client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactored the search client to implement a comprehensive CLI option system with: 1. Added CliOptions class for parsing command line arguments 2. Implemented TextOutput and JsonOutput formatters for different output formats 3. Added time range filtering capabilities with TimeParser utility 4. Reorganized main.cpp into cleaner modular structure 5. Added CMake entries for new source files The changes enable more flexible command-line usage with configurable search parameters, time filters and output formats while maintaining backward compatibility. Log: Added advanced CLI options and JSON output format to search client Influence: 1. Test all CLI options combinations 2. Verify JSON and text output formats 3. Test time range filters with various formats 4. Check error handling for invalid inputs 5. Verify file type and extension filters feat: 为搜索客户端添加CLI选项和输出格式器 重构搜索客户端实现全面的命令行选项系统: 1. 添加CliOptions类解析命令行参数 2. 实现TextOutput和JsonOutput格式器支持不同输出格式 3. 增加TimeParser工具类支持时间范围过滤 4. 重组main.cpp为更清晰的模块化结构 5. 在CMake中添加新源文件条目 这些变更提供了更灵活的命令行用法,可配置搜索参数、时间过滤和输出格式,同 时保持向后兼容性。 Log: 为搜索客户端添加高级CLI选项和JSON输出格式 Influence: 1. 测试所有CLI选项组合 2. 验证JSON和文本输出格式 3. 测试各种格式的时间范围过滤 4. 检查无效输入的错误处理 5. 验证文件类型和扩展名过滤 --- .../dfm-search-client/CMakeLists.txt | 9 + .../dfm-search-client/cli_options.cpp | 299 ++++++++ .../dfm-search-client/cli_options.h | 114 +++ src/dfm-search/dfm-search-client/main.cpp | 685 ++++-------------- .../dfm-search-client/output/json_output.cpp | 244 +++++++ .../dfm-search-client/output/json_output.h | 70 ++ .../output/output_formatter.h | 71 ++ .../dfm-search-client/output/text_output.cpp | 116 +++ .../dfm-search-client/output/text_output.h | 66 ++ .../dfm-search-client/time_parser.cpp | 79 ++ .../dfm-search-client/time_parser.h | 43 ++ 11 files changed, 1243 insertions(+), 553 deletions(-) create mode 100644 src/dfm-search/dfm-search-client/cli_options.cpp create mode 100644 src/dfm-search/dfm-search-client/cli_options.h create mode 100644 src/dfm-search/dfm-search-client/output/json_output.cpp create mode 100644 src/dfm-search/dfm-search-client/output/json_output.h create mode 100644 src/dfm-search/dfm-search-client/output/output_formatter.h create mode 100644 src/dfm-search/dfm-search-client/output/text_output.cpp create mode 100644 src/dfm-search/dfm-search-client/output/text_output.h create mode 100644 src/dfm-search/dfm-search-client/time_parser.cpp create mode 100644 src/dfm-search/dfm-search-client/time_parser.h diff --git a/src/dfm-search/dfm-search-client/CMakeLists.txt b/src/dfm-search/dfm-search-client/CMakeLists.txt index 4e880b03..d11b5c1e 100644 --- a/src/dfm-search/dfm-search-client/CMakeLists.txt +++ b/src/dfm-search/dfm-search-client/CMakeLists.txt @@ -4,6 +4,15 @@ project(dfm${DFM_VERSION_MAJOR}-search-client) set(SRCS main.cpp + cli_options.cpp + cli_options.h + time_parser.cpp + time_parser.h + output/output_formatter.h + output/text_output.cpp + output/text_output.h + output/json_output.cpp + output/json_output.h ) find_package(Qt${QT_VERSION_MAJOR}Core REQUIRED) diff --git a/src/dfm-search/dfm-search-client/cli_options.cpp b/src/dfm-search/dfm-search-client/cli_options.cpp new file mode 100644 index 00000000..c0727f5e --- /dev/null +++ b/src/dfm-search/dfm-search-client/cli_options.cpp @@ -0,0 +1,299 @@ +// SPDX-FileCopyrightText: 2022 - 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "cli_options.h" +#include "time_parser.h" + +#include <QCoreApplication> +#include <QFileInfo> +#include <iostream> + +using namespace dfmsearch; +using namespace std; + +CliOptions::CliOptions() + : m_typeOption(QStringList() << "type", "Search type (filename, content or ocr)", "type", "filename") + , m_methodOption(QStringList() << "method", "Search method (indexed or realtime)", "method", "indexed") + , m_queryOption(QStringList() << "query", "Query type (simple, boolean or wildcard)", "query", "simple") + , m_caseSensitiveOption(QStringList() << "case-sensitive", "Enable case sensitivity") + , m_includeHiddenOption(QStringList() << "include-hidden", "Include hidden files") + , m_pinyinOption(QStringList() << "pinyin", "Enable pinyin search (for filename search)") + , m_pinyinAcronymOption(QStringList() << "pinyin-acronym", "Enable pinyin acronym search (for filename search)") + , m_fileTypesOption(QStringList() << "file-types", "Filter by file types, comma separated", "types") + , m_fileExtensionsOption(QStringList() << "file-extensions", "Filter by file extensions, comma separated", "extensions") + , m_maxResultsOption(QStringList() << "max-results", "Maximum number of results", "number", "100") + , m_maxPreviewOption(QStringList() << "max-preview", "Max content preview length", "length", "200") + , m_wildcardOption(QStringList() << "wildcard", "Enable wildcard search with * and ? patterns") + , m_jsonOption(QStringList() << "json" + << "j", + "Output results in JSON format") + , m_timeFieldOption(QStringList() << "time-field", "Time field to filter (birth or modify)", "field", "modify") + , m_timeLastOption(QStringList() << "time-last", "Rolling time window (e.g., 3d, 2h, 30m)", "duration") + , m_timeTodayOption(QStringList() << "time-today", "Filter files from today") + , m_timeYesterdayOption(QStringList() << "time-yesterday", "Filter files from yesterday") + , m_timeThisWeekOption(QStringList() << "time-this-week", "Filter files from this week") + , m_timeLastWeekOption(QStringList() << "time-last-week", "Filter files from last week") + , m_timeThisMonthOption(QStringList() << "time-this-month", "Filter files from this month") + , m_timeLastMonthOption(QStringList() << "time-last-month", "Filter files from last month") + , m_timeThisYearOption(QStringList() << "time-this-year", "Filter files from this year") + , m_timeLastYearOption(QStringList() << "time-last-year", "Filter files from last year") + , m_timeRangeOption(QStringList() << "time-range", "Custom time range (start,end)", "range") +{ + setupOptions(); +} + +void CliOptions::setupOptions() +{ + m_parser.setApplicationDescription("DFM Search Client"); + m_parser.addHelpOption(); + + // 基本选项 + m_parser.addOption(m_typeOption); + m_parser.addOption(m_methodOption); + m_parser.addOption(m_queryOption); + m_parser.addOption(m_caseSensitiveOption); + m_parser.addOption(m_includeHiddenOption); + m_parser.addOption(m_pinyinOption); + m_parser.addOption(m_pinyinAcronymOption); + m_parser.addOption(m_fileTypesOption); + m_parser.addOption(m_fileExtensionsOption); + m_parser.addOption(m_maxResultsOption); + m_parser.addOption(m_maxPreviewOption); + m_parser.addOption(m_wildcardOption); + m_parser.addOption(m_jsonOption); + + // 时间范围过滤选项 + m_parser.addOption(m_timeFieldOption); + m_parser.addOption(m_timeLastOption); + m_parser.addOption(m_timeTodayOption); + m_parser.addOption(m_timeYesterdayOption); + m_parser.addOption(m_timeThisWeekOption); + m_parser.addOption(m_timeLastWeekOption); + m_parser.addOption(m_timeThisMonthOption); + m_parser.addOption(m_timeLastMonthOption); + m_parser.addOption(m_timeThisYearOption); + m_parser.addOption(m_timeLastYearOption); + m_parser.addOption(m_timeRangeOption); + + // 位置参数 + m_parser.addPositionalArgument("keyword", "Search keyword"); + m_parser.addPositionalArgument("search_path", "Path to search in"); +} + +void CliOptions::printHelp() const +{ + std::cout << "Usage: dfm6-search-client [options] <keyword> <search_path>" << std::endl; + std::cout << std::endl; + std::cout << "Search Types:" << std::endl; + std::cout << " --type=<filename|content|ocr> Search type (default: filename)" << std::endl; + std::cout << " filename: Search by file name" << std::endl; + std::cout << " content: Search by file content" << std::endl; + std::cout << " ocr: Search by OCR text in images" << std::endl; + std::cout << std::endl; + std::cout << "Search Options:" << std::endl; + std::cout << " --method=<indexed|realtime> Search method (default: indexed)" << std::endl; + std::cout << " --query=<simple|boolean|wildcard> Query type (default: simple)" << std::endl; + std::cout << " --wildcard Enable wildcard search with * and ? patterns" << std::endl; + std::cout << " --case-sensitive Enable case sensitivity" << std::endl; + std::cout << " --include-hidden Include hidden files" << std::endl; + std::cout << " --pinyin Enable pinyin search (for filename search)" << std::endl; + std::cout << " --pinyin-acronym Enable pinyin acronym search (for filename search)" << std::endl; + std::cout << " --file-types=<types> Filter by file types, comma separated" << std::endl; + std::cout << " --file-extensions=<exts> Filter by file extensions, comma separated" << std::endl; + std::cout << " --max-results=<number> Maximum number of results" << std::endl; + std::cout << " --max-preview=<length> Max content preview length (for content search)" << std::endl; + std::cout << std::endl; + std::cout << "Time Range Filter Options:" << std::endl; + std::cout << " --time-field=<birth|modify> Time field to filter (birth=creation, modify=modification)" << std::endl; + std::cout << " --time-last=<N><unit> Rolling time window: N units ago to now" << std::endl; + std::cout << " Units: m=minutes, h=hours, d=days, w=weeks, M=months, y=years" << std::endl; + std::cout << " Examples: --time-last=3d (last 3 days), --time-last=2h (last 2 hours)" << std::endl; + std::cout << " --time-today Filter files from today" << std::endl; + std::cout << " --time-yesterday Filter files from yesterday" << std::endl; + std::cout << " --time-this-week Filter files from this week" << std::endl; + std::cout << " --time-last-week Filter files from last week" << std::endl; + std::cout << " --time-this-month Filter files from this month" << std::endl; + std::cout << " --time-last-month Filter files from last month" << std::endl; + std::cout << " --time-this-year Filter files from this year" << std::endl; + std::cout << " --time-last-year Filter files from last year" << std::endl; + std::cout << " --time-range=<start>,<end> Custom time range (format: YYYY-MM-DD or \"YYYY-MM-DD HH:MM\")" << std::endl; + std::cout << " Example: --time-range=\"2025-01-01,2025-12-31\"" << std::endl; + std::cout << std::endl; + std::cout << "Output Options:" << std::endl; + std::cout << " --json, -j Output results in JSON format" << std::endl; + std::cout << " --help Display this help" << std::endl; + std::cout << std::endl; + std::cout << "Examples:" << std::endl; + std::cout << " # Basic filename search" << std::endl; + std::cout << " dfm6-search-client \"document\" /home/user" << std::endl; + std::cout << std::endl; + std::cout << " # Content search" << std::endl; + std::cout << " dfm6-search-client --type=content \"hello world\" /home/user/Documents" << std::endl; + std::cout << std::endl; + std::cout << " # OCR search in images" << std::endl; + std::cout << " dfm6-search-client --type=ocr \"screenshot\" /home/user/Pictures" << std::endl; + std::cout << std::endl; + std::cout << " # Realtime search with time filter" << std::endl; + std::cout << " dfm6-search-client --method=realtime --time-last=7d \"report\" /home/user" << std::endl; +} + +bool CliOptions::parse(QCoreApplication &app, SearchCliConfig &config) +{ + m_parser.process(app); + + QStringList positionalArgs = m_parser.positionalArguments(); + if (positionalArgs.size() < 2) { + printHelp(); + return false; + } + + config.keyword = positionalArgs.at(0); + config.searchPath = positionalArgs.at(1); + + // 验证搜索路径 + QFileInfo pathInfo(config.searchPath); + if (!pathInfo.exists() || !pathInfo.isDir()) { + std::cerr << "Error: Search path does not exist or is not a directory" << std::endl; + return false; + } + + // 解析搜索类型 + QString typeStr = m_parser.value(m_typeOption); + if (typeStr == "content") { + config.searchType = SearchType::Content; + } else if (typeStr == "ocr") { + config.searchType = SearchType::Ocr; + } else if (typeStr != "filename") { + std::cerr << "Error: Invalid search type. Use 'filename', 'content', or 'ocr'" << std::endl; + return false; + } + + // 解析搜索方法 + QString methodStr = m_parser.value(m_methodOption); + if (methodStr == "realtime") { + config.searchMethod = SearchMethod::Realtime; + } else if (methodStr != "indexed") { + std::cerr << "Error: Invalid search method. Use 'indexed' or 'realtime'" << std::endl; + return false; + } + + // 解析查询类型 + QString queryStr = m_parser.value(m_queryOption); + if (queryStr == "boolean") { + config.queryType = SearchQuery::Type::Boolean; + } else if (queryStr == "wildcard" || m_parser.isSet(m_wildcardOption)) { + config.queryType = SearchQuery::Type::Wildcard; + } else if (queryStr != "simple") { + std::cerr << "Error: Invalid query type. Use 'simple', 'boolean', or 'wildcard'" << std::endl; + return false; + } + + // 解析开关选项 + config.caseSensitive = m_parser.isSet(m_caseSensitiveOption); + config.includeHidden = m_parser.isSet(m_includeHiddenOption); + config.pinyinEnabled = m_parser.isSet(m_pinyinOption); + config.pinyinAcronymEnabled = m_parser.isSet(m_pinyinAcronymOption); + config.jsonOutput = m_parser.isSet(m_jsonOption); + + // 解析过滤选项 + if (m_parser.isSet(m_fileTypesOption)) { +#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) + config.fileTypes = m_parser.value(m_fileTypesOption).split(',', Qt::SkipEmptyParts); +#else + config.fileTypes = m_parser.value(m_fileTypesOption).split(',', QString::SkipEmptyParts); +#endif + } + + if (m_parser.isSet(m_fileExtensionsOption)) { +#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) + config.fileExtensions = m_parser.value(m_fileExtensionsOption).split(',', Qt::SkipEmptyParts); +#else + config.fileExtensions = m_parser.value(m_fileExtensionsOption).split(',', QString::SkipEmptyParts); +#endif + } + + // 解析数值选项 + if (m_parser.isSet(m_maxResultsOption)) { + bool ok; + int maxResults = m_parser.value(m_maxResultsOption).toInt(&ok); + if (ok && maxResults > 0) { + config.maxResults = maxResults; + } + } + + if (m_parser.isSet(m_maxPreviewOption)) { + bool ok; + int previewLength = m_parser.value(m_maxPreviewOption).toInt(&ok); + if (ok && previewLength > 0) { + config.maxPreviewLength = previewLength; + } + } + + // 解析时间范围选项 + return parseTimeOptions(config); +} + +bool CliOptions::parseTimeOptions(SearchCliConfig &config) +{ + // 设置时间字段 + QString timeFieldStr = m_parser.value(m_timeFieldOption); + if (timeFieldStr == "birth") { + config.timeFilter.setTimeField(DFMSEARCH::TimeField::BirthTime); + } else if (timeFieldStr == "modify") { + config.timeFilter.setTimeField(DFMSEARCH::TimeField::ModifyTime); + } else { + std::cerr << "Error: Invalid time field. Use 'birth' or 'modify'" << std::endl; + return false; + } + + // 处理时间范围选项(只能激活一个) + if (m_parser.isSet(m_timeLastOption)) { + QString lastArg = m_parser.value(m_timeLastOption); + int value; + DFMSEARCH::TimeUnit unit; + if (TimeParser::parseTimeLast(lastArg, value, unit)) { + config.timeFilter.setLast(value, unit); + config.hasTimeFilter = true; + } else { + std::cerr << "Error: Invalid --time-last format. Use format like '3d', '2h', '30m'" << std::endl; + return false; + } + } else if (m_parser.isSet(m_timeTodayOption)) { + config.timeFilter.setToday(); + config.hasTimeFilter = true; + } else if (m_parser.isSet(m_timeYesterdayOption)) { + config.timeFilter.setYesterday(); + config.hasTimeFilter = true; + } else if (m_parser.isSet(m_timeThisWeekOption)) { + config.timeFilter.setThisWeek(); + config.hasTimeFilter = true; + } else if (m_parser.isSet(m_timeLastWeekOption)) { + config.timeFilter.setLastWeek(); + config.hasTimeFilter = true; + } else if (m_parser.isSet(m_timeThisMonthOption)) { + config.timeFilter.setThisMonth(); + config.hasTimeFilter = true; + } else if (m_parser.isSet(m_timeLastMonthOption)) { + config.timeFilter.setLastMonth(); + config.hasTimeFilter = true; + } else if (m_parser.isSet(m_timeThisYearOption)) { + config.timeFilter.setThisYear(); + config.hasTimeFilter = true; + } else if (m_parser.isSet(m_timeLastYearOption)) { + config.timeFilter.setLastYear(); + config.hasTimeFilter = true; + } else if (m_parser.isSet(m_timeRangeOption)) { + QString rangeArg = m_parser.value(m_timeRangeOption); + QDateTime start, end; + if (TimeParser::parseTimeRange(rangeArg, start, end)) { + config.timeFilter.setRange(start, end); + config.hasTimeFilter = true; + } else { + std::cerr << "Error: Invalid --time-range format. Use format 'YYYY-MM-DD,YYYY-MM-DD' or 'YYYY-MM-DD HH:MM,YYYY-MM-DD HH:MM'" << std::endl; + return false; + } + } + + return true; +} diff --git a/src/dfm-search/dfm-search-client/cli_options.h b/src/dfm-search/dfm-search-client/cli_options.h new file mode 100644 index 00000000..998df7e7 --- /dev/null +++ b/src/dfm-search/dfm-search-client/cli_options.h @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: 2022 - 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef CLI_OPTIONS_H +#define CLI_OPTIONS_H + +#include <QCommandLineParser> +#include <QCommandLineOption> +#include <QStringList> + +#include <dfm-search/dsearch_global.h> +#include <dfm-search/searchquery.h> +#include <dfm-search/timerangefilter.h> + +namespace dfmsearch { + +/** + * @brief 命令行搜索参数配置 + * + * 封装所有命令行解析结果,遵循单一职责原则 + */ +struct SearchCliConfig +{ + // 基本参数 + QString keyword; + QString searchPath; + SearchType searchType = SearchType::FileName; + SearchMethod searchMethod = SearchMethod::Indexed; + SearchQuery::Type queryType = SearchQuery::Type::Simple; + + // 开关选项 + bool caseSensitive = false; + bool includeHidden = false; + bool pinyinEnabled = false; + bool pinyinAcronymEnabled = false; + bool wildcardEnabled = false; + bool jsonOutput = false; + + // 过滤选项 + QStringList fileTypes; + QStringList fileExtensions; + int maxResults = 100; + int maxPreviewLength = 200; + + // 时间范围过滤 + bool hasTimeFilter = false; + DFMSEARCH::TimeRangeFilter timeFilter; +}; + +/** + * @brief 命令行选项管理器 + * + * 负责定义和解析命令行参数,遵循单一职责原则 + */ +class CliOptions +{ +public: + CliOptions(); + ~CliOptions() = default; + + /** + * @brief 解析命令行参数 + * @param app QCoreApplication实例 + * @param config 输出的配置结构 + * @return 解析成功返回true,失败返回false + */ + bool parse(QCoreApplication &app, SearchCliConfig &config); + + /** + * @brief 打印帮助信息 + */ + void printHelp() const; + +private: + void setupOptions(); + bool validateConfig(SearchCliConfig &config); + bool parseTimeOptions(SearchCliConfig &config); + +private: + QCommandLineParser m_parser; + + // 基本选项 + QCommandLineOption m_typeOption; + QCommandLineOption m_methodOption; + QCommandLineOption m_queryOption; + QCommandLineOption m_caseSensitiveOption; + QCommandLineOption m_includeHiddenOption; + QCommandLineOption m_pinyinOption; + QCommandLineOption m_pinyinAcronymOption; + QCommandLineOption m_fileTypesOption; + QCommandLineOption m_fileExtensionsOption; + QCommandLineOption m_maxResultsOption; + QCommandLineOption m_maxPreviewOption; + QCommandLineOption m_wildcardOption; + QCommandLineOption m_jsonOption; + + // 时间范围过滤选项 + QCommandLineOption m_timeFieldOption; + QCommandLineOption m_timeLastOption; + QCommandLineOption m_timeTodayOption; + QCommandLineOption m_timeYesterdayOption; + QCommandLineOption m_timeThisWeekOption; + QCommandLineOption m_timeLastWeekOption; + QCommandLineOption m_timeThisMonthOption; + QCommandLineOption m_timeLastMonthOption; + QCommandLineOption m_timeThisYearOption; + QCommandLineOption m_timeLastYearOption; + QCommandLineOption m_timeRangeOption; +}; + +} // namespace dfmsearch + +#endif // CLI_OPTIONS_H diff --git a/src/dfm-search/dfm-search-client/main.cpp b/src/dfm-search/dfm-search-client/main.cpp index eb5643f4..f6ff3fe9 100644 --- a/src/dfm-search/dfm-search-client/main.cpp +++ b/src/dfm-search/dfm-search-client/main.cpp @@ -3,19 +3,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include <QCoreApplication> -#include <QCommandLineParser> -#include <QCommandLineOption> #include <QDebug> -#include <QStringList> -#include <QFileInfo> -#include <QDir> -#include <QJsonObject> -#include <QJsonDocument> -#include <QJsonArray> -#include <QDateTime> -#include <QTimer> -#include <dfm-search/dsearch_global.h> #include <dfm-search/searchengine.h> #include <dfm-search/searchfactory.h> #include <dfm-search/searchquery.h> @@ -23,592 +12,182 @@ #include <dfm-search/filenamesearchapi.h> #include <dfm-search/contentsearchapi.h> #include <dfm-search/ocrtextsearchapi.h> -#include "../dfm-search-lib/utils/filenameblacklistmatcher.h" -#include <iostream> +#include "cli_options.h" +#include "output/text_output.h" +#include "output/json_output.h" using namespace dfmsearch; -using namespace std; -// # 基本文件名搜索 -// dfm6-search-client "document" /home/user - -// # 内容搜索 -// dfm6-search-client --type=content "hello world" /home/user/Documents - -// # 使用realtime搜索 -// dfm6-search-client --method=realtime "report" /home/user - -// # 区分大小写的搜索 -// dfm6-search-client --case-sensitive "README" /home/user - -// # 文件类型过滤 -// dfm6-search-client --file-types=doc,pic "" /home/user - -// # 文件后缀过滤 -// dfm6-search-client --file-extensions=txt,pdf "" /home/user - -// # 文件类型和后缀组合过滤 -// dfm6-search-client --file-types=doc --file-extensions=docx,odt "report" /home/user - -// # 布尔查询 -// dfm6-search-client --query=boolean "meeting,notes,2023" /home/user/Documents - -// # Combined -// dfm6-search-client --file-types="dir,doc" --query=boolean "dde,file" / -// dfm6-search-client --pinyin --query=boolean "wendang,xinjian" / -// dfm6-search-client --pinyin --file-types="doc,pic" --query=boolean "wen,dang" / -// dfm6-search-client --pinyin-acronym "nh" /home/user # 搜索"你好"的拼音首字母 -// dfm6-search-client --pinyin-acronym --query=boolean "wd,xj" / # 搜索"文档,新建"的拼音首字母 -// dfm6-search-client --pinyin --pinyin-acronym "wendang" / # 智能模式:有效拼音用拼音搜索 -// dfm6-search-client --pinyin --pinyin-acronym "wd" / # 智能模式:有效首字母用首字母搜索 -// dfm6-search-client --pinyin --pinyin-acronym "nh123" / # 智能模式:首字母+数字用首字母搜索 -// dfm6-search-client --pinyin --pinyin-acronym "abc@#" / # 智能模式:无效输入fallback到普通搜索 -// dfm6-search-client --file-extensions="txt,pdf" --query=boolean "report,data" / - -void printUsage() +/** + * @brief 配置搜索引擎选项 + */ +static void configureSearchOptions(SearchOptions &options, const SearchCliConfig &config) { - std::cout << "Usage: dfm6-search-client [options] <keyword> <search_path>" << std::endl; - std::cout << "Options:" << std::endl; - std::cout << " --type=<filename|content|ocr> Search type (default: filename)" << std::endl; - std::cout << " --method=<indexed|realtime> Search method (default: indexed)" << std::endl; - std::cout << " --query=<simple|boolean|wildcard> Query type (default: simple)" << std::endl; - std::cout << " --wildcard Enable wildcard search with * and ? patterns" << std::endl; - std::cout << " --case-sensitive Enable case sensitivity" << std::endl; - std::cout << " --include-hidden Include hidden files" << std::endl; - std::cout << " --pinyin Enable pinyin search (for filename search)" << std::endl; - std::cout << " --pinyin-acronym Enable pinyin acronym search (for filename search)" << std::endl; - std::cout << " --file-types=<types> Filter by file types, comma separated" << std::endl; - std::cout << " --file-extensions=<exts> Filter by file extensions, comma separated" << std::endl; - std::cout << " --max-results=<number> Maximum number of results" << std::endl; - std::cout << " --max-preview=<length> Max content preview length (for content search)" << std::endl; - std::cout << " --json, -j Output results in JSON format" << std::endl; - std::cout << " --help Display this help" << std::endl; -} - -void printSearchResult(const SearchResult &result, SearchType searchType) -{ - std::cout << "Found: " << result.path().toStdString() << std::endl; + options.setSearchMethod(config.searchMethod); + options.setCaseSensitive(config.caseSensitive); + options.setIncludeHidden(config.includeHidden); + options.setSearchPath(config.searchPath); + options.setMaxResults(config.maxResults); + options.setDetailedResultsEnabled(true); + + if (config.searchMethod == SearchMethod::Realtime) { + options.setResultFoundEnabled(true); + } - if (searchType == SearchType::FileName) { - FileNameResultAPI resultAPI(const_cast<SearchResult &>(result)); + // 配置类型特定选项 + if (config.searchType == SearchType::FileName) { + FileNameOptionsAPI fileNameOptions(options); + fileNameOptions.setPinyinEnabled(config.pinyinEnabled); + fileNameOptions.setPinyinAcronymEnabled(config.pinyinAcronymEnabled); - if (resultAPI.isDirectory()) { - std::cout << " Type: Directory" << std::endl; - } else { - std::cout << " Type: " << resultAPI.fileType().toStdString() << std::endl; - std::cout << " Size: " << resultAPI.size().toStdString() << " bytes" << std::endl; + if (!config.fileTypes.isEmpty()) { + fileNameOptions.setFileTypes(config.fileTypes); } - - std::cout << " Modified: " << resultAPI.modifiedTime().toStdString() << std::endl; - } else if (searchType == SearchType::Content) { - ContentResultAPI contentResult(const_cast<SearchResult &>(result)); - std::cout << " Content match: " << contentResult.highlightedContent().toStdString() << std::endl; - } else if (searchType == SearchType::Ocr) { - OcrTextResultAPI ocrResult(const_cast<SearchResult &>(result)); - std::cout << " OCR text match" << std::endl; + if (!config.fileExtensions.isEmpty()) { + fileNameOptions.setFileExtensions(config.fileExtensions); + } + } else if (config.searchType == SearchType::Content) { + ContentOptionsAPI contentOptions(options); + contentOptions.setMaxPreviewLength(config.maxPreviewLength); + contentOptions.setFullTextRetrievalEnabled(true); + contentOptions.setSearchResultHighlightEnabled(true); + contentOptions.setFilenameContentMixedAndSearchEnabled(true); + } else if (config.searchType == SearchType::Ocr) { + OcrTextOptionsAPI ocrTextOptions(options); + ocrTextOptions.setFilenameOcrContentMixedAndSearchEnabled(true); } - std::cout << std::endl; -} - -//-------------------------------------------------------------------- -// JSON Output Helpers -//-------------------------------------------------------------------- - -QJsonValue resultToJson(const SearchResult &result, SearchType searchType) -{ - if (searchType == SearchType::FileName) { - // 文件名搜索:直接返回路径字符串 - return result.path(); - } else if (searchType == SearchType::Content) { - // 内容搜索:返回包含路径和内容匹配的对象 - QJsonObject obj; - obj["path"] = result.path(); - ContentResultAPI contentResult(const_cast<SearchResult &>(result)); - obj["contentMatch"] = contentResult.highlightedContent(); - return obj; - } else if (searchType == SearchType::Ocr) { - // OCR 搜索:返回路径 - QJsonObject obj; - obj["path"] = result.path(); - return obj; + // 应用时间范围过滤 + if (config.hasTimeFilter) { + options.setTimeRangeFilter(config.timeFilter); } - return result.path(); -} - -void printJsonLine(const QJsonObject &obj) -{ - QJsonDocument doc(obj); - std::cout << doc.toJson(QJsonDocument::Compact).constData() << std::endl; } -// 流式模式:输出搜索开始 -void printJsonSearchStart(const QString &keyword, const QString &searchPath, - SearchType searchType, SearchMethod searchMethod, - const SearchOptions &options) +/** + * @brief 创建输出格式化器 + */ +static OutputFormatter *createOutputFormatter(const SearchCliConfig &config, QObject *parent) { - QJsonObject startObj; - startObj["type"] = "search_started"; - - QString searchTypeStr; - switch (searchType) { - case SearchType::FileName: - searchTypeStr = "filename"; - break; - case SearchType::Content: - searchTypeStr = "content"; - break; - case SearchType::Ocr: - searchTypeStr = "ocr"; - break; - default: - searchTypeStr = "unknown"; + if (config.jsonOutput) { + // JSON 输出:实时搜索使用流式,索引搜索使用完整输出 + bool streaming = (config.searchMethod == SearchMethod::Realtime); + return new JsonOutput(streaming, parent); } - - QJsonObject searchInfo; - searchInfo["keyword"] = keyword; - searchInfo["searchPath"] = searchPath; - searchInfo["searchType"] = searchTypeStr; - searchInfo["searchMethod"] = (searchMethod == SearchMethod::Indexed ? "indexed" : "realtime"); - searchInfo["caseSensitive"] = options.caseSensitive(); - searchInfo["includeHidden"] = options.includeHidden(); - - startObj["search"] = searchInfo; - startObj["timestamp"] = QDateTime::currentDateTime().toString(Qt::ISODate); - - printJsonLine(startObj); -} - -// 流式模式:输出单个结果 -void printJsonResult(const SearchResult &result, SearchType searchType) -{ - QJsonObject resultObj; - resultObj["type"] = "result"; - resultObj["data"] = resultToJson(result, searchType); - printJsonLine(resultObj); + return new TextOutput(parent); } -// 流式模式:输出搜索结束 -void printJsonSearchEnd(int totalResults, qint64 elapsedMs) +/** + * @brief 连接搜索引擎信号到输出格式化器 + */ +static void connectSignals(SearchEngine *engine, OutputFormatter *formatter, + const SearchCliConfig &config, QCoreApplication &app) { - QJsonObject endObj; - endObj["type"] = "search_finished"; - - QJsonObject status; - status["state"] = "success"; - status["totalResults"] = totalResults; - - endObj["status"] = status; - - QJsonObject timestamps; - timestamps["finished"] = QDateTime::currentDateTime().toString(Qt::ISODate); - timestamps["duration"] = elapsedMs; - - endObj["timestamps"] = timestamps; - printJsonLine(endObj); + // 连接 finished 信号以退出应用 + QObject::connect(formatter, &OutputFormatter::finished, &app, &QCoreApplication::quit); + + // 搜索开始 + QObject::connect(engine, &SearchEngine::searchStarted, [formatter]() { + formatter->outputSearchStarted(); + }); + + // 结果到达 + QObject::connect(engine, &SearchEngine::resultsFound, [formatter](const SearchResultList &results) { + for (const auto &result : results) { + formatter->outputResult(result); + } + }); + + // 搜索完成 + QObject::connect(engine, &SearchEngine::searchFinished, [formatter](const QList<SearchResult> &results) { + formatter->outputSearchFinished(results); + }); + + // 搜索取消 + QObject::connect(engine, &SearchEngine::searchCancelled, [formatter]() { + formatter->outputSearchCancelled(); + }); + + // 错误处理 + QObject::connect(engine, &SearchEngine::errorOccurred, [formatter](const DFMSEARCH::SearchError &error) { + formatter->outputError(error); + }); } -// 完整 JSON 输出(非流式) -struct JsonOutputContext -{ - QString keyword; - QString searchPath; - SearchType searchType; - SearchMethod searchMethod; - SearchOptions options; - QDateTime startTime; - QJsonArray results; -}; - -void printJsonComplete(const JsonOutputContext &ctx) +/** + * @brief 创建搜索查询 + */ +static SearchQuery createSearchQuery(const SearchCliConfig &config) { - QJsonObject root; - - // 搜索信息 - QJsonObject searchInfo; - searchInfo["keyword"] = ctx.keyword; - searchInfo["searchPath"] = ctx.searchPath; - - QString searchTypeStr; - switch (ctx.searchType) { - case SearchType::FileName: - searchTypeStr = "filename"; - break; - case SearchType::Content: - searchTypeStr = "content"; - break; - case SearchType::Ocr: - searchTypeStr = "ocr"; - break; - default: - searchTypeStr = "unknown"; + if (config.queryType == SearchQuery::Type::Simple) { + return SearchFactory::createQuery(config.keyword, SearchQuery::Type::Simple); + } else if (config.queryType == SearchQuery::Type::Wildcard) { + return SearchFactory::createQuery(config.keyword, SearchQuery::Type::Wildcard); + } else { + // Boolean 查询:按逗号分割关键字 +#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) + QStringList keywords = config.keyword.split(',', Qt::SkipEmptyParts); +#else + QStringList keywords = config.keyword.split(',', QString::SkipEmptyParts); +#endif + SearchQuery query = SearchFactory::createQuery(keywords, SearchQuery::Type::Boolean); + query.setBooleanOperator(SearchQuery::BooleanOperator::AND); + return query; } - searchInfo["searchType"] = searchTypeStr; - searchInfo["searchMethod"] = (ctx.searchMethod == SearchMethod::Indexed ? "indexed" : "realtime"); - searchInfo["caseSensitive"] = ctx.options.caseSensitive(); - searchInfo["includeHidden"] = ctx.options.includeHidden(); - root["search"] = searchInfo; - - // 时间戳 - QDateTime endTime = QDateTime::currentDateTime(); - qint64 duration = ctx.startTime.msecsTo(endTime); - - QJsonObject timestamps; - timestamps["started"] = ctx.startTime.toString(Qt::ISODate); - timestamps["finished"] = endTime.toString(Qt::ISODate); - timestamps["duration"] = duration; - root["timestamps"] = timestamps; - - // 状态 - QJsonObject status; - status["state"] = "success"; - status["totalResults"] = ctx.results.size(); - root["status"] = status; - - // 结果数组 - root["results"] = ctx.results; - - // 输出完整 JSON - QJsonDocument doc(root); - std::cout << doc.toJson(QJsonDocument::Indented).constData() << std::endl; } int main(int argc, char *argv[]) { - QCoreApplication a(argc, argv); + QCoreApplication app(argc, argv); - QCommandLineParser parser; - parser.setApplicationDescription("DFM Search Client"); - parser.addHelpOption(); - - // Add command line options - QCommandLineOption typeOption(QStringList() << "type", "Search type (filename or content)", "type", "filename"); - QCommandLineOption methodOption(QStringList() << "method", "Search method (indexed or realtime)", "method", "indexed"); - QCommandLineOption queryOption(QStringList() << "query", "Query type (simple or boolean)", "query", "simple"); - QCommandLineOption caseSensitiveOption(QStringList() << "case-sensitive", "Enable case sensitivity"); - QCommandLineOption includeHiddenOption(QStringList() << "include-hidden", "Include hidden files"); - QCommandLineOption pinyinOption(QStringList() << "pinyin", "Enable pinyin search (for filename search)"); - QCommandLineOption pinyinAcronymOption(QStringList() << "pinyin-acronym", "Enable pinyin acronym search (for filename search)"); - QCommandLineOption fileTypesOption(QStringList() << "file-types", "Filter by file types, comma separated", "types"); - QCommandLineOption fileExtensionsOption(QStringList() << "file-extensions", "Filter by file extensions, comma separated", "extensions"); - QCommandLineOption maxResultsOption(QStringList() << "max-results", "Maximum number of results", "number", "100"); - QCommandLineOption maxPreviewOption(QStringList() << "max-preview", "Max content preview length", "length", "200"); - QCommandLineOption wildcardOption(QStringList() << "wildcard", "Enable wildcard search with * and ? patterns"); - QCommandLineOption jsonOption(QStringList() << "json" - << "j", - "Output results in JSON format"); - - parser.addOption(typeOption); - parser.addOption(methodOption); - parser.addOption(queryOption); - parser.addOption(caseSensitiveOption); - parser.addOption(includeHiddenOption); - parser.addOption(pinyinOption); - parser.addOption(pinyinAcronymOption); - parser.addOption(fileTypesOption); - parser.addOption(fileExtensionsOption); - parser.addOption(maxResultsOption); - parser.addOption(maxPreviewOption); - parser.addOption(wildcardOption); - parser.addOption(jsonOption); - - // Setup positional arguments - parser.addPositionalArgument("keyword", "Search keyword"); - parser.addPositionalArgument("search_path", "Path to search in"); - - // Process arguments - parser.process(a); - - QStringList positionalArgs = parser.positionalArguments(); - if (positionalArgs.size() < 2) { - printUsage(); + // 解析命令行参数 + CliOptions cliOptions; + SearchCliConfig config; + if (!cliOptions.parse(app, config)) { return 1; } - QString keyword = positionalArgs.at(0); - QString searchPath = positionalArgs.at(1); - - // Validate search path - QFileInfo pathInfo(searchPath); - if (!pathInfo.exists() || !pathInfo.isDir()) { - std::cerr << "Error: Search path does not exist or is not a directory" << std::endl; - return 1; - } - - // Get search type - SearchType searchType = SearchType::FileName; - QString typeStr = parser.value(typeOption); - if (typeStr == "content") { - searchType = SearchType::Content; - } else if (typeStr == "ocr") { - searchType = SearchType::Ocr; - } else if (typeStr != "filename") { - std::cerr << "Error: Invalid search type. Use 'filename', 'content', or 'ocr'" << std::endl; - return 1; - } - - // Get search method - SearchMethod searchMethod = SearchMethod::Indexed; - QString methodStr = parser.value(methodOption); - if (methodStr == "realtime") { - searchMethod = SearchMethod::Realtime; - } else if (methodStr != "indexed") { - std::cerr << "Error: Invalid search method. Use 'indexed' or 'realtime'" << std::endl; - return 1; - } - - // Get query type - SearchQuery::Type queryType = SearchQuery::Type::Simple; - QString queryStr = parser.value(queryOption); - if (queryStr == "boolean") { - queryType = SearchQuery::Type::Boolean; - } else if (queryStr == "wildcard" || parser.isSet(wildcardOption)) { - queryType = SearchQuery::Type::Wildcard; - } else if (queryStr != "simple") { - std::cerr << "Error: Invalid query type. Use 'simple', 'boolean', or 'wildcard'" << std::endl; - return 1; - } - - // Create search engine - SearchEngine *engine = SearchFactory::createEngine(searchType, &a); + // 创建搜索引擎 + SearchEngine *engine = SearchFactory::createEngine(config.searchType, &app); if (!engine) { - std::cerr << "Error: Failed to create search engine" << std::endl; + qCritical() << "Error: Failed to create search engine"; return 1; } - // Setup search options + // 配置搜索选项 SearchOptions options; - options.setSearchMethod(searchMethod); - options.setCaseSensitive(parser.isSet(caseSensitiveOption)); - options.setIncludeHidden(parser.isSet(includeHiddenOption)); - options.setSearchPath(searchPath); - if (searchMethod == SearchMethod::Realtime) { - options.setResultFoundEnabled(true); - options.setDetailedResultsEnabled(true); - } - - // Set max results if specified - if (parser.isSet(maxResultsOption)) { - bool ok; - int maxResults = parser.value(maxResultsOption).toInt(&ok); - if (ok && maxResults > 0) { - options.setMaxResults(maxResults); - } - } - - // Set type-specific options - if (searchType == SearchType::FileName) { - FileNameOptionsAPI fileNameOptions(options); - fileNameOptions.setPinyinEnabled(parser.isSet(pinyinOption)); - fileNameOptions.setPinyinAcronymEnabled(parser.isSet(pinyinAcronymOption)); + configureSearchOptions(options, config); + engine->setSearchOptions(options); - if (parser.isSet(fileTypesOption)) { -#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) - QStringList types = parser.value(fileTypesOption).split(',', Qt::SkipEmptyParts); -#else - QStringList types = parser.value(fileTypesOption).split(',', QString::SkipEmptyParts); -#endif - fileNameOptions.setFileTypes(types); - } + // 创建输出格式化器 + OutputFormatter *formatter = createOutputFormatter(config, &app); - if (parser.isSet(fileExtensionsOption)) { -#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) - QStringList extensions = parser.value(fileExtensionsOption).split(',', Qt::SkipEmptyParts); -#else - QStringList extensions = parser.value(fileExtensionsOption).split(',', QString::SkipEmptyParts); -#endif - fileNameOptions.setFileExtensions(extensions); - } - } else if (searchType == SearchType::Content) { - ContentOptionsAPI contentOptions(options); + // 设置输出格式化器上下文 + formatter->setSearchContext(config.keyword, config.searchPath, + config.searchType, config.searchMethod); - // if (parser.isSet(fileTypesOption)) { - // QStringList extensions = parser.value(fileTypesOption).split(',', Qt::SkipEmptyParts); - // contentOptions.setFileTypeFilters(extensions); - // } - contentOptions.setMaxPreviewLength(200); - contentOptions.setFullTextRetrievalEnabled(true); - contentOptions.setSearchResultHighlightEnabled(true); - contentOptions.setFilenameContentMixedAndSearchEnabled(true); - if (parser.isSet(maxPreviewOption)) { - bool ok; - int previewLength = parser.value(maxPreviewOption).toInt(&ok); - if (ok && previewLength > 0) { - contentOptions.setMaxPreviewLength(previewLength); - } + // 为文本输出设置额外信息 + TextOutput *textOutput = qobject_cast<TextOutput *>(formatter); + if (textOutput) { + textOutput->setSearchOptions(options); + textOutput->setTimeFilterInfo(config.hasTimeFilter, config.timeFilter); + if (!config.fileExtensions.isEmpty()) { + textOutput->setFileExtensionsFilter(config.fileExtensions.join(',')); } - } else if (searchType == SearchType::Ocr) { - OcrTextOptionsAPI ocrTextOptions(options); - ocrTextOptions.setFilenameOcrContentMixedAndSearchEnabled(true); } - engine->setSearchOptions(options); - - // Create and configure search query - SearchQuery query; - if (queryType == SearchQuery::Type::Simple) { - query = SearchFactory::createQuery(keyword, SearchQuery::Type::Simple); - } else if (queryType == SearchQuery::Type::Wildcard) { - query = SearchFactory::createQuery(keyword, SearchQuery::Type::Wildcard); - } else { - // For boolean query, split keywords by comma and create a boolean query -#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) - QStringList keywords = keyword.split(',', Qt::SkipEmptyParts); -#else - QStringList keywords = keyword.split(',', QString::SkipEmptyParts); -#endif - - query = SearchFactory::createQuery(keywords, SearchQuery::Type::Boolean); - query.setBooleanOperator(SearchQuery::BooleanOperator::AND); + // 为JSON输出设置额外信息 + JsonOutput *jsonOutput = qobject_cast<JsonOutput *>(formatter); + if (jsonOutput) { + jsonOutput->setSearchOptions(options); } - // 检测是否使用 JSON 输出模式 - bool useJsonOutput = parser.isSet(jsonOption); - - // JSON 模式上下文(用于非流式模式) - JsonOutputContext jsonContext; - if (useJsonOutput && searchMethod == SearchMethod::Indexed) { - // 非流式模式:收集所有结果后统一输出 - jsonContext.keyword = keyword; - jsonContext.searchPath = searchPath; - jsonContext.searchType = searchType; - jsonContext.searchMethod = searchMethod; - jsonContext.options = options; - jsonContext.startTime = QDateTime::currentDateTime(); - } - - // Connect signals - if (useJsonOutput) { - // JSON 输出模式 - if (searchMethod == SearchMethod::Realtime) { - // 流式 JSON 输出(实时搜索) - QObject::connect(engine, &SearchEngine::searchStarted, [keyword, searchPath, searchType, searchMethod, &options]() { - printJsonSearchStart(keyword, searchPath, searchType, searchMethod, options); - }); - - QObject::connect(engine, &SearchEngine::resultsFound, [searchType](const SearchResultList &results) { - for (const auto &result : results) - printJsonResult(result, searchType); - }); - - // 记录搜索开始时间 - QDateTime searchStartTime = QDateTime::currentDateTime(); - - QObject::connect(engine, &SearchEngine::searchFinished, [searchStartTime](const QList<SearchResult> &results) { - qint64 elapsedMs = searchStartTime.msecsTo(QDateTime::currentDateTime()); - printJsonSearchEnd(results.size(), elapsedMs); - QCoreApplication::quit(); - }); - - QObject::connect(engine, &SearchEngine::searchCancelled, [] { - QJsonObject cancelObj; - cancelObj["type"] = "search_cancelled"; - cancelObj["timestamp"] = QDateTime::currentDateTime().toString(Qt::ISODate); - printJsonLine(cancelObj); - QCoreApplication::quit(); - }); - - QObject::connect(engine, &SearchEngine::errorOccurred, [](const DFMSEARCH::SearchError &error) { - QJsonObject errorObj; - errorObj["type"] = "error"; - errorObj["name"] = error.name(); - errorObj["message"] = error.message(); - errorObj["timestamp"] = QDateTime::currentDateTime().toString(Qt::ISODate); - printJsonLine(errorObj); - }); - - } else { - // 完整 JSON 输出(索引搜索) - QObject::connect(engine, &SearchEngine::searchStarted, []() { - // 非流式模式:开始时不输出 - }); - - QObject::connect(engine, &SearchEngine::resultsFound, [&jsonContext, searchType](const SearchResultList &results) { - for (const auto &result : results) { - jsonContext.results.append(resultToJson(result, searchType)); - } - }); - - QObject::connect(engine, &SearchEngine::searchFinished, [&jsonContext, searchType](const QList<SearchResult> &results) { - // 如果结果没有被 resultsFound 收集(禁用了 resultFoundEnabled),则在这里转换 - if (jsonContext.results.isEmpty() && !results.isEmpty()) { - for (const auto &result : results) { - jsonContext.results.append(resultToJson(result, searchType)); - } - } - printJsonComplete(jsonContext); - QCoreApplication::quit(); - }); - - QObject::connect(engine, &SearchEngine::searchCancelled, [] { - QJsonObject cancelObj; - cancelObj["type"] = "search_cancelled"; - cancelObj["timestamp"] = QDateTime::currentDateTime().toString(Qt::ISODate); - printJsonLine(cancelObj); - QCoreApplication::quit(); - }); - - QObject::connect(engine, &SearchEngine::errorOccurred, [](const DFMSEARCH::SearchError &error) { - QJsonObject errorObj; - errorObj["type"] = "error"; - errorObj["name"] = error.name(); - errorObj["message"] = error.message(); - errorObj["timestamp"] = QDateTime::currentDateTime().toString(Qt::ISODate); - printJsonLine(errorObj); - }); - } - } else { - // 文本输出模式(原有逻辑) - QObject::connect(engine, &SearchEngine::searchStarted, [] { - std::cout << "Search started..." << std::endl; - }); - - QObject::connect(engine, &SearchEngine::resultsFound, [searchType](const SearchResultList &results) { - for (const auto &result : results) - printSearchResult(result, searchType); - }); - - QObject::connect(engine, &SearchEngine::searchFinished, [options, searchType](const QList<SearchResult> &results) { - std::cout << "Search finished. Total results: " << results.size() << std::endl; - if (!options.resultFoundEnabled()) { - std::for_each(results.begin(), results.end(), [searchType](const SearchResult &result) { - printSearchResult(result, searchType); - }); - } - QCoreApplication::quit(); - }); - - QObject::connect(engine, &SearchEngine::searchCancelled, [] { - std::cout << "Search cancelled" << std::endl; - QCoreApplication::quit(); - }); - - QObject::connect(engine, &SearchEngine::errorOccurred, [](const DFMSEARCH::SearchError &error) { - std::cerr << "[Error]: " << error.code() - << "[Name]: " << error.name().toStdString() - << "[Message]:" << error.message().toStdString() << std::endl; - }); - - // Start search - std::cout << "Searching for: " << keyword.toStdString() << std::endl; - std::cout << "In path: " << searchPath.toStdString() << std::endl; - - QString typeStr = "Filename"; - if (searchType == SearchType::Content) - typeStr = "Content"; - else if (searchType == SearchType::Ocr) - typeStr = "Ocr"; - std::cout << "Search type: " << typeStr.toStdString() << std::endl; - std::cout << "Search method: " << (searchMethod == SearchMethod::Indexed ? "Indexed" : "Realtime") << std::endl; - - // Print file extensions if set - if (searchType == SearchType::FileName && parser.isSet(fileExtensionsOption)) { - std::cout << "File extensions filter: " << parser.value(fileExtensionsOption).toStdString() << std::endl; - } - } + // 连接信号 + connectSignals(engine, formatter, config, app); + // 创建并执行搜索查询 + SearchQuery query = createSearchQuery(config); engine->search(query); - return a.exec(); + return app.exec(); } diff --git a/src/dfm-search/dfm-search-client/output/json_output.cpp b/src/dfm-search/dfm-search-client/output/json_output.cpp new file mode 100644 index 00000000..2a44ab57 --- /dev/null +++ b/src/dfm-search/dfm-search-client/output/json_output.cpp @@ -0,0 +1,244 @@ +// SPDX-FileCopyrightText: 2022 - 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "json_output.h" +#include <dfm-search/filenamesearchapi.h> +#include <dfm-search/contentsearchapi.h> +#include <dfm-search/ocrtextsearchapi.h> + +#include <QJsonDocument> +#include <iostream> + +using namespace dfmsearch; +using namespace std; + +void JsonOutput::setSearchContext(const QString &keyword, const QString &searchPath, + SearchType searchType, SearchMethod searchMethod) +{ + m_keyword = keyword; + m_searchPath = searchPath; + m_searchType = searchType; + m_searchMethod = searchMethod; +} + +QJsonValue JsonOutput::resultToJson(const SearchResult &result) +{ + if (m_searchType == SearchType::FileName) { + // 文件名搜索:直接返回路径字符串 + return result.path(); + } else if (m_searchType == SearchType::Content) { + // 内容搜索:返回包含路径和内容匹配的对象 + QJsonObject obj; + obj["path"] = result.path(); + ContentResultAPI contentResult(const_cast<SearchResult &>(result)); + obj["contentMatch"] = contentResult.highlightedContent(); + return obj; + } else if (m_searchType == SearchType::Ocr) { + // OCR 搜索:返回路径 + QJsonObject obj; + obj["path"] = result.path(); + return obj; + } + return result.path(); +} + +void JsonOutput::printJsonLine(const QJsonObject &obj) +{ + QJsonDocument doc(obj); + std::cout << doc.toJson(QJsonDocument::Compact).constData() << std::endl; +} + +void JsonOutput::outputSearchStarted() +{ + m_startTime = QDateTime::currentDateTime(); + m_collectedResults = QJsonArray(); // 清空收集的结果 + + if (m_streaming) { + outputStreamingStart(); + } + // 非流式模式:开始时不输出 +} + +void JsonOutput::outputStreamingStart() +{ + QJsonObject startObj; + startObj["type"] = "search_started"; + + QString searchTypeStr; + switch (m_searchType) { + case SearchType::FileName: + searchTypeStr = "filename"; + break; + case SearchType::Content: + searchTypeStr = "content"; + break; + case SearchType::Ocr: + searchTypeStr = "ocr"; + break; + default: + searchTypeStr = "unknown"; + } + + QJsonObject searchInfo; + searchInfo["keyword"] = m_keyword; + searchInfo["searchPath"] = m_searchPath; + searchInfo["searchType"] = searchTypeStr; + searchInfo["searchMethod"] = (m_searchMethod == SearchMethod::Indexed ? "indexed" : "realtime"); + searchInfo["caseSensitive"] = m_options.caseSensitive(); + searchInfo["includeHidden"] = m_options.includeHidden(); + + // 添加时间范围过滤信息 + if (m_options.hasTimeRangeFilter()) { + DFMSEARCH::TimeRangeFilter timeFilter = m_options.timeRangeFilter(); + QJsonObject timeFilterInfo; + timeFilterInfo["field"] = (timeFilter.timeField() == DFMSEARCH::TimeField::BirthTime) ? "birth" : "modify"; + auto [start, end] = timeFilter.resolveTimeRange(); + timeFilterInfo["startTime"] = start.toString(Qt::ISODate); + timeFilterInfo["endTime"] = end.toString(Qt::ISODate); + searchInfo["timeRangeFilter"] = timeFilterInfo; + } + + startObj["search"] = searchInfo; + startObj["timestamp"] = QDateTime::currentDateTime().toString(Qt::ISODate); + + printJsonLine(startObj); +} + +void JsonOutput::outputResult(const SearchResult &result) +{ + if (m_streaming) { + outputStreamingResult(result); + } else { + // 非流式模式:收集结果 + m_collectedResults.append(resultToJson(result)); + } +} + +void JsonOutput::outputStreamingResult(const SearchResult &result) +{ + QJsonObject resultObj; + resultObj["type"] = "result"; + resultObj["data"] = resultToJson(result); + printJsonLine(resultObj); +} + +void JsonOutput::outputSearchFinished(const QList<SearchResult> &results) +{ + if (m_streaming) { + outputStreamingFinish(results); + } else { + outputCompleteResult(results); + } + + emit finished(); +} + +void JsonOutput::outputStreamingFinish(const QList<SearchResult> &results) +{ + QJsonObject endObj; + endObj["type"] = "search_finished"; + + QJsonObject status; + status["state"] = "success"; + status["totalResults"] = results.size(); + + endObj["status"] = status; + + QJsonObject timestamps; + timestamps["finished"] = QDateTime::currentDateTime().toString(Qt::ISODate); + timestamps["duration"] = m_startTime.msecsTo(QDateTime::currentDateTime()); + + endObj["timestamps"] = timestamps; + printJsonLine(endObj); +} + +void JsonOutput::outputCompleteResult(const QList<SearchResult> &results) +{ + QJsonObject root; + + // 搜索信息 + QJsonObject searchInfo; + searchInfo["keyword"] = m_keyword; + searchInfo["searchPath"] = m_searchPath; + + QString searchTypeStr; + switch (m_searchType) { + case SearchType::FileName: + searchTypeStr = "filename"; + break; + case SearchType::Content: + searchTypeStr = "content"; + break; + case SearchType::Ocr: + searchTypeStr = "ocr"; + break; + default: + searchTypeStr = "unknown"; + } + searchInfo["searchType"] = searchTypeStr; + searchInfo["searchMethod"] = (m_searchMethod == SearchMethod::Indexed ? "indexed" : "realtime"); + searchInfo["caseSensitive"] = m_options.caseSensitive(); + searchInfo["includeHidden"] = m_options.includeHidden(); + + // 添加时间范围过滤信息 + if (m_options.hasTimeRangeFilter()) { + DFMSEARCH::TimeRangeFilter timeFilter = m_options.timeRangeFilter(); + QJsonObject timeFilterInfo; + timeFilterInfo["field"] = (timeFilter.timeField() == DFMSEARCH::TimeField::BirthTime) ? "birth" : "modify"; + auto [start, end] = timeFilter.resolveTimeRange(); + timeFilterInfo["startTime"] = start.toString(Qt::ISODate); + timeFilterInfo["endTime"] = end.toString(Qt::ISODate); + searchInfo["timeRangeFilter"] = timeFilterInfo; + } + + root["search"] = searchInfo; + + // 时间戳 + QDateTime endTime = QDateTime::currentDateTime(); + qint64 duration = m_startTime.msecsTo(endTime); + + QJsonObject timestamps; + timestamps["started"] = m_startTime.toString(Qt::ISODate); + timestamps["finished"] = endTime.toString(Qt::ISODate); + timestamps["duration"] = duration; + root["timestamps"] = timestamps; + + // 状态 + QJsonObject status; + status["state"] = "success"; + status["totalResults"] = m_collectedResults.isEmpty() ? results.size() : m_collectedResults.size(); + root["status"] = status; + + // 结果数组 + if (m_collectedResults.isEmpty() && !results.isEmpty()) { + // 如果结果没有被 resultsFound 收集,则在这里转换 + for (const auto &result : results) { + m_collectedResults.append(resultToJson(result)); + } + } + root["results"] = m_collectedResults; + + // 输出完整 JSON + QJsonDocument doc(root); + std::cout << doc.toJson(QJsonDocument::Indented).constData() << std::endl; +} + +void JsonOutput::outputSearchCancelled() +{ + QJsonObject cancelObj; + cancelObj["type"] = "search_cancelled"; + cancelObj["timestamp"] = QDateTime::currentDateTime().toString(Qt::ISODate); + printJsonLine(cancelObj); + emit finished(); +} + +void JsonOutput::outputError(const DFMSEARCH::SearchError &error) +{ + QJsonObject errorObj; + errorObj["type"] = "error"; + errorObj["name"] = error.name(); + errorObj["message"] = error.message(); + errorObj["timestamp"] = QDateTime::currentDateTime().toString(Qt::ISODate); + printJsonLine(errorObj); +} diff --git a/src/dfm-search/dfm-search-client/output/json_output.h b/src/dfm-search/dfm-search-client/output/json_output.h new file mode 100644 index 00000000..af2ae365 --- /dev/null +++ b/src/dfm-search/dfm-search-client/output/json_output.h @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2022 - 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef JSON_OUTPUT_H +#define JSON_OUTPUT_H + +#include "output_formatter.h" +#include <dfm-search/searchoptions.h> + +#include <QJsonArray> +#include <QDateTime> + +namespace dfmsearch { + +/** + * @brief JSON格式输出器 + * + * 支持流式和非流式两种JSON输出模式 + */ +class JsonOutput : public OutputFormatter +{ + Q_OBJECT + +public: + explicit JsonOutput(bool streaming = false, QObject *parent = nullptr) + : OutputFormatter(parent) + , m_streaming(streaming) { } + + void setSearchContext(const QString &keyword, const QString &searchPath, + SearchType searchType, SearchMethod searchMethod) override; + + void outputSearchStarted() override; + void outputResult(const SearchResult &result) override; + void outputSearchFinished(const QList<SearchResult> &results) override; + void outputSearchCancelled() override; + void outputError(const DFMSEARCH::SearchError &error) override; + + /** + * @brief 设置搜索选项 + */ + void setSearchOptions(const SearchOptions &options) { m_options = options; } + +private: + QJsonValue resultToJson(const SearchResult &result); + void printJsonLine(const QJsonObject &obj); + + // 流式输出方法 + void outputStreamingStart(); + void outputStreamingResult(const SearchResult &result); + void outputStreamingFinish(const QList<SearchResult> &results); + + // 非流式输出方法 + void outputCompleteResult(const QList<SearchResult> &results); + +private: + QString m_keyword; + QString m_searchPath; + SearchType m_searchType = SearchType::FileName; + SearchMethod m_searchMethod = SearchMethod::Indexed; + SearchOptions m_options; + QDateTime m_startTime; + + bool m_streaming; + QJsonArray m_collectedResults; +}; + +} // namespace dfmsearch + +#endif // JSON_OUTPUT_H diff --git a/src/dfm-search/dfm-search-client/output/output_formatter.h b/src/dfm-search/dfm-search-client/output/output_formatter.h new file mode 100644 index 00000000..d6be9fdc --- /dev/null +++ b/src/dfm-search/dfm-search-client/output/output_formatter.h @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2022 - 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef OUTPUT_FORMATTER_H +#define OUTPUT_FORMATTER_H + +#include <dfm-search/dsearch_global.h> +#include <dfm-search/searchresult.h> +#include <dfm-search/searchquery.h> + +#include <QObject> +#include <QJsonObject> + +namespace dfmsearch { + +/** + * @brief 搜索结果输出格式化器基类 + * + * 定义输出格式化的抽象接口,遵循依赖倒置原则 (DIP) + */ +class OutputFormatter : public QObject +{ + Q_OBJECT + +public: + explicit OutputFormatter(QObject *parent = nullptr) + : QObject(parent) { } + virtual ~OutputFormatter() = default; + + /** + * @brief 设置搜索上下文信息 + */ + virtual void setSearchContext(const QString &keyword, const QString &searchPath, + SearchType searchType, SearchMethod searchMethod) = 0; + + /** + * @brief 输出搜索开始 + */ + virtual void outputSearchStarted() = 0; + + /** + * @brief 输出单个搜索结果 + */ + virtual void outputResult(const SearchResult &result) = 0; + + /** + * @brief 输出搜索结束 + */ + virtual void outputSearchFinished(const QList<SearchResult> &results) = 0; + + /** + * @brief 输出搜索取消 + */ + virtual void outputSearchCancelled() = 0; + + /** + * @brief 输出错误 + */ + virtual void outputError(const DFMSEARCH::SearchError &error) = 0; + +Q_SIGNALS: + /** + * @brief 输出完成信号(用于退出应用) + */ + void finished(); +}; + +} // namespace dfmsearch + +#endif // OUTPUT_FORMATTER_H diff --git a/src/dfm-search/dfm-search-client/output/text_output.cpp b/src/dfm-search/dfm-search-client/output/text_output.cpp new file mode 100644 index 00000000..aabf79ca --- /dev/null +++ b/src/dfm-search/dfm-search-client/output/text_output.cpp @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2022 - 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "text_output.h" +#include <dfm-search/filenamesearchapi.h> +#include <dfm-search/contentsearchapi.h> +#include <dfm-search/ocrtextsearchapi.h> + +#include <iostream> + +using namespace dfmsearch; +using namespace std; + +void TextOutput::setSearchContext(const QString &keyword, const QString &searchPath, + SearchType searchType, SearchMethod searchMethod) +{ + m_keyword = keyword; + m_searchPath = searchPath; + m_searchType = searchType; + m_searchMethod = searchMethod; +} + +void TextOutput::setTimeFilterInfo(bool hasFilter, const DFMSEARCH::TimeRangeFilter &filter) +{ + m_hasTimeFilter = hasFilter; + m_timeFilter = filter; +} + +void TextOutput::outputSearchStarted() +{ + std::cout << "Search started..." << std::endl; + std::cout << "Searching for: " << m_keyword.toStdString() << std::endl; + std::cout << "In path: " << m_searchPath.toStdString() << std::endl; + + QString typeStr = "Filename"; + if (m_searchType == SearchType::Content) + typeStr = "Content"; + else if (m_searchType == SearchType::Ocr) + typeStr = "Ocr"; + std::cout << "Search type: " << typeStr.toStdString() << std::endl; + std::cout << "Search method: " << (m_searchMethod == SearchMethod::Indexed ? "Indexed" : "Realtime") << std::endl; + + // 打印文件扩展名过滤 + if (!m_fileExtensionsFilter.isEmpty()) { + std::cout << "File extensions filter: " << m_fileExtensionsFilter.toStdString() << std::endl; + } + + // 打印时间范围过滤 + if (m_hasTimeFilter) { + std::cout << "Time range filter: "; + std::cout << (m_timeFilter.timeField() == DFMSEARCH::TimeField::BirthTime ? "birth_time" : "modify_time"); + auto [start, end] = m_timeFilter.resolveTimeRange(); + std::cout << " from " << start.toString("yyyy-MM-dd HH:mm:ss").toStdString() + << " to " << end.toString("yyyy-MM-dd HH:mm:ss").toStdString(); + std::cout << std::endl; + } +} + +void TextOutput::printSearchResult(const SearchResult &result) +{ + std::cout << "Found: " << result.path().toStdString() << std::endl; + + if (m_searchType == SearchType::FileName) { + FileNameResultAPI resultAPI(const_cast<SearchResult &>(result)); + + if (resultAPI.isDirectory()) { + std::cout << " Type: Directory" << std::endl; + } else { + std::cout << " Type: " << resultAPI.fileType().toStdString() << std::endl; + std::cout << " Size: " << resultAPI.size().toStdString() << " bytes" << std::endl; + } + + std::cout << " Modified: " << resultAPI.modifiedTime().toStdString() << std::endl; + } else if (m_searchType == SearchType::Content) { + ContentResultAPI contentResult(const_cast<SearchResult &>(result)); + std::cout << " Content match: " << contentResult.highlightedContent().toStdString() << std::endl; + } else if (m_searchType == SearchType::Ocr) { + OcrTextResultAPI ocrResult(const_cast<SearchResult &>(result)); + std::cout << " OCR text match" << std::endl; + } + + std::cout << std::endl; +} + +void TextOutput::outputResult(const SearchResult &result) +{ + printSearchResult(result); +} + +void TextOutput::outputSearchFinished(const QList<SearchResult> &results) +{ + std::cout << "Search finished. Total results: " << results.size() << std::endl; + + // 如果禁用了 resultFoundEnabled,则在这里输出结果 + if (!m_options.resultFoundEnabled()) { + for (const auto &result : results) { + printSearchResult(result); + } + } + + emit finished(); +} + +void TextOutput::outputSearchCancelled() +{ + std::cout << "Search cancelled" << std::endl; + emit finished(); +} + +void TextOutput::outputError(const DFMSEARCH::SearchError &error) +{ + std::cerr << "[Error]: " << error.code() + << "[Name]: " << error.name().toStdString() + << "[Message]:" << error.message().toStdString() << std::endl; +} diff --git a/src/dfm-search/dfm-search-client/output/text_output.h b/src/dfm-search/dfm-search-client/output/text_output.h new file mode 100644 index 00000000..f1107937 --- /dev/null +++ b/src/dfm-search/dfm-search-client/output/text_output.h @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2022 - 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef TEXT_OUTPUT_H +#define TEXT_OUTPUT_H + +#include "output_formatter.h" +#include <dfm-search/searchoptions.h> + +namespace dfmsearch { + +/** + * @brief 文本格式输出器 + * + * 以人类可读的文本格式输出搜索结果 + */ +class TextOutput : public OutputFormatter +{ + Q_OBJECT + +public: + explicit TextOutput(QObject *parent = nullptr) + : OutputFormatter(parent) { } + + void setSearchContext(const QString &keyword, const QString &searchPath, + SearchType searchType, SearchMethod searchMethod) override; + + void outputSearchStarted() override; + void outputResult(const SearchResult &result) override; + void outputSearchFinished(const QList<SearchResult> &results) override; + void outputSearchCancelled() override; + void outputError(const DFMSEARCH::SearchError &error) override; + + /** + * @brief 设置文件扩展名过滤(用于输出提示) + */ + void setFileExtensionsFilter(const QString &extensions) { m_fileExtensionsFilter = extensions; } + + /** + * @brief 设置时间过滤信息 + */ + void setTimeFilterInfo(bool hasFilter, const DFMSEARCH::TimeRangeFilter &filter); + + /** + * @brief 设置搜索选项 + */ + void setSearchOptions(const SearchOptions &options) { m_options = options; } + +private: + void printSearchResult(const SearchResult &result); + +private: + QString m_keyword; + QString m_searchPath; + SearchType m_searchType = SearchType::FileName; + SearchMethod m_searchMethod = SearchMethod::Indexed; + SearchOptions m_options; + QString m_fileExtensionsFilter; + bool m_hasTimeFilter = false; + DFMSEARCH::TimeRangeFilter m_timeFilter; +}; + +} // namespace dfmsearch + +#endif // TEXT_OUTPUT_H diff --git a/src/dfm-search/dfm-search-client/time_parser.cpp b/src/dfm-search/dfm-search-client/time_parser.cpp new file mode 100644 index 00000000..9978d00c --- /dev/null +++ b/src/dfm-search/dfm-search-client/time_parser.cpp @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2022 - 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "time_parser.h" + +#include <QRegularExpression> +#include <QStringList> + +using namespace dfmsearch; + +bool TimeParser::parseTimeLast(const QString &arg, int &value, DFMSEARCH::TimeUnit &unit) +{ + if (arg.isEmpty()) + return false; + + // 提取数字和单位 + QRegularExpression re("^(\\d+)([mhdwMy])$"); + QRegularExpressionMatch match = re.match(arg); + + if (!match.hasMatch()) + return false; + + bool ok; + value = match.captured(1).toInt(&ok); + if (!ok || value < 0) + return false; + + QString unitChar = match.captured(2); + if (unitChar == "m") { + unit = DFMSEARCH::TimeUnit::Minutes; + } else if (unitChar == "h") { + unit = DFMSEARCH::TimeUnit::Hours; + } else if (unitChar == "d") { + unit = DFMSEARCH::TimeUnit::Days; + } else if (unitChar == "w") { + unit = DFMSEARCH::TimeUnit::Weeks; + } else if (unitChar == "M") { + unit = DFMSEARCH::TimeUnit::Months; + } else if (unitChar == "y") { + unit = DFMSEARCH::TimeUnit::Years; + } else { + return false; + } + + return true; +} + +bool TimeParser::parseTimeRange(const QString &arg, QDateTime &start, QDateTime &end) +{ + if (arg.isEmpty()) + return false; + + QStringList parts = arg.split(','); + if (parts.size() != 2) + return false; + + QString startStr = parts[0].trimmed(); + QString endStr = parts[1].trimmed(); + + // 优先尝试带时间的格式,再尝试不带时间的 + start = QDateTime::fromString(startStr, "yyyy-MM-dd HH:mm"); + if (!start.isValid()) { + start = QDateTime::fromString(startStr, "yyyy-MM-dd"); + if (start.isValid()) { + start.setTime(QTime(0, 0, 0)); + } + } + + end = QDateTime::fromString(endStr, "yyyy-MM-dd HH:mm"); + if (!end.isValid()) { + end = QDateTime::fromString(endStr, "yyyy-MM-dd"); + if (end.isValid()) { + end.setTime(QTime(23, 59, 59)); + } + } + + return start.isValid() && end.isValid(); +} diff --git a/src/dfm-search/dfm-search-client/time_parser.h b/src/dfm-search/dfm-search-client/time_parser.h new file mode 100644 index 00000000..26cc9b1c --- /dev/null +++ b/src/dfm-search/dfm-search-client/time_parser.h @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2022 - 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef TIME_PARSER_H +#define TIME_PARSER_H + +#include <QString> +#include <QDateTime> +#include <dfm-search/dsearch_global.h> + +namespace dfmsearch { + +/** + * @brief 时间范围解析工具类 + * + * 提供时间参数解析功能,遵循单一职责原则 + */ +class TimeParser +{ +public: + /** + * @brief 解析 --time-last 参数(如 "3d", "2h", "30m") + * @param arg 输入字符串 + * @param value 输出数值 + * @param unit 输出时间单位 + * @return 解析成功返回true + */ + static bool parseTimeLast(const QString &arg, int &value, DFMSEARCH::TimeUnit &unit); + + /** + * @brief 解析 --time-range 参数(如 "2025-01-01,2025-12-31") + * @param arg 输入字符串 + * @param start 输出开始时间 + * @param end 输出结束时间 + * @return 解析成功返回true + */ + static bool parseTimeRange(const QString &arg, QDateTime &start, QDateTime &end); +}; + +} // namespace dfmsearch + +#endif // TIME_PARSER_H From 676c1441d7b1c69a2be049f9db8c26e23bcdab6e Mon Sep 17 00:00:00 2001 From: Zhang Sheng <zhangsheng@uniontech.com> Date: Mon, 13 Apr 2026 11:38:10 +0800 Subject: [PATCH 14/21] feat: enhance search result metadata and output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Added TimeResultAPI class to centralize time-related operations 2. Expanded metadata in ContentResultAPI, FileNameResultAPI, and OcrTextResultAPI with: - Extended file attributes (name, extension, hidden status) - Detailed time information (creation/modification timestamps and formatted strings) 3. Implemented verbose output mode in CLI with -v flag showing full metadata 4. Improved JSON output to include complete file metadata when verbose 5. Deprecated legacy time-related methods in favor of timestamp-based API 6. Added new field processing in all search strategies to populate extended metadata Log: Enhanced search results with detailed metadata and improved output options Influence: 1. Test all search types (filename, content, OCR) with verbose mode 2. Verify JSON output contains all metadata fields 3. Check time-related filtering with new timestamp fields 4. Test backward compatibility with legacy modifiedTime() method 5. Verify output formatting in both simple and verbose modes 6. Test hidden file detection and display feat: 增强搜索结果的元数据和输出功能 1. 添加 TimeResultAPI 类来集中处理时间相关操作 2. 扩展 ContentResultAPI, FileNameResultAPI 和 OcrTextResultAPI 的元 数据: - 扩展文件属性(名称、扩展名、隐藏状态) - 详细时间信息(创建/修改时间戳和格式化字符串) 3. 在 CLI 中实现详细输出模式(-v 标志显示完整元数据) 4. 改进 JSON 输出以包含完整的文件元数据(详细模式时) 5. 废弃旧版时间相关方法,推荐使用基于时间戳的 API 6. 在所有搜索策略中添加新字段处理以填充扩展元数据 Log: 增强搜索结果详情的元数据和改进输出选项 Influence: 1. 使用详细模式测试所有搜索类型(文件名、内容、OCR) 2. 验证 JSON 输出包含所有元数据字段 3. 使用新时间戳字段检查时间相关过滤 4. 测试与旧版 modifiedTime() 方法的向后兼容性 5. 验证简单和详细模式下的输出格式 6. 测试隐藏文件检测和显示 --- .../dfm-search/dfm-search/contentsearchapi.h | 71 +++++++++++ include/dfm-search/dfm-search/field_names.h | 3 +- .../dfm-search/dfm-search/filenamesearchapi.h | 89 +++++++++++++- .../dfm-search/dfm-search/ocrtextsearchapi.h | 71 +++++++++++ include/dfm-search/dfm-search/timeresultapi.h | 91 ++++++++++++++ .../dfm-search-client/cli_options.cpp | 6 + .../dfm-search-client/cli_options.h | 2 + src/dfm-search/dfm-search-client/main.cpp | 3 +- .../dfm-search-client/output/json_output.cpp | 116 +++++++++++++++++- .../dfm-search-client/output/json_output.h | 7 ++ .../output/output_formatter.h | 6 + .../dfm-search-client/output/text_output.cpp | 93 +++++++++++++- .../dfm-search-client/output/text_output.h | 7 ++ .../contentsearch/contentsearchapi.cpp | 61 +++++++++ .../contentstrategies/indexedstrategy.cpp | 35 ++++++ .../dfm-search-lib/core/timeresultapi.cpp | 54 ++++++++ .../filenamesearch/filenamesearchapi.cpp | 71 +++++++++++ .../filenamestrategies/indexedstrategy.cpp | 51 ++++++-- .../filenamestrategies/indexedstrategy.h | 4 +- .../filenamestrategies/realtimestrategy.cpp | 22 +++- .../ocrtextsearch/ocrtextsearchapi.cpp | 61 +++++++++ .../ocrtextstrategies/indexedstrategy.cpp | 44 +++++++ 22 files changed, 941 insertions(+), 27 deletions(-) create mode 100644 include/dfm-search/dfm-search/timeresultapi.h create mode 100644 src/dfm-search/dfm-search-lib/core/timeresultapi.cpp diff --git a/include/dfm-search/dfm-search/contentsearchapi.h b/include/dfm-search/dfm-search/contentsearchapi.h index 6c2d194a..abb1414c 100644 --- a/include/dfm-search/dfm-search/contentsearchapi.h +++ b/include/dfm-search/dfm-search/contentsearchapi.h @@ -107,6 +107,9 @@ class ContentOptionsAPI * * This class extends the base SearchResult with content search specific features, * such as highlighted content preview. + * + * When detailed results are enabled, this API provides access to additional + * metadata including filename, hidden status, and time information. */ class ContentResultAPI { @@ -117,6 +120,8 @@ class ContentResultAPI */ ContentResultAPI(SearchResult &result); + // ==================== Content Attributes ==================== + /** * @brief Get the highlighted content preview * @return The highlighted content as QString @@ -129,6 +134,72 @@ class ContentResultAPI */ void setHighlightedContent(const QString &content); + // ==================== Extended Attributes ==================== + + /** + * @brief Get the file name (without path) + * @return The file name + */ + QString filename() const; + + /** + * @brief Set the file name + * @param name The file name to set + */ + void setFilename(const QString &name); + + /** + * @brief Check if the file is hidden + * @return true if the file is hidden, false otherwise + */ + bool isHidden() const; + + /** + * @brief Set whether the file is hidden + * @param hidden true if the file is hidden, false otherwise + */ + void setIsHidden(bool hidden); + + // ==================== Modification Time ==================== + + /** + * @brief Set the modification time timestamp + * @param timestamp Unix timestamp in seconds + */ + void setModifyTimestamp(qint64 timestamp); + + /** + * @brief Get the modification time timestamp + * @return Unix timestamp in seconds, 0 if not set + */ + qint64 modifyTimestamp() const; + + /** + * @brief Get the modification time as a formatted string + * @return Formatted time string (yyyy-MM-dd HH:mm:ss) + */ + QString modifyTimeString() const; + + // ==================== Birth/Creation Time ==================== + + /** + * @brief Set the birth/creation time timestamp + * @param timestamp Unix timestamp in seconds + */ + void setBirthTimestamp(qint64 timestamp); + + /** + * @brief Get the birth/creation time timestamp + * @return Unix timestamp in seconds, 0 if not set + */ + qint64 birthTimestamp() const; + + /** + * @brief Get the birth/creation time as a formatted string + * @return Formatted time string (yyyy-MM-dd HH:mm:ss) + */ + QString birthTimeString() const; + private: SearchResult &m_result; }; diff --git a/include/dfm-search/dfm-search/field_names.h b/include/dfm-search/dfm-search/field_names.h index 02c2cdc5..27d0ff31 100644 --- a/include/dfm-search/dfm-search/field_names.h +++ b/include/dfm-search/dfm-search/field_names.h @@ -20,7 +20,6 @@ constexpr const wchar_t kFileNameLower[] = L"file_name_lower"; constexpr const wchar_t kFullPath[] = L"full_path"; constexpr const wchar_t kIsHidden[] = L"is_hidden"; constexpr const wchar_t kModifyTime[] = L"modify_time"; -constexpr const wchar_t kModifyTimeStr[] = L"modify_time_str"; constexpr const wchar_t kBirthTime[] = L"birth_time"; constexpr const wchar_t kFileSizeStr[] = L"file_size_str"; constexpr const wchar_t kPinyin[] = L"pinyin"; @@ -47,7 +46,7 @@ constexpr const wchar_t kPath[] = L"path"; constexpr const wchar_t kIsHidden[] = L"is_hidden"; constexpr const wchar_t kAncestorPaths[] = L"ancestor_paths"; constexpr const wchar_t kBirthTime[] = L"birth_time"; -constexpr const wchar_t kModifyTime[] = L"modify_time"; +constexpr const wchar_t kModifyTime[] = L"modify_time"; } // namespace OcrText } // namespace LuceneFieldNames diff --git a/include/dfm-search/dfm-search/filenamesearchapi.h b/include/dfm-search/dfm-search/filenamesearchapi.h index efe48671..4af8fba7 100644 --- a/include/dfm-search/dfm-search/filenamesearchapi.h +++ b/include/dfm-search/dfm-search/filenamesearchapi.h @@ -94,6 +94,9 @@ class FileNameOptionsAPI * * This class extends the base SearchResult with file name search specific features, * such as file size, modification time, and file type information. + * + * When detailed results are enabled, this API provides access to all indexed fields + * including filename, extension, hidden status, and both modification and birth times. */ class FileNameResultAPI { @@ -104,6 +107,8 @@ class FileNameResultAPI */ explicit FileNameResultAPI(SearchResult &result); + // ==================== Basic Attributes ==================== + /** * @brief Get the file size * @return The file size as formatted string @@ -117,14 +122,16 @@ class FileNameResultAPI void setSize(const QString &size); /** - * @brief Get the file modification time + * @brief Get the file modification time (legacy, use modifyTimeString instead) * @return The modification time as formatted string + * @deprecated Use modifyTimeString() for formatted string or modifyTimestamp() for raw timestamp */ QString modifiedTime() const; /** - * @brief Set the file modification time + * @brief Set the file modification time (legacy) * @param time The modification time as formatted string + * @deprecated Use setModifyTimestamp() instead */ void setModifiedTime(const QString &time); @@ -152,6 +159,84 @@ class FileNameResultAPI */ void setFileType(const QString &type) const; + // ==================== Extended Attributes ==================== + + /** + * @brief Get the file name (without path) + * @return The file name + */ + QString filename() const; + + /** + * @brief Set the file name + * @param name The file name to set + */ + void setFilename(const QString &name); + + /** + * @brief Get the file extension + * @return The file extension (lowercase, without dot) + */ + QString fileExtension() const; + + /** + * @brief Set the file extension + * @param ext The file extension to set + */ + void setFileExtension(const QString &ext); + + /** + * @brief Check if the file is hidden + * @return true if the file is hidden, false otherwise + */ + bool isHidden() const; + + /** + * @brief Set whether the file is hidden + * @param hidden true if the file is hidden, false otherwise + */ + void setIsHidden(bool hidden); + + // ==================== Modification Time ==================== + + /** + * @brief Set the modification time timestamp + * @param timestamp Unix timestamp in seconds + */ + void setModifyTimestamp(qint64 timestamp); + + /** + * @brief Get the modification time timestamp + * @return Unix timestamp in seconds, 0 if not set + */ + qint64 modifyTimestamp() const; + + /** + * @brief Get the modification time as a formatted string + * @return Formatted time string (yyyy-MM-dd HH:mm:ss) + */ + QString modifyTimeString() const; + + // ==================== Birth/Creation Time ==================== + + /** + * @brief Set the birth/creation time timestamp + * @param timestamp Unix timestamp in seconds + */ + void setBirthTimestamp(qint64 timestamp); + + /** + * @brief Get the birth/creation time timestamp + * @return Unix timestamp in seconds, 0 if not set + */ + qint64 birthTimestamp() const; + + /** + * @brief Get the birth/creation time as a formatted string + * @return Formatted time string (yyyy-MM-dd HH:mm:ss) + */ + QString birthTimeString() const; + private: SearchResult &m_result; }; diff --git a/include/dfm-search/dfm-search/ocrtextsearchapi.h b/include/dfm-search/dfm-search/ocrtextsearchapi.h index 54d896b4..4e854ee2 100644 --- a/include/dfm-search/dfm-search/ocrtextsearchapi.h +++ b/include/dfm-search/dfm-search/ocrtextsearchapi.h @@ -58,6 +58,9 @@ class OcrTextOptionsAPI * * This class extends the base SearchResult with OCR text search specific features. * Note: OCR text search does not support content highlighting like content search. + * + * When detailed results are enabled, this API provides access to additional + * metadata including filename, hidden status, and time information. */ class OcrTextResultAPI { @@ -68,6 +71,8 @@ class OcrTextResultAPI */ OcrTextResultAPI(SearchResult &result); + // ==================== OCR Content Attributes ==================== + /** * @brief Get the OCR extracted text content * @return The OCR extracted text as QString @@ -80,6 +85,72 @@ class OcrTextResultAPI */ void setOcrContent(const QString &content); + // ==================== Extended Attributes ==================== + + /** + * @brief Get the file name (without path) + * @return The file name + */ + QString filename() const; + + /** + * @brief Set the file name + * @param name The file name to set + */ + void setFilename(const QString &name); + + /** + * @brief Check if the file is hidden + * @return true if the file is hidden, false otherwise + */ + bool isHidden() const; + + /** + * @brief Set whether the file is hidden + * @param hidden true if the file is hidden, false otherwise + */ + void setIsHidden(bool hidden); + + // ==================== Modification Time ==================== + + /** + * @brief Set the modification time timestamp + * @param timestamp Unix timestamp in seconds + */ + void setModifyTimestamp(qint64 timestamp); + + /** + * @brief Get the modification time timestamp + * @return Unix timestamp in seconds, 0 if not set + */ + qint64 modifyTimestamp() const; + + /** + * @brief Get the modification time as a formatted string + * @return Formatted time string (yyyy-MM-dd HH:mm:ss) + */ + QString modifyTimeString() const; + + // ==================== Birth/Creation Time ==================== + + /** + * @brief Set the birth/creation time timestamp + * @param timestamp Unix timestamp in seconds + */ + void setBirthTimestamp(qint64 timestamp); + + /** + * @brief Get the birth/creation time timestamp + * @return Unix timestamp in seconds, 0 if not set + */ + qint64 birthTimestamp() const; + + /** + * @brief Get the birth/creation time as a formatted string + * @return Formatted time string (yyyy-MM-dd HH:mm:ss) + */ + QString birthTimeString() const; + private: SearchResult &m_result; }; diff --git a/include/dfm-search/dfm-search/timeresultapi.h b/include/dfm-search/dfm-search/timeresultapi.h new file mode 100644 index 00000000..244dcecb --- /dev/null +++ b/include/dfm-search/dfm-search/timeresultapi.h @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: 2025 - 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later +#ifndef TIMERESULTAPI_H +#define TIMERESULTAPI_H + +#include <QString> +#include <QDateTime> + +#include <dfm-search/dsearch_global.h> + +DFM_SEARCH_BEGIN_NS + +class SearchResult; + +/** + * @brief The TimeResultAPI class provides shared time attribute access for search results + * + * This class provides a unified interface for accessing time-related attributes + * (modification time and birth/creation time) in SearchResult objects. It follows + * the DRY principle by centralizing time attribute handling across different + * search types (filename, content, OCR). + * + * Time values are stored as Unix timestamps (seconds since epoch) internally, + * and can be retrieved either as raw timestamps or as formatted time strings. + */ +class TimeResultAPI +{ +public: + /** + * @brief Constructor + * @param result The SearchResult object to operate on + */ + explicit TimeResultAPI(SearchResult &result); + + // ==================== Modification Time ==================== + + /** + * @brief Set the modification time timestamp + * @param timestamp Unix timestamp in seconds + */ + void setModifyTimestamp(qint64 timestamp); + + /** + * @brief Get the modification time timestamp + * @return Unix timestamp in seconds, 0 if not set + */ + qint64 modifyTimestamp() const; + + /** + * @brief Get the modification time as a formatted string + * @return Formatted time string (yyyy-MM-dd HH:mm:ss), empty if not set + */ + QString modifyTimeString() const; + + // ==================== Birth/Creation Time ==================== + + /** + * @brief Set the birth/creation time timestamp + * @param timestamp Unix timestamp in seconds + */ + void setBirthTimestamp(qint64 timestamp); + + /** + * @brief Get the birth/creation time timestamp + * @return Unix timestamp in seconds, 0 if not set + */ + qint64 birthTimestamp() const; + + /** + * @brief Get the birth/creation time as a formatted string + * @return Formatted time string (yyyy-MM-dd HH:mm:ss), empty if not set + */ + QString birthTimeString() const; + + // ==================== Utility Functions ==================== + + /** + * @brief Format a Unix timestamp to a human-readable string + * @param timestamp Unix timestamp in seconds + * @return Formatted time string (yyyy-MM-dd HH:mm:ss) + */ + static QString formatTimestamp(qint64 timestamp); + +private: + SearchResult &m_result; +}; + +DFM_SEARCH_END_NS + +#endif // TIMERESULTAPI_H diff --git a/src/dfm-search/dfm-search-client/cli_options.cpp b/src/dfm-search/dfm-search-client/cli_options.cpp index c0727f5e..e53bdcbb 100644 --- a/src/dfm-search/dfm-search-client/cli_options.cpp +++ b/src/dfm-search/dfm-search-client/cli_options.cpp @@ -28,6 +28,9 @@ CliOptions::CliOptions() , m_jsonOption(QStringList() << "json" << "j", "Output results in JSON format") + , m_verboseOption(QStringList() << "verbose" + << "v", + "Enable verbose output with detailed result information") , m_timeFieldOption(QStringList() << "time-field", "Time field to filter (birth or modify)", "field", "modify") , m_timeLastOption(QStringList() << "time-last", "Rolling time window (e.g., 3d, 2h, 30m)", "duration") , m_timeTodayOption(QStringList() << "time-today", "Filter files from today") @@ -62,6 +65,7 @@ void CliOptions::setupOptions() m_parser.addOption(m_maxPreviewOption); m_parser.addOption(m_wildcardOption); m_parser.addOption(m_jsonOption); + m_parser.addOption(m_verboseOption); // 时间范围过滤选项 m_parser.addOption(m_timeFieldOption); @@ -122,6 +126,7 @@ void CliOptions::printHelp() const std::cout << std::endl; std::cout << "Output Options:" << std::endl; std::cout << " --json, -j Output results in JSON format" << std::endl; + std::cout << " --verbose, -v Enable verbose output with detailed result information" << std::endl; std::cout << " --help Display this help" << std::endl; std::cout << std::endl; std::cout << "Examples:" << std::endl; @@ -195,6 +200,7 @@ bool CliOptions::parse(QCoreApplication &app, SearchCliConfig &config) config.pinyinEnabled = m_parser.isSet(m_pinyinOption); config.pinyinAcronymEnabled = m_parser.isSet(m_pinyinAcronymOption); config.jsonOutput = m_parser.isSet(m_jsonOption); + config.verbose = m_parser.isSet(m_verboseOption); // 解析过滤选项 if (m_parser.isSet(m_fileTypesOption)) { diff --git a/src/dfm-search/dfm-search-client/cli_options.h b/src/dfm-search/dfm-search-client/cli_options.h index 998df7e7..30611105 100644 --- a/src/dfm-search/dfm-search-client/cli_options.h +++ b/src/dfm-search/dfm-search-client/cli_options.h @@ -36,6 +36,7 @@ struct SearchCliConfig bool pinyinAcronymEnabled = false; bool wildcardEnabled = false; bool jsonOutput = false; + bool verbose = false; // 详细输出模式 // 过滤选项 QStringList fileTypes; @@ -94,6 +95,7 @@ class CliOptions QCommandLineOption m_maxPreviewOption; QCommandLineOption m_wildcardOption; QCommandLineOption m_jsonOption; + QCommandLineOption m_verboseOption; // 时间范围过滤选项 QCommandLineOption m_timeFieldOption; diff --git a/src/dfm-search/dfm-search-client/main.cpp b/src/dfm-search/dfm-search-client/main.cpp index f6ff3fe9..11241f88 100644 --- a/src/dfm-search/dfm-search-client/main.cpp +++ b/src/dfm-search/dfm-search-client/main.cpp @@ -29,7 +29,7 @@ static void configureSearchOptions(SearchOptions &options, const SearchCliConfig options.setIncludeHidden(config.includeHidden); options.setSearchPath(config.searchPath); options.setMaxResults(config.maxResults); - options.setDetailedResultsEnabled(true); + options.setDetailedResultsEnabled(config.verbose); // 使用 verbose 选项控制详细输出 if (config.searchMethod == SearchMethod::Realtime) { options.setResultFoundEnabled(true); @@ -171,6 +171,7 @@ int main(int argc, char *argv[]) if (textOutput) { textOutput->setSearchOptions(options); textOutput->setTimeFilterInfo(config.hasTimeFilter, config.timeFilter); + textOutput->setVerbose(config.verbose); if (!config.fileExtensions.isEmpty()) { textOutput->setFileExtensionsFilter(config.fileExtensions.join(',')); } diff --git a/src/dfm-search/dfm-search-client/output/json_output.cpp b/src/dfm-search/dfm-search-client/output/json_output.cpp index 2a44ab57..83f184e6 100644 --- a/src/dfm-search/dfm-search-client/output/json_output.cpp +++ b/src/dfm-search/dfm-search-client/output/json_output.cpp @@ -24,20 +24,126 @@ void JsonOutput::setSearchContext(const QString &keyword, const QString &searchP QJsonValue JsonOutput::resultToJson(const SearchResult &result) { - if (m_searchType == SearchType::FileName) { - // 文件名搜索:直接返回路径字符串 + // 如果不启用详细结果,只返回路径 + if (!m_options.detailedResultsEnabled()) { return result.path(); + } + + if (m_searchType == SearchType::FileName) { + // 文件名搜索:返回详细对象 + QJsonObject obj; + obj["path"] = result.path(); + + FileNameResultAPI resultAPI(const_cast<SearchResult &>(result)); + obj["isDirectory"] = resultAPI.isDirectory(); + + if (!resultAPI.isDirectory()) { + obj["fileType"] = resultAPI.fileType(); + obj["size"] = resultAPI.size(); + } + + QString filename = resultAPI.filename(); + if (!filename.isEmpty()) { + obj["filename"] = filename; + } + + QString ext = resultAPI.fileExtension(); + if (!ext.isEmpty()) { + obj["extension"] = ext; + } + + obj["isHidden"] = resultAPI.isHidden(); + + // 修改时间(包含时间戳和时间字符串) + qint64 modifyTs = resultAPI.modifyTimestamp(); + if (modifyTs > 0) { + QJsonObject modifyTimeObj; + modifyTimeObj["timestamp"] = modifyTs; + modifyTimeObj["formatted"] = resultAPI.modifyTimeString(); + obj["modifyTime"] = modifyTimeObj; + } + + // 创建时间(包含时间戳和时间字符串) + qint64 birthTs = resultAPI.birthTimestamp(); + if (birthTs > 0) { + QJsonObject birthTimeObj; + birthTimeObj["timestamp"] = birthTs; + birthTimeObj["formatted"] = resultAPI.birthTimeString(); + obj["birthTime"] = birthTimeObj; + } + + return obj; } else if (m_searchType == SearchType::Content) { // 内容搜索:返回包含路径和内容匹配的对象 QJsonObject obj; obj["path"] = result.path(); - ContentResultAPI contentResult(const_cast<SearchResult &>(result)); - obj["contentMatch"] = contentResult.highlightedContent(); + + ContentResultAPI resultAPI(const_cast<SearchResult &>(result)); + obj["contentMatch"] = resultAPI.highlightedContent(); + + QString filename = resultAPI.filename(); + if (!filename.isEmpty()) { + obj["filename"] = filename; + } + + obj["isHidden"] = resultAPI.isHidden(); + + // 修改时间 + qint64 modifyTs = resultAPI.modifyTimestamp(); + if (modifyTs > 0) { + QJsonObject modifyTimeObj; + modifyTimeObj["timestamp"] = modifyTs; + modifyTimeObj["formatted"] = resultAPI.modifyTimeString(); + obj["modifyTime"] = modifyTimeObj; + } + + // 创建时间 + qint64 birthTs = resultAPI.birthTimestamp(); + if (birthTs > 0) { + QJsonObject birthTimeObj; + birthTimeObj["timestamp"] = birthTs; + birthTimeObj["formatted"] = resultAPI.birthTimeString(); + obj["birthTime"] = birthTimeObj; + } + return obj; } else if (m_searchType == SearchType::Ocr) { - // OCR 搜索:返回路径 + // OCR 搜索:返回详细对象 QJsonObject obj; obj["path"] = result.path(); + + OcrTextResultAPI resultAPI(const_cast<SearchResult &>(result)); + + QString ocrContent = resultAPI.ocrContent(); + if (!ocrContent.isEmpty()) { + obj["ocrContent"] = ocrContent; + } + + QString filename = resultAPI.filename(); + if (!filename.isEmpty()) { + obj["filename"] = filename; + } + + obj["isHidden"] = resultAPI.isHidden(); + + // 修改时间 + qint64 modifyTs = resultAPI.modifyTimestamp(); + if (modifyTs > 0) { + QJsonObject modifyTimeObj; + modifyTimeObj["timestamp"] = modifyTs; + modifyTimeObj["formatted"] = resultAPI.modifyTimeString(); + obj["modifyTime"] = modifyTimeObj; + } + + // 创建时间 + qint64 birthTs = resultAPI.birthTimestamp(); + if (birthTs > 0) { + QJsonObject birthTimeObj; + birthTimeObj["timestamp"] = birthTs; + birthTimeObj["formatted"] = resultAPI.birthTimeString(); + obj["birthTime"] = birthTimeObj; + } + return obj; } return result.path(); diff --git a/src/dfm-search/dfm-search-client/output/json_output.h b/src/dfm-search/dfm-search-client/output/json_output.h index af2ae365..6890ef7b 100644 --- a/src/dfm-search/dfm-search-client/output/json_output.h +++ b/src/dfm-search/dfm-search-client/output/json_output.h @@ -41,6 +41,12 @@ class JsonOutput : public OutputFormatter */ void setSearchOptions(const SearchOptions &options) { m_options = options; } + /** + * @brief 设置是否启用详细输出模式 + * @param verbose true 启用详细输出 + */ + void setVerbose(bool verbose) { m_verbose = verbose; } + private: QJsonValue resultToJson(const SearchResult &result); void printJsonLine(const QJsonObject &obj); @@ -62,6 +68,7 @@ class JsonOutput : public OutputFormatter QDateTime m_startTime; bool m_streaming; + bool m_verbose = false; QJsonArray m_collectedResults; }; diff --git a/src/dfm-search/dfm-search-client/output/output_formatter.h b/src/dfm-search/dfm-search-client/output/output_formatter.h index d6be9fdc..d3383991 100644 --- a/src/dfm-search/dfm-search-client/output/output_formatter.h +++ b/src/dfm-search/dfm-search-client/output/output_formatter.h @@ -54,6 +54,12 @@ class OutputFormatter : public QObject */ virtual void outputSearchCancelled() = 0; + /** + * @brief 设置是否启用详细输出模式 + * @param verbose true 启用详细输出, false 只禁用简易输出 + */ + virtual void setVerbose(bool verbose) = 0; + /** * @brief 输出错误 */ diff --git a/src/dfm-search/dfm-search-client/output/text_output.cpp b/src/dfm-search/dfm-search-client/output/text_output.cpp index aabf79ca..c3994219 100644 --- a/src/dfm-search/dfm-search-client/output/text_output.cpp +++ b/src/dfm-search/dfm-search-client/output/text_output.cpp @@ -59,6 +59,13 @@ void TextOutput::outputSearchStarted() void TextOutput::printSearchResult(const SearchResult &result) { + // 非详细模式下只输出路径 + if (!m_verbose) { + std::cout << result.path().toStdString() << std::endl; + return; + } + + // 详细模式下输出所有信息 std::cout << "Found: " << result.path().toStdString() << std::endl; if (m_searchType == SearchType::FileName) { @@ -71,13 +78,89 @@ void TextOutput::printSearchResult(const SearchResult &result) std::cout << " Size: " << resultAPI.size().toStdString() << " bytes" << std::endl; } - std::cout << " Modified: " << resultAPI.modifiedTime().toStdString() << std::endl; + // 文件名和扩展名 + QString filename = resultAPI.filename(); + if (!filename.isEmpty()) { + std::cout << " Filename: " << filename.toStdString() << std::endl; + } + QString ext = resultAPI.fileExtension(); + if (!ext.isEmpty()) { + std::cout << " Extension: " << ext.toStdString() << std::endl; + } + + // 隐藏状态 + std::cout << " Hidden: " << (resultAPI.isHidden() ? "Yes" : "No") << std::endl; + + // 修改时间(同时输出时间戳和时间字符串) + qint64 modifyTs = resultAPI.modifyTimestamp(); + if (modifyTs > 0) { + std::cout << " Modified: " << resultAPI.modifyTimeString().toStdString() + << " (timestamp: " << modifyTs << ")" << std::endl; + } + + // 创建时间(同时输出时间戳和时间字符串) + qint64 birthTs = resultAPI.birthTimestamp(); + if (birthTs > 0) { + std::cout << " Created: " << resultAPI.birthTimeString().toStdString() + << " (timestamp: " << birthTs << ")" << std::endl; + } } else if (m_searchType == SearchType::Content) { - ContentResultAPI contentResult(const_cast<SearchResult &>(result)); - std::cout << " Content match: " << contentResult.highlightedContent().toStdString() << std::endl; + ContentResultAPI resultAPI(const_cast<SearchResult &>(result)); + + std::cout << " Content match: " << resultAPI.highlightedContent().toStdString() << std::endl; + + // 文件名 + QString filename = resultAPI.filename(); + if (!filename.isEmpty()) { + std::cout << " Filename: " << filename.toStdString() << std::endl; + } + + // 隐藏状态 + std::cout << " Hidden: " << (resultAPI.isHidden() ? "Yes" : "No") << std::endl; + + // 修改时间 + qint64 modifyTs = resultAPI.modifyTimestamp(); + if (modifyTs > 0) { + std::cout << " Modified: " << resultAPI.modifyTimeString().toStdString() + << " (timestamp: " << modifyTs << ")" << std::endl; + } + + // 创建时间 + qint64 birthTs = resultAPI.birthTimestamp(); + if (birthTs > 0) { + std::cout << " Created: " << resultAPI.birthTimeString().toStdString() + << " (timestamp: " << birthTs << ")" << std::endl; + } } else if (m_searchType == SearchType::Ocr) { - OcrTextResultAPI ocrResult(const_cast<SearchResult &>(result)); - std::cout << " OCR text match" << std::endl; + OcrTextResultAPI resultAPI(const_cast<SearchResult &>(result)); + + QString ocrContent = resultAPI.ocrContent(); + if (!ocrContent.isEmpty()) { + std::cout << " OCR content: " << ocrContent.toStdString() << std::endl; + } + + // 文件名 + QString filename = resultAPI.filename(); + if (!filename.isEmpty()) { + std::cout << " Filename: " << filename.toStdString() << std::endl; + } + + // 隐藏状态 + std::cout << " Hidden: " << (resultAPI.isHidden() ? "Yes" : "No") << std::endl; + + // 修改时间 + qint64 modifyTs = resultAPI.modifyTimestamp(); + if (modifyTs > 0) { + std::cout << " Modified: " << resultAPI.modifyTimeString().toStdString() + << " (timestamp: " << modifyTs << ")" << std::endl; + } + + // 创建时间 + qint64 birthTs = resultAPI.birthTimestamp(); + if (birthTs > 0) { + std::cout << " Created: " << resultAPI.birthTimeString().toStdString() + << " (timestamp: " << birthTs << ")" << std::endl; + } } std::cout << std::endl; diff --git a/src/dfm-search/dfm-search-client/output/text_output.h b/src/dfm-search/dfm-search-client/output/text_output.h index f1107937..817ab666 100644 --- a/src/dfm-search/dfm-search-client/output/text_output.h +++ b/src/dfm-search/dfm-search-client/output/text_output.h @@ -47,6 +47,12 @@ class TextOutput : public OutputFormatter */ void setSearchOptions(const SearchOptions &options) { m_options = options; } + /** + * @brief 设置是否启用详细输出模式 + * @param verbose true 启用详细输出, false 只输出路径 + */ + void setVerbose(bool verbose) { m_verbose = verbose; } + private: void printSearchResult(const SearchResult &result); @@ -59,6 +65,7 @@ class TextOutput : public OutputFormatter QString m_fileExtensionsFilter; bool m_hasTimeFilter = false; DFMSEARCH::TimeRangeFilter m_timeFilter; + bool m_verbose = false; }; } // namespace dfmsearch diff --git a/src/dfm-search/dfm-search-lib/contentsearch/contentsearchapi.cpp b/src/dfm-search/dfm-search-lib/contentsearch/contentsearchapi.cpp index e4edce5c..913df0ca 100644 --- a/src/dfm-search/dfm-search-lib/contentsearch/contentsearchapi.cpp +++ b/src/dfm-search/dfm-search-lib/contentsearch/contentsearchapi.cpp @@ -2,6 +2,9 @@ // // SPDX-License-Identifier: GPL-3.0-or-later #include <dfm-search/contentsearchapi.h> +#include <dfm-search/timeresultapi.h> + +#include <QDateTime> DFM_SEARCH_BEGIN_NS @@ -72,4 +75,62 @@ void ContentResultAPI::setHighlightedContent(const QString &content) m_result.setCustomAttribute("highlightedContent", content); } +// ==================== Extended Attributes ==================== + +QString ContentResultAPI::filename() const +{ + return m_result.customAttribute("filename").toString(); +} + +void ContentResultAPI::setFilename(const QString &name) +{ + m_result.setCustomAttribute("filename", name); +} + +bool ContentResultAPI::isHidden() const +{ + return m_result.customAttribute("isHidden").toBool(); +} + +void ContentResultAPI::setIsHidden(bool hidden) +{ + m_result.setCustomAttribute("isHidden", hidden); +} + +// ==================== Modification Time ==================== + +void ContentResultAPI::setModifyTimestamp(qint64 timestamp) +{ + m_result.setCustomAttribute("modifyTimestamp", timestamp); +} + +qint64 ContentResultAPI::modifyTimestamp() const +{ + return m_result.customAttribute("modifyTimestamp").toLongLong(); +} + +QString ContentResultAPI::modifyTimeString() const +{ + qint64 ts = modifyTimestamp(); + return ts > 0 ? TimeResultAPI::formatTimestamp(ts) : QString(); +} + +// ==================== Birth/Creation Time ==================== + +void ContentResultAPI::setBirthTimestamp(qint64 timestamp) +{ + m_result.setCustomAttribute("birthTimestamp", timestamp); +} + +qint64 ContentResultAPI::birthTimestamp() const +{ + return m_result.customAttribute("birthTimestamp").toLongLong(); +} + +QString ContentResultAPI::birthTimeString() const +{ + qint64 ts = birthTimestamp(); + return ts > 0 ? TimeResultAPI::formatTimestamp(ts) : QString(); +} + DFM_SEARCH_END_NS diff --git a/src/dfm-search/dfm-search-lib/contentsearch/contentstrategies/indexedstrategy.cpp b/src/dfm-search/dfm-search-lib/contentsearch/contentstrategies/indexedstrategy.cpp index f9161eec..bc498aa6 100644 --- a/src/dfm-search/dfm-search-lib/contentsearch/contentstrategies/indexedstrategy.cpp +++ b/src/dfm-search/dfm-search-lib/contentsearch/contentstrategies/indexedstrategy.cpp @@ -352,6 +352,41 @@ void ContentIndexedStrategy::processSearchResults(const Lucene::IndexSearcherPtr } } + // 设置详细结果(如果启用) + if (Q_UNLIKELY(m_options.detailedResultsEnabled())) { + // 文件名 + Lucene::String filenameField = doc->get(LuceneFieldNames::Content::kFilename); + if (!filenameField.empty()) { + resultApi.setFilename(QString::fromStdWString(filenameField)); + } + + // 隐藏状态 + Lucene::String hiddenField = doc->get(LuceneFieldNames::Content::kIsHidden); + if (!hiddenField.empty()) { + resultApi.setIsHidden(QString::fromStdWString(hiddenField).toLower() == "y"); + } + + // 修改时间 + Lucene::String modifyTimeField = doc->get(LuceneFieldNames::Content::kModifyTime); + if (!modifyTimeField.empty()) { + bool ok = false; + qint64 timestamp = QString::fromStdWString(modifyTimeField).toLongLong(&ok); + if (ok && timestamp > 0) { + resultApi.setModifyTimestamp(timestamp); + } + } + + // 创建时间 + Lucene::String birthTimeField = doc->get(LuceneFieldNames::Content::kBirthTime); + if (!birthTimeField.empty()) { + bool ok = false; + qint64 timestamp = QString::fromStdWString(birthTimeField).toLongLong(&ok); + if (ok && timestamp > 0) { + resultApi.setBirthTimestamp(timestamp); + } + } + } + // 添加到结果集合 m_results.append(result); diff --git a/src/dfm-search/dfm-search-lib/core/timeresultapi.cpp b/src/dfm-search/dfm-search-lib/core/timeresultapi.cpp new file mode 100644 index 00000000..067a13d9 --- /dev/null +++ b/src/dfm-search/dfm-search-lib/core/timeresultapi.cpp @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2025 - 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later +#include <dfm-search/timeresultapi.h> +#include <dfm-search/searchresult.h> + +DFM_SEARCH_BEGIN_NS + +TimeResultAPI::TimeResultAPI(SearchResult &result) + : m_result(result) +{ +} + +void TimeResultAPI::setModifyTimestamp(qint64 timestamp) +{ + m_result.setCustomAttribute("modifyTimestamp", timestamp); +} + +qint64 TimeResultAPI::modifyTimestamp() const +{ + return m_result.customAttribute("modifyTimestamp").toLongLong(); +} + +QString TimeResultAPI::modifyTimeString() const +{ + qint64 ts = modifyTimestamp(); + return ts > 0 ? formatTimestamp(ts) : QString(); +} + +void TimeResultAPI::setBirthTimestamp(qint64 timestamp) +{ + m_result.setCustomAttribute("birthTimestamp", timestamp); +} + +qint64 TimeResultAPI::birthTimestamp() const +{ + return m_result.customAttribute("birthTimestamp").toLongLong(); +} + +QString TimeResultAPI::birthTimeString() const +{ + qint64 ts = birthTimestamp(); + return ts > 0 ? formatTimestamp(ts) : QString(); +} + +QString TimeResultAPI::formatTimestamp(qint64 timestamp) +{ + if (timestamp <= 0) { + return QString(); + } + return QDateTime::fromSecsSinceEpoch(timestamp).toString("yyyy-MM-dd HH:mm:ss"); +} + +DFM_SEARCH_END_NS diff --git a/src/dfm-search/dfm-search-lib/filenamesearch/filenamesearchapi.cpp b/src/dfm-search/dfm-search-lib/filenamesearch/filenamesearchapi.cpp index 283e70ea..8770125a 100644 --- a/src/dfm-search/dfm-search-lib/filenamesearch/filenamesearchapi.cpp +++ b/src/dfm-search/dfm-search-lib/filenamesearch/filenamesearchapi.cpp @@ -2,6 +2,9 @@ // // SPDX-License-Identifier: GPL-3.0-or-later #include <dfm-search/filenamesearchapi.h> +#include <dfm-search/timeresultapi.h> + +#include <QDateTime> DFM_SEARCH_BEGIN_NS @@ -97,4 +100,72 @@ void FileNameResultAPI::setFileType(const QString &type) const m_result.setCustomAttribute("fileType", type); } +// ==================== Extended Attributes ==================== + +QString FileNameResultAPI::filename() const +{ + return m_result.customAttribute("filename").toString(); +} + +void FileNameResultAPI::setFilename(const QString &name) +{ + m_result.setCustomAttribute("filename", name); +} + +QString FileNameResultAPI::fileExtension() const +{ + return m_result.customAttribute("fileExtension").toString(); +} + +void FileNameResultAPI::setFileExtension(const QString &ext) +{ + m_result.setCustomAttribute("fileExtension", ext); +} + +bool FileNameResultAPI::isHidden() const +{ + return m_result.customAttribute("isHidden").toBool(); +} + +void FileNameResultAPI::setIsHidden(bool hidden) +{ + m_result.setCustomAttribute("isHidden", hidden); +} + +// ==================== Modification Time ==================== + +void FileNameResultAPI::setModifyTimestamp(qint64 timestamp) +{ + m_result.setCustomAttribute("modifyTimestamp", timestamp); +} + +qint64 FileNameResultAPI::modifyTimestamp() const +{ + return m_result.customAttribute("modifyTimestamp").toLongLong(); +} + +QString FileNameResultAPI::modifyTimeString() const +{ + qint64 ts = modifyTimestamp(); + return ts > 0 ? TimeResultAPI::formatTimestamp(ts) : QString(); +} + +// ==================== Birth/Creation Time ==================== + +void FileNameResultAPI::setBirthTimestamp(qint64 timestamp) +{ + m_result.setCustomAttribute("birthTimestamp", timestamp); +} + +qint64 FileNameResultAPI::birthTimestamp() const +{ + return m_result.customAttribute("birthTimestamp").toLongLong(); +} + +QString FileNameResultAPI::birthTimeString() const +{ + qint64 ts = birthTimestamp(); + return ts > 0 ? TimeResultAPI::formatTimestamp(ts) : QString(); +} + DFM_SEARCH_END_NS diff --git a/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.cpp b/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.cpp index 069f0dc6..7cc268e9 100644 --- a/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.cpp +++ b/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.cpp @@ -555,10 +555,7 @@ void FileNameIndexedStrategy::executeIndexQuery(const IndexQuery &query, const Q // 处理搜索结果 if (Q_UNLIKELY(m_options.detailedResultsEnabled())) { - QString type = QString::fromStdWString(doc->get(LuceneFieldNames::FileName::kFileType)); - QString time = QString::fromStdWString(doc->get(LuceneFieldNames::FileName::kModifyTimeStr)); - QString size = QString::fromStdWString(doc->get(LuceneFieldNames::FileName::kFileSizeStr)); - m_results.append(processSearchResult(path, type, time, size)); + m_results.append(processDetailedSearchResult(path, doc)); } else { // perf: quickly SearchResult result(path); @@ -578,16 +575,54 @@ void FileNameIndexedStrategy::executeIndexQuery(const IndexQuery &query, const Q qInfo() << "Filename result processing time:" << resultTimer.elapsed() << "ms"; } -SearchResult FileNameIndexedStrategy::processSearchResult(const QString &path, const QString &type, const QString &time, const QString &size) +SearchResult FileNameIndexedStrategy::processDetailedSearchResult( + const QString &path, + const Lucene::DocumentPtr &doc) { // 创建搜索结果 SearchResult result(path); FileNameResultAPI api(result); - api.setSize(size); - api.setModifiedTime(time); - api.setIsDirectory(type == "dir"); + + // 文件类型 + QString type = QString::fromStdWString(doc->get(LuceneFieldNames::FileName::kFileType)); api.setFileType(type); + api.setIsDirectory(type == "dir"); + + // 文件名和扩展名 + QString fileName = QString::fromStdWString(doc->get(LuceneFieldNames::FileName::kFileName)); + api.setFilename(fileName); + + QString fileExt = QString::fromStdWString(doc->get(LuceneFieldNames::FileName::kFileExt)); + api.setFileExtension(fileExt); + + // 文件大小 + QString size = QString::fromStdWString(doc->get(LuceneFieldNames::FileName::kFileSizeStr)); + api.setSize(size); + + // 隐藏状态 + QString isHiddenStr = QString::fromStdWString(doc->get(LuceneFieldNames::FileName::kIsHidden)).toLower(); + api.setIsHidden(isHiddenStr == "y"); + + // 修改时间 + QString modifyTimeStr = QString::fromStdWString(doc->get(LuceneFieldNames::FileName::kModifyTime)); + if (!modifyTimeStr.isEmpty()) { + bool ok = false; + qint64 modifyTimestamp = modifyTimeStr.toLongLong(&ok); + if (ok && modifyTimestamp > 0) { + api.setModifyTimestamp(modifyTimestamp); + } + } + + // 创建时间 + QString birthTimeStr = QString::fromStdWString(doc->get(LuceneFieldNames::FileName::kBirthTime)); + if (!birthTimeStr.isEmpty()) { + bool ok = false; + qint64 birthTimestamp = birthTimeStr.toLongLong(&ok); + if (ok && birthTimestamp > 0) { + api.setBirthTimestamp(birthTimestamp); + } + } return result; } diff --git a/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.h b/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.h index 974f062f..4e0ac0a9 100644 --- a/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.h +++ b/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.h @@ -93,8 +93,8 @@ class FileNameIndexedStrategy : public FileNameBaseStrategy // 构建布尔查询的辅助方法 BooleanQueryPtr buildBooleanTermsQuery(const IndexQuery &query, const AnalyzerPtr &analyzer) const; - // 处理搜索结果 - SearchResult processSearchResult(const QString &path, const QString &type, const QString &time, const QString &size); + // 处理详细搜索结果(读取所有索引字段) + SearchResult processDetailedSearchResult(const QString &path, const Lucene::DocumentPtr &doc); // 成员变量 QString m_indexDir; // 索引目录路径 diff --git a/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/realtimestrategy.cpp b/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/realtimestrategy.cpp index dd131084..dd0ff80f 100644 --- a/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/realtimestrategy.cpp +++ b/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/realtimestrategy.cpp @@ -180,9 +180,27 @@ void FileNameRealTimeStrategy::search(const SearchQuery &query) if (detailedResults) { FileNameResultAPI api(result); - api.setModifiedTime(info.lastModified().toString()); api.setIsDirectory(info.isDir()); - api.setFileType(info.suffix().isEmpty() ? (info.isDir() ? "directory" : "unknown") : info.suffix()); + + if (!info.isDir()) { + api.setFileType(info.suffix().isEmpty() ? "unknown" : info.suffix().toLower()); + api.setFileExtension(info.suffix().toLower()); + api.setSize(QString::number(info.size())); + } else { + api.setFileType("dir"); + } + + api.setFilename(info.fileName()); + api.setIsHidden(info.isHidden()); + + // 设置修改时间戳 + api.setModifyTimestamp(info.lastModified().toSecsSinceEpoch()); + + // 设置创建时间戳 + QDateTime birthTime = info.birthTime(); + if (birthTime.isValid()) { + api.setBirthTimestamp(birthTime.toSecsSinceEpoch()); + } } // 实时发送结果 diff --git a/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextsearchapi.cpp b/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextsearchapi.cpp index bd7d7bef..d7d1b06c 100644 --- a/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextsearchapi.cpp +++ b/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextsearchapi.cpp @@ -2,6 +2,9 @@ // // SPDX-License-Identifier: GPL-3.0-or-later #include <dfm-search/ocrtextsearchapi.h> +#include <dfm-search/timeresultapi.h> + +#include <QDateTime> DFM_SEARCH_BEGIN_NS @@ -38,4 +41,62 @@ void OcrTextResultAPI::setOcrContent(const QString &content) m_result.setCustomAttribute("ocrContent", content); } +// ==================== Extended Attributes ==================== + +QString OcrTextResultAPI::filename() const +{ + return m_result.customAttribute("filename").toString(); +} + +void OcrTextResultAPI::setFilename(const QString &name) +{ + m_result.setCustomAttribute("filename", name); +} + +bool OcrTextResultAPI::isHidden() const +{ + return m_result.customAttribute("isHidden").toBool(); +} + +void OcrTextResultAPI::setIsHidden(bool hidden) +{ + m_result.setCustomAttribute("isHidden", hidden); +} + +// ==================== Modification Time ==================== + +void OcrTextResultAPI::setModifyTimestamp(qint64 timestamp) +{ + m_result.setCustomAttribute("modifyTimestamp", timestamp); +} + +qint64 OcrTextResultAPI::modifyTimestamp() const +{ + return m_result.customAttribute("modifyTimestamp").toLongLong(); +} + +QString OcrTextResultAPI::modifyTimeString() const +{ + qint64 ts = modifyTimestamp(); + return ts > 0 ? TimeResultAPI::formatTimestamp(ts) : QString(); +} + +// ==================== Birth/Creation Time ==================== + +void OcrTextResultAPI::setBirthTimestamp(qint64 timestamp) +{ + m_result.setCustomAttribute("birthTimestamp", timestamp); +} + +qint64 OcrTextResultAPI::birthTimestamp() const +{ + return m_result.customAttribute("birthTimestamp").toLongLong(); +} + +QString OcrTextResultAPI::birthTimeString() const +{ + qint64 ts = birthTimestamp(); + return ts > 0 ? TimeResultAPI::formatTimestamp(ts) : QString(); +} + DFM_SEARCH_END_NS diff --git a/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextstrategies/indexedstrategy.cpp b/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextstrategies/indexedstrategy.cpp index d69ded44..0a496d77 100644 --- a/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextstrategies/indexedstrategy.cpp +++ b/src/dfm-search/dfm-search-lib/ocrtextsearch/ocrtextstrategies/indexedstrategy.cpp @@ -16,6 +16,7 @@ #include <dfm-search/field_names.h> #include <dfm-search/timerangefilter.h> +#include <dfm-search/ocrtextsearchapi.h> #include "3rdparty/fulltext/chineseanalyzer.h" #include "utils/cancellablecollector.h" @@ -319,6 +320,49 @@ void OcrTextIndexedStrategy::processSearchResults(const Lucene::IndexSearcherPtr // Create search result SearchResult result(path); + // 设置详细结果(如果启用) + if (Q_UNLIKELY(m_options.detailedResultsEnabled())) { + OcrTextResultAPI resultApi(result); + + // OCR 内容 + Lucene::String ocrContentField = doc->get(LuceneFieldNames::OcrText::kOcrContents); + if (!ocrContentField.empty()) { + resultApi.setOcrContent(QString::fromStdWString(ocrContentField)); + } + + // 文件名 + Lucene::String filenameField = doc->get(LuceneFieldNames::OcrText::kFilename); + if (!filenameField.empty()) { + resultApi.setFilename(QString::fromStdWString(filenameField)); + } + + // 隐藏状态 + Lucene::String hiddenField = doc->get(LuceneFieldNames::OcrText::kIsHidden); + if (!hiddenField.empty()) { + resultApi.setIsHidden(QString::fromStdWString(hiddenField).toLower() == "y"); + } + + // 修改时间 + Lucene::String modifyTimeField = doc->get(LuceneFieldNames::OcrText::kModifyTime); + if (!modifyTimeField.empty()) { + bool ok = false; + qint64 timestamp = QString::fromStdWString(modifyTimeField).toLongLong(&ok); + if (ok && timestamp > 0) { + resultApi.setModifyTimestamp(timestamp); + } + } + + // 创建时间 + Lucene::String birthTimeField = doc->get(LuceneFieldNames::OcrText::kBirthTime); + if (!birthTimeField.empty()) { + bool ok = false; + qint64 timestamp = QString::fromStdWString(birthTimeField).toLongLong(&ok); + if (ok && timestamp > 0) { + resultApi.setBirthTimestamp(timestamp); + } + } + } + // Add to result collection m_results.append(result); From 16f9086096716c9df5c9fe35dcf11102d45f863d Mon Sep 17 00:00:00 2001 From: Zhang Sheng <zhangsheng@uniontech.com> Date: Mon, 13 Apr 2026 13:29:35 +0800 Subject: [PATCH 15/21] refactor: optimize file search strategy logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improved the file search strategy by removing dedicated FileType and FileExt search types in favor of unified Combined search handling. Now all file type/extension searches are processed through the Combined search path for better consistency and maintainability. Key changes: 1. Removed FileType and FileExt search types from enum and related processing code 2. Modified determineSearchType to always use Combined when file types/ extensions are present without keywords 3. Simplified buildIndexQuery by removing dedicated file type/extension cases 4. Improved combined search logic with better conditional processing 5. Updated comments to reflect the unified search approach The change was made to reduce code complexity and provide more consistent search behavior regardless of whether a keyword is present. The Combined search path already handles all necessary functionality. Influence: 1. Verify all file searches still work correctly with and without keywords 2. Test combinations of keywords with file types/extensions 3. Check basic file type searches without keywords 4. Validate boolean search functionality 5. Test pinyin and pinyin acronym searches refactor: 优化文件搜索策略逻辑 通过移除专用的文件类型和后缀搜索类型,改进了文件搜索策略,转为统一使用组 合搜索处理,以提高一致性和可维护性。 主要变更: 1. 从枚举中移除文件类型和后缀搜索类型及相关处理代码 2. 修改determineSearchType在没有关键词但有文件类型/后缀时始终使用组合 搜索 3. 简化buildIndexQuery逻辑,移除专用文件类型/后缀处理分支 4. 改进组合搜索的条件处理逻辑 5. 更新注释以反映统一搜索方法 此变更旨在降低代码复杂性,并确保无论是否存在关键词,都能提供一致的搜索行 为。组合搜索路径已包含所有必要的功能。 影响范围: 1. 验证带关键词和不带关键词的文件搜索仍能正常工作 2. 测试关键词与文件类型/后缀的组合搜索 3. 检查不带关键词的基本文件类型搜索 4. 验证布尔搜索功能 5. 测试拼音和拼音首字母搜索功能 --- .../filenamestrategies/indexedstrategy.cpp | 46 +++++-------------- .../filenamestrategies/indexedstrategy.h | 4 +- 2 files changed, 12 insertions(+), 38 deletions(-) diff --git a/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.cpp b/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.cpp index 7cc268e9..6746fa2f 100644 --- a/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.cpp +++ b/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.cpp @@ -346,20 +346,15 @@ FileNameIndexedStrategy::SearchType FileNameIndexedStrategy::determineSearchType bool hasFileExts = !fileExtensions.isEmpty(); bool isBoolean = (query.type() == SearchQuery::Type::Boolean); - // 检查是否需要组合搜索 + // 检查是否需要组合搜索(关键词 + 文件类型/后缀) bool combinedWithTypes = (hasKeyword || isBoolean) && (hasFileTypes || hasFileExts); if (combinedWithTypes) { return SearchType::Combined; } - // 空关键词但有文件类型,使用文件类型搜索 - if (!hasKeyword && hasFileTypes) { - return SearchType::FileType; - } - - // 空关键词但有文件后缀,使用文件后缀搜索 - if (!hasKeyword && hasFileExts) { - return SearchType::FileExt; + // 无关键词时,文件类型和后缀组合搜索 + if (!hasKeyword && (hasFileTypes || hasFileExts)) { + return SearchType::Combined; } // 通配符查询类型(显式指定) @@ -434,15 +429,14 @@ FileNameIndexedStrategy::IndexQuery FileNameIndexedStrategy::buildIndexQuery( case SearchType::PinyinAcronym: result.terms.append(query.keyword()); break; - case SearchType::FileType: - result.fileTypes = fileTypes; - break; - case SearchType::FileExt: - result.fileExtensions = fileExtensions; - break; case SearchType::Combined: - result.terms = query.type() == SearchQuery::Type::Boolean ? SearchUtility::extractBooleanKeywords(query) : QStringList { query.keyword() }; - result.booleanOp = query.type() == SearchQuery::Type::Boolean ? query.booleanOperator() : SearchQuery::BooleanOperator::AND; + // 只有当有关键词时才设置 terms + if (query.type() == SearchQuery::Type::Boolean) { + result.terms = SearchUtility::extractBooleanKeywords(query); + result.booleanOp = query.booleanOperator(); + } else if (!query.keyword().isEmpty()) { + result.terms = QStringList { query.keyword() }; + } result.combineWithFileType = !fileTypes.isEmpty(); result.combineWithFileExt = !fileExtensions.isEmpty(); break; @@ -711,24 +705,6 @@ Lucene::QueryPtr FileNameIndexedStrategy::buildLuceneQuery(const IndexQuery &que } } break; - case SearchType::FileType: - if (!query.fileTypes.isEmpty()) { - QueryPtr typeQuery = m_queryBuilder->buildTypeQuery(query.fileTypes); - if (typeQuery) { - finalQuery->add(typeQuery, BooleanClause::MUST); - hasValidQuery = true; - } - } - break; - case SearchType::FileExt: - if (!query.fileExtensions.isEmpty()) { - QueryPtr extQuery = m_queryBuilder->buildExtQuery(query.fileExtensions); - if (extQuery) { - finalQuery->add(extQuery, BooleanClause::MUST); - hasValidQuery = true; - } - } - break; case SearchType::Combined: if (!query.terms.isEmpty()) { BooleanQueryPtr combinedQuery = buildBooleanTermsQuery(query, analyzer); diff --git a/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.h b/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.h index 4e0ac0a9..2d4f143f 100644 --- a/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.h +++ b/src/dfm-search/dfm-search-lib/filenamesearch/filenamestrategies/indexedstrategy.h @@ -35,9 +35,7 @@ class FileNameIndexedStrategy : public FileNameBaseStrategy Boolean, // 布尔多关键词搜索 Pinyin, // 拼音搜索 PinyinAcronym, // 拼音首字母搜索 - FileType, // 文件类型搜索 - FileExt, // 文件后缀搜索 - Combined // 组合搜索(关键词+文件类型/拼音/文件后缀) + Combined // 组合搜索(关键词+文件类型/拼音/文件后缀/文件类型和后缀) }; // 索引查询结构 From d93b9370b8ce70cf6d31542ae68e3aaab7a91e93 Mon Sep 17 00:00:00 2001 From: Zhang Sheng <zhangsheng@uniontech.com> Date: Mon, 13 Apr 2026 13:41:52 +0800 Subject: [PATCH 16/21] chore: update search binary name and installation path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The change updates the DFM search client binary name from 'dfm6- search-client' to 'dfm-searcher' for better clarity and consistency. Additionally, the installation path has been moved from libexec to bin directory to make the binary more accessible on the system path. This modification includes: 1. Changed project name in CMakeLists.txt from version-specific 'dfm6- search-client' to consistent 'dfm-searcher' 2. Updated binary installation path from libexec to standard bin directory 3. Modified package install configuration accordingly The change improves usability by making the search tool more discoverable and aligns with standard binary naming conventions. chore: 更新搜索二进制文件名和安装路径 本次更改将DFM搜索客户端二进制文件名从'dfm6-search-client'更新为'dfm- searcher'以提高清晰度和一致性。同时文件安装路径从libexec移动到了bin目 录,使得二进制文件在系统路径中更易访问。 具体修改包括: 1. CMakeLists.txt中的项目名称从版本特定名称'dfm6-search-client'改为一致 的'dfm-searcher' 2. 二进制文件安装路径从libexec改为标准bin目录 3. 相应更新了软件包安装配置 此项修改通过使搜索工具更易发现来提升可用性,同时符合标准二进制文件命名 规范。 --- debian/libdfm6-search.install | 4 +- .../dfm-search-client/CMakeLists.txt | 4 +- .../dfm-search-client/cli_options.cpp | 65 ++++++++++--------- 3 files changed, 37 insertions(+), 36 deletions(-) diff --git a/debian/libdfm6-search.install b/debian/libdfm6-search.install index 6c79185c..9c76e128 100644 --- a/debian/libdfm6-search.install +++ b/debian/libdfm6-search.install @@ -1,2 +1,2 @@ -usr/lib/*/libdfm6-search*.so* -usr/libexec/dfm6-search-client \ No newline at end of file +usr/lib/*/libdfm6-search*.so* +usr/bin/dfm-searcher \ No newline at end of file diff --git a/src/dfm-search/dfm-search-client/CMakeLists.txt b/src/dfm-search/dfm-search-client/CMakeLists.txt index d11b5c1e..dcba205b 100644 --- a/src/dfm-search/dfm-search-client/CMakeLists.txt +++ b/src/dfm-search/dfm-search-client/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.10) -project(dfm${DFM_VERSION_MAJOR}-search-client) +project(dfm-searcher) set(SRCS main.cpp @@ -30,4 +30,4 @@ target_link_libraries( ) -install(TARGETS ${PROJECT_NAME} DESTINATION ${CMAKE_INSTALL_LIBEXECDIR}) +install(TARGETS ${PROJECT_NAME} DESTINATION ${CMAKE_INSTALL_BINDIR}) diff --git a/src/dfm-search/dfm-search-client/cli_options.cpp b/src/dfm-search/dfm-search-client/cli_options.cpp index e53bdcbb..ec6b19b8 100644 --- a/src/dfm-search/dfm-search-client/cli_options.cpp +++ b/src/dfm-search/dfm-search-client/cli_options.cpp @@ -13,35 +13,36 @@ using namespace dfmsearch; using namespace std; CliOptions::CliOptions() - : m_typeOption(QStringList() << "type", "Search type (filename, content or ocr)", "type", "filename") - , m_methodOption(QStringList() << "method", "Search method (indexed or realtime)", "method", "indexed") - , m_queryOption(QStringList() << "query", "Query type (simple, boolean or wildcard)", "query", "simple") - , m_caseSensitiveOption(QStringList() << "case-sensitive", "Enable case sensitivity") - , m_includeHiddenOption(QStringList() << "include-hidden", "Include hidden files") - , m_pinyinOption(QStringList() << "pinyin", "Enable pinyin search (for filename search)") - , m_pinyinAcronymOption(QStringList() << "pinyin-acronym", "Enable pinyin acronym search (for filename search)") - , m_fileTypesOption(QStringList() << "file-types", "Filter by file types, comma separated", "types") - , m_fileExtensionsOption(QStringList() << "file-extensions", "Filter by file extensions, comma separated", "extensions") - , m_maxResultsOption(QStringList() << "max-results", "Maximum number of results", "number", "100") - , m_maxPreviewOption(QStringList() << "max-preview", "Max content preview length", "length", "200") - , m_wildcardOption(QStringList() << "wildcard", "Enable wildcard search with * and ? patterns") - , m_jsonOption(QStringList() << "json" + : m_typeOption(QStringList() << "type", + "Search type (filename, content or ocr)", "type", "filename"), + m_methodOption(QStringList() << "method", "Search method (indexed or realtime)", "method", "indexed"), + m_queryOption(QStringList() << "query", "Query type (simple, boolean or wildcard)", "query", "simple"), + m_caseSensitiveOption(QStringList() << "case-sensitive", "Enable case sensitivity"), + m_includeHiddenOption(QStringList() << "include-hidden", "Include hidden files"), + m_pinyinOption(QStringList() << "pinyin", "Enable pinyin search (for filename search)"), + m_pinyinAcronymOption(QStringList() << "pinyin-acronym", "Enable pinyin acronym search (for filename search)"), + m_fileTypesOption(QStringList() << "file-types", "Filter by file types, comma separated", "types"), + m_fileExtensionsOption(QStringList() << "file-extensions", "Filter by file extensions, comma separated", "extensions"), + m_maxResultsOption(QStringList() << "max-results", "Maximum number of results", "number", "100"), + m_maxPreviewOption(QStringList() << "max-preview", "Max content preview length", "length", "200"), + m_wildcardOption(QStringList() << "wildcard", "Enable wildcard search with * and ? patterns"), + m_jsonOption(QStringList() << "json" << "j", - "Output results in JSON format") - , m_verboseOption(QStringList() << "verbose" + "Output results in JSON format"), + m_verboseOption(QStringList() << "verbose" << "v", - "Enable verbose output with detailed result information") - , m_timeFieldOption(QStringList() << "time-field", "Time field to filter (birth or modify)", "field", "modify") - , m_timeLastOption(QStringList() << "time-last", "Rolling time window (e.g., 3d, 2h, 30m)", "duration") - , m_timeTodayOption(QStringList() << "time-today", "Filter files from today") - , m_timeYesterdayOption(QStringList() << "time-yesterday", "Filter files from yesterday") - , m_timeThisWeekOption(QStringList() << "time-this-week", "Filter files from this week") - , m_timeLastWeekOption(QStringList() << "time-last-week", "Filter files from last week") - , m_timeThisMonthOption(QStringList() << "time-this-month", "Filter files from this month") - , m_timeLastMonthOption(QStringList() << "time-last-month", "Filter files from last month") - , m_timeThisYearOption(QStringList() << "time-this-year", "Filter files from this year") - , m_timeLastYearOption(QStringList() << "time-last-year", "Filter files from last year") - , m_timeRangeOption(QStringList() << "time-range", "Custom time range (start,end)", "range") + "Enable verbose output with detailed result information"), + m_timeFieldOption(QStringList() << "time-field", "Time field to filter (birth or modify)", "field", "modify"), + m_timeLastOption(QStringList() << "time-last", "Rolling time window (e.g., 3d, 2h, 30m)", "duration"), + m_timeTodayOption(QStringList() << "time-today", "Filter files from today"), + m_timeYesterdayOption(QStringList() << "time-yesterday", "Filter files from yesterday"), + m_timeThisWeekOption(QStringList() << "time-this-week", "Filter files from this week"), + m_timeLastWeekOption(QStringList() << "time-last-week", "Filter files from last week"), + m_timeThisMonthOption(QStringList() << "time-this-month", "Filter files from this month"), + m_timeLastMonthOption(QStringList() << "time-last-month", "Filter files from last month"), + m_timeThisYearOption(QStringList() << "time-this-year", "Filter files from this year"), + m_timeLastYearOption(QStringList() << "time-last-year", "Filter files from last year"), + m_timeRangeOption(QStringList() << "time-range", "Custom time range (start,end)", "range") { setupOptions(); } @@ -87,7 +88,7 @@ void CliOptions::setupOptions() void CliOptions::printHelp() const { - std::cout << "Usage: dfm6-search-client [options] <keyword> <search_path>" << std::endl; + std::cout << "Usage: dfm-searcher [options] <keyword> <search_path>" << std::endl; std::cout << std::endl; std::cout << "Search Types:" << std::endl; std::cout << " --type=<filename|content|ocr> Search type (default: filename)" << std::endl; @@ -131,16 +132,16 @@ void CliOptions::printHelp() const std::cout << std::endl; std::cout << "Examples:" << std::endl; std::cout << " # Basic filename search" << std::endl; - std::cout << " dfm6-search-client \"document\" /home/user" << std::endl; + std::cout << " dfm-searcher \"document\" /home/user" << std::endl; std::cout << std::endl; std::cout << " # Content search" << std::endl; - std::cout << " dfm6-search-client --type=content \"hello world\" /home/user/Documents" << std::endl; + std::cout << " dfm-searcher --type=content \"hello world\" /home/user/Documents" << std::endl; std::cout << std::endl; std::cout << " # OCR search in images" << std::endl; - std::cout << " dfm6-search-client --type=ocr \"screenshot\" /home/user/Pictures" << std::endl; + std::cout << " dfm-searcher --type=ocr \"screenshot\" /home/user/Pictures" << std::endl; std::cout << std::endl; std::cout << " # Realtime search with time filter" << std::endl; - std::cout << " dfm6-search-client --method=realtime --time-last=7d \"report\" /home/user" << std::endl; + std::cout << " dfm-searcher --method=realtime --time-last=7d \"report\" /home/user" << std::endl; } bool CliOptions::parse(QCoreApplication &app, SearchCliConfig &config) From 3f8e9efaec6a04e81f791af4c6f69407a56a8ff6 Mon Sep 17 00:00:00 2001 From: Zhang Sheng <zhangsheng@uniontech.com> Date: Mon, 13 Apr 2026 17:14:23 +0800 Subject: [PATCH 17/21] refactor: optimize search utility path checking functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Refactored isPathInContentIndexDirectory to use static cached directory list 2. Refactored isPathInOcrTextIndexDirectory similarly to reduce repeated calls 3. Optimized isPathInFileNameIndexDirectory with cached lists for both blacklist and indexed directories 4. Changes improve performance by avoiding repeated calls to directory fetching functions The modifications cache directory lists as static variables to prevent repeated calls to defaultIndexedDirectory() and defaultBlacklistPaths() functions within frequently called path checking methods. This optimization reduces computational overhead while maintaining the same functionality. Influence: 1. Verify path checking still works correctly in all cases 2. Test performance impact by comparing search operations before and after 3. Ensure blacklist functionality remains effective 4. Check edge cases with different path combinations refactor: 优化搜索工具路径检查函数 1. 重构 isPathInContentIndexDirectory 使用静态缓存的目录列表 2. 以相同方式重构 isPathInOcrTextIndexDirectory 减少重复调用 3. 优化 isPathInFileNameIndexDirectory 使用缓存的列表同时处理黑名单和索 引目录 4. 通过避免重复调用目录获取函数来提升性能 这些修改将目录列表缓存为静态变量,避免在频繁调用的路径检查方法中重复调用 defaultIndexedDirectory() 和 defaultBlacklistPaths() 函数。这种优化在保 持相同功能的同时减少了计算开销。 Influence: 1. 验证路径检查在所有情况下仍然正常工作 2. 通过比较优化前后的搜索操作测试性能影响 3. 确保黑名单功能保持有效 4. 检查不同路径组合的边缘情况 --- .../dfm-search-lib/utils/searchutility.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/dfm-search/dfm-search-lib/utils/searchutility.cpp b/src/dfm-search/dfm-search-lib/utils/searchutility.cpp index c72ef707..c960bd58 100644 --- a/src/dfm-search/dfm-search-lib/utils/searchutility.cpp +++ b/src/dfm-search/dfm-search-lib/utils/searchutility.cpp @@ -615,7 +615,8 @@ bool isPathInContentIndexDirectory(const QString &path) if (!isContentIndexAvailable()) return false; - return isPathInAnyDirectory(path, defaultIndexedDirectory()); + static const QStringList &kDirs = DFMSEARCH::Global::defaultIndexedDirectory(); + return isPathInAnyDirectory(path, kDirs); } bool isContentIndexAvailable() @@ -672,7 +673,8 @@ bool isPathInOcrTextIndexDirectory(const QString &path) if (!isOcrTextIndexAvailable()) return false; - return isPathInAnyDirectory(path, defaultIndexedDirectory()); + static const QStringList &kDirs = DFMSEARCH::Global::defaultIndexedDirectory(); + return isPathInAnyDirectory(path, kDirs); } bool isOcrTextIndexAvailable() @@ -712,10 +714,12 @@ bool isPathInFileNameIndexDirectory(const QString &path) if (!isFileNameIndexDirectoryAvailable()) return false; - if (BlacklistMatcher::isPathBlacklisted(path, defaultBlacklistPaths())) + static const QStringList &kBlackPaths = defaultBlacklistPaths(); + if (BlacklistMatcher::isPathBlacklisted(path, kBlackPaths)) return false; - return isPathInAnyDirectory(path, defaultIndexedDirectory()); + static const QStringList &kIndexedDirs = DFMSEARCH::Global::defaultIndexedDirectory(); + return isPathInAnyDirectory(path, kIndexedDirs); } bool isFileNameIndexDirectoryAvailable() From 627b79bd5ab4d4faec962b499ad60464e6dd8289 Mon Sep 17 00:00:00 2001 From: Zhang Sheng <zhangsheng@uniontech.com> Date: Mon, 20 Apr 2026 11:22:51 +0800 Subject: [PATCH 18/21] refactor: implement Pimpl pattern for TimeRangeFilter class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Replaced direct member variables with a private implementation class (TimeRangeFilterData) 2. Modified constructors, destructor and assignment operators to handle the pimpl pointer 3. All member access now goes through the d pointer 4. Moved internal RangeMode enum to implementation file 5. Added proper memory management with unique_ptr Reason for changes: 1. Provides better encapsulation by hiding implementation details 2. Reduces header file dependencies and compilation time 3. Makes ABI more stable since private members aren't exposed 4. Follows better OOP design principles 5. Easier to modify implementation without affecting clients Influence: 1. Verify all time range filtering functionality works as before 2. Test copy/move operations between TimeRangeFilter instances 3. Check memory usage and leaks when creating/destroying filters 4. Verify range calculations still produce correct results 5. Test with different time fields (modify/created time etc.) refactor: 为TimeRangeFilter类实现Pimpl模式 1. 将直接成员变量替换为私有实现类(TimeRangeFilterData) 2. 修改构造函数、析构函数和赋值运算符以处理pimpl指针 3. 所有成员访问现在通过d指针进行 4. 将内部RangeMode枚举移到实现文件 5. 添加了使用unique_ptr的适当内存管理 更改原因: 1. 通过隐藏实现细节提供更好的封装 2. 减少头文件依赖和编译时间 3. 因为不暴露私有成员使ABI更稳定 4. 遵循更好的OOP设计原则 5. 更容易修改实现而不影响客户端 Influence: 1. 验证所有时间范围筛选功能是否像以前一样工作 2. 测试TimeRangeFilter实例之间的复制/移动操作 3. 检查创建/销毁过滤器时的内存使用和泄漏情况 4. 验证范围计算仍能产生正确结果 5. 使用不同时间字段(修改时间/创建时间等)进行测试 --- .../dfm-search/dfm-search/timerangefilter.h | 37 +--- .../dfm-search-lib/core/timerangefilter.cpp | 197 ++++++++++++------ 2 files changed, 144 insertions(+), 90 deletions(-) diff --git a/include/dfm-search/dfm-search/timerangefilter.h b/include/dfm-search/dfm-search/timerangefilter.h index c941022f..35f94b38 100644 --- a/include/dfm-search/dfm-search/timerangefilter.h +++ b/include/dfm-search/dfm-search/timerangefilter.h @@ -6,11 +6,14 @@ #include <QDateTime> #include <QPair> +#include <memory> #include <dfm-search/dsearch_global.h> DFM_SEARCH_BEGIN_NS +class TimeRangeFilterData; + /** * @brief The TimeRangeFilter class provides time range filtering for search operations. * @@ -43,27 +46,27 @@ class TimeRangeFilter /** * @brief Copy constructor */ - TimeRangeFilter(const TimeRangeFilter &other) = default; + TimeRangeFilter(const TimeRangeFilter &other); /** * @brief Move constructor */ - TimeRangeFilter(TimeRangeFilter &&other) noexcept = default; + TimeRangeFilter(TimeRangeFilter &&other) noexcept; /** * @brief Destructor */ - ~TimeRangeFilter() = default; + ~TimeRangeFilter(); /** * @brief Assignment operator */ - TimeRangeFilter &operator=(const TimeRangeFilter &other) = default; + TimeRangeFilter &operator=(const TimeRangeFilter &other); /** * @brief Move assignment operator */ - TimeRangeFilter &operator=(TimeRangeFilter &&other) noexcept = default; + TimeRangeFilter &operator=(TimeRangeFilter &&other) noexcept; // ---------- Time Field ---------- @@ -230,29 +233,7 @@ class TimeRangeFilter */ static QPair<QDateTime, QDateTime> resolveFixedUnitTimeRange(int value, TimeUnit unit); - /** - * @brief Internal enum for range mode - */ - enum class RangeMode { - Invalid, // No range set - Relative, // Relative time (setLast) - rolling range from N units ago to now - FixedUnit, // Fixed unit range (yesterday, last week, etc.) - complete unit - Custom // Custom start/end - }; - - TimeField m_field = TimeField::ModifyTime; - RangeMode m_mode = RangeMode::Invalid; - - // For relative/fixed mode - int m_relativeValue = 0; - TimeUnit m_relativeUnit = TimeUnit::Days; - - // For custom mode - QDateTime m_startTime; - QDateTime m_endTime; - - bool m_includeLower = true; - bool m_includeUpper = false; + std::unique_ptr<TimeRangeFilterData> d; }; DFM_SEARCH_END_NS diff --git a/src/dfm-search/dfm-search-lib/core/timerangefilter.cpp b/src/dfm-search/dfm-search-lib/core/timerangefilter.cpp index c7a200ac..0ba06c32 100644 --- a/src/dfm-search/dfm-search-lib/core/timerangefilter.cpp +++ b/src/dfm-search/dfm-search-lib/core/timerangefilter.cpp @@ -7,172 +7,245 @@ DFM_SEARCH_BEGIN_NS +/** + * @brief Internal enum for range mode + */ +enum class RangeMode { + Invalid, // No range set + Relative, // Relative time (setLast) - rolling range from N units ago to now + FixedUnit, // Fixed unit range (yesterday, last week, etc.) - complete unit + Custom // Custom start/end +}; + +class TimeRangeFilterData +{ +public: + TimeRangeFilterData() + : field(TimeField::ModifyTime) + , mode(RangeMode::Invalid) + , relativeValue(0) + , relativeUnit(TimeUnit::Days) + , includeLower(true) + , includeUpper(false) + { + } + + TimeRangeFilterData(const TimeRangeFilterData &other) + : field(other.field) + , mode(other.mode) + , relativeValue(other.relativeValue) + , relativeUnit(other.relativeUnit) + , startTime(other.startTime) + , endTime(other.endTime) + , includeLower(other.includeLower) + , includeUpper(other.includeUpper) + { + } + + TimeField field; + RangeMode mode; + + // For relative/fixed mode + int relativeValue; + TimeUnit relativeUnit; + + // For custom mode + QDateTime startTime; + QDateTime endTime; + + bool includeLower; + bool includeUpper; +}; + TimeRangeFilter::TimeRangeFilter() - : m_field(TimeField::ModifyTime) - , m_mode(RangeMode::Invalid) - , m_relativeValue(0) - , m_relativeUnit(TimeUnit::Days) - , m_includeLower(true) - , m_includeUpper(false) + : d(std::make_unique<TimeRangeFilterData>()) +{ +} + +TimeRangeFilter::TimeRangeFilter(const TimeRangeFilter &other) + : d(std::make_unique<TimeRangeFilterData>(*other.d)) +{ +} + +TimeRangeFilter::TimeRangeFilter(TimeRangeFilter &&other) noexcept + : d(std::move(other.d)) { } +TimeRangeFilter::~TimeRangeFilter() = default; + +TimeRangeFilter &TimeRangeFilter::operator=(const TimeRangeFilter &other) +{ + if (this != &other) { + d = std::make_unique<TimeRangeFilterData>(*other.d); + } + return *this; +} + +TimeRangeFilter &TimeRangeFilter::operator=(TimeRangeFilter &&other) noexcept +{ + if (this != &other) { + d = std::move(other.d); + } + return *this; +} + TimeRangeFilter &TimeRangeFilter::setTimeField(TimeField field) { - m_field = field; + d->field = field; return *this; } TimeField TimeRangeFilter::timeField() const { - return m_field; + return d->field; } TimeRangeFilter &TimeRangeFilter::setLast(int value, TimeUnit unit) { - m_mode = RangeMode::Relative; // Rolling range from N units ago to now - m_relativeValue = value; - m_relativeUnit = unit; - m_startTime = QDateTime(); - m_endTime = QDateTime(); + d->mode = RangeMode::Relative; // Rolling range from N units ago to now + d->relativeValue = value; + d->relativeUnit = unit; + d->startTime = QDateTime(); + d->endTime = QDateTime(); return *this; } TimeRangeFilter &TimeRangeFilter::setToday() { - m_mode = RangeMode::FixedUnit; // Complete unit (today) - m_relativeValue = 0; - m_relativeUnit = TimeUnit::Days; + d->mode = RangeMode::FixedUnit; // Complete unit (today) + d->relativeValue = 0; + d->relativeUnit = TimeUnit::Days; return *this; } TimeRangeFilter &TimeRangeFilter::setYesterday() { - m_mode = RangeMode::FixedUnit; // Complete unit (yesterday) - m_relativeValue = 1; - m_relativeUnit = TimeUnit::Days; + d->mode = RangeMode::FixedUnit; // Complete unit (yesterday) + d->relativeValue = 1; + d->relativeUnit = TimeUnit::Days; return *this; } TimeRangeFilter &TimeRangeFilter::setThisWeek() { - m_mode = RangeMode::FixedUnit; - m_relativeValue = 0; - m_relativeUnit = TimeUnit::Weeks; + d->mode = RangeMode::FixedUnit; + d->relativeValue = 0; + d->relativeUnit = TimeUnit::Weeks; return *this; } TimeRangeFilter &TimeRangeFilter::setLastWeek() { - m_mode = RangeMode::FixedUnit; - m_relativeValue = 1; - m_relativeUnit = TimeUnit::Weeks; + d->mode = RangeMode::FixedUnit; + d->relativeValue = 1; + d->relativeUnit = TimeUnit::Weeks; return *this; } TimeRangeFilter &TimeRangeFilter::setThisMonth() { - m_mode = RangeMode::FixedUnit; - m_relativeValue = 0; - m_relativeUnit = TimeUnit::Months; + d->mode = RangeMode::FixedUnit; + d->relativeValue = 0; + d->relativeUnit = TimeUnit::Months; return *this; } TimeRangeFilter &TimeRangeFilter::setLastMonth() { - m_mode = RangeMode::FixedUnit; - m_relativeValue = 1; - m_relativeUnit = TimeUnit::Months; + d->mode = RangeMode::FixedUnit; + d->relativeValue = 1; + d->relativeUnit = TimeUnit::Months; return *this; } TimeRangeFilter &TimeRangeFilter::setThisYear() { - m_mode = RangeMode::FixedUnit; - m_relativeValue = 0; - m_relativeUnit = TimeUnit::Years; + d->mode = RangeMode::FixedUnit; + d->relativeValue = 0; + d->relativeUnit = TimeUnit::Years; return *this; } TimeRangeFilter &TimeRangeFilter::setLastYear() { - m_mode = RangeMode::FixedUnit; - m_relativeValue = 1; - m_relativeUnit = TimeUnit::Years; + d->mode = RangeMode::FixedUnit; + d->relativeValue = 1; + d->relativeUnit = TimeUnit::Years; return *this; } TimeRangeFilter &TimeRangeFilter::setRange(const QDateTime &start, const QDateTime &end) { - m_mode = RangeMode::Custom; - m_startTime = start; - m_endTime = end; - m_relativeValue = 0; - m_relativeUnit = TimeUnit::Days; + d->mode = RangeMode::Custom; + d->startTime = start; + d->endTime = end; + d->relativeValue = 0; + d->relativeUnit = TimeUnit::Days; return *this; } QDateTime TimeRangeFilter::startTime() const { - return m_startTime; + return d->startTime; } QDateTime TimeRangeFilter::endTime() const { - return m_endTime; + return d->endTime; } TimeRangeFilter &TimeRangeFilter::setIncludeLower(bool include) { - m_includeLower = include; + d->includeLower = include; return *this; } TimeRangeFilter &TimeRangeFilter::setIncludeUpper(bool include) { - m_includeUpper = include; + d->includeUpper = include; return *this; } bool TimeRangeFilter::includeLower() const { - return m_includeLower; + return d->includeLower; } bool TimeRangeFilter::includeUpper() const { - return m_includeUpper; + return d->includeUpper; } TimeRangeFilter &TimeRangeFilter::clear() { - m_mode = RangeMode::Invalid; - m_startTime = QDateTime(); - m_endTime = QDateTime(); - m_relativeValue = 0; - m_relativeUnit = TimeUnit::Days; - m_includeLower = true; - m_includeUpper = false; + d->mode = RangeMode::Invalid; + d->startTime = QDateTime(); + d->endTime = QDateTime(); + d->relativeValue = 0; + d->relativeUnit = TimeUnit::Days; + d->includeLower = true; + d->includeUpper = false; return *this; } bool TimeRangeFilter::isValid() const { - return m_mode != RangeMode::Invalid; + return d->mode != RangeMode::Invalid; } QPair<QDateTime, QDateTime> TimeRangeFilter::resolveTimeRange() const { - if (m_mode == RangeMode::Custom) { - return qMakePair(m_startTime, m_endTime); + if (d->mode == RangeMode::Custom) { + return qMakePair(d->startTime, d->endTime); } - if (m_mode == RangeMode::Relative) { - return resolveRelativeTimeRange(m_relativeValue, m_relativeUnit); + if (d->mode == RangeMode::Relative) { + return resolveRelativeTimeRange(d->relativeValue, d->relativeUnit); } - if (m_mode == RangeMode::FixedUnit) { - return resolveFixedUnitTimeRange(m_relativeValue, m_relativeUnit); + if (d->mode == RangeMode::FixedUnit) { + return resolveFixedUnitTimeRange(d->relativeValue, d->relativeUnit); } return qMakePair(QDateTime(), QDateTime()); From cf0cd6d6412d7e6d5960240c70b295822a5a11b4 Mon Sep 17 00:00:00 2001 From: Zhang Sheng <zhangsheng@uniontech.com> Date: Mon, 20 Apr 2026 11:23:49 +0800 Subject: [PATCH 19/21] chore: bump version to 1.3.52 1.3.52 Log: --- debian/changelog | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/debian/changelog b/debian/changelog index 637b5f75..3410e274 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,21 @@ +util-dfm (1.3.52) unstable; urgency=medium + + * fix: change content index directory path + * refactor: centralize Lucene field names in constants + * feat: add OCR text search support + * fix: improve path matching logic for content indexing + * feat: add birth time field name constant + * refactor: correct field names and add modify_time + * feat: implement time range filtering for search + * feat: add CLI options and output formatters for search client + * feat: enhance search result metadata and output + * refactor: optimize file search strategy logic + * chore: update search binary name and installation path + * refactor: optimize search utility path checking functions + * refactor: implement Pimpl pattern for TimeRangeFilter class + + -- Zhang Sheng <zhangsheng@uniontech.com> Mon, 20 Apr 2026 11:23:35 +0800 + util-dfm (1.3.51) unstable; urgency=medium * chore: reorder GNUInstallDirs include in CMakeLists From 4dc6308ef88e376afbb07abf143aec663192a316 Mon Sep 17 00:00:00 2001 From: Zhang Sheng <zhangsheng@uniontech.com> Date: Mon, 20 Apr 2026 11:29:59 +0800 Subject: [PATCH 20/21] chore: update SPDX header update SPDX header Log: --- debian/control | 2 +- include/dfm-search/dfm-search/field_names.h | 2 +- .../dfm-search/dfm-search/timerangefilter.h | 2 +- include/dfm-search/dfm-search/timeresultapi.h | 2 +- src/dfm-burn/3rdparty/udfclient/config.guess | 114 ++- src/dfm-burn/3rdparty/udfclient/config.sub | 943 +++++++++++++----- .../dfm-search-client/cli_options.cpp | 2 +- .../dfm-search-client/cli_options.h | 2 +- .../dfm-search-client/output/json_output.cpp | 4 +- .../dfm-search-client/output/json_output.h | 5 +- .../output/output_formatter.h | 2 +- .../dfm-search-client/output/text_output.cpp | 4 +- .../dfm-search-client/output/text_output.h | 2 +- .../dfm-search-client/time_parser.cpp | 2 +- .../dfm-search-client/time_parser.h | 2 +- .../dfm-search-lib/core/timerangefilter.cpp | 32 +- .../dfm-search-lib/core/timeresultapi.cpp | 2 +- .../dfm-search-lib/utils/timerangeutils.cpp | 22 +- .../dfm-search-lib/utils/timerangeutils.h | 12 +- 19 files changed, 833 insertions(+), 325 deletions(-) diff --git a/debian/control b/debian/control index 18523d38..578fd30c 100644 --- a/debian/control +++ b/debian/control @@ -117,4 +117,4 @@ Depends: ${misc:Depends}, libdfm6-search (=${binary:Version}) Description: A library about searching files dev. - A library that provides file searching interface dev. + A library that provides file searching interface dev. \ No newline at end of file diff --git a/include/dfm-search/dfm-search/field_names.h b/include/dfm-search/dfm-search/field_names.h index 27d0ff31..e3d96ccc 100644 --- a/include/dfm-search/dfm-search/field_names.h +++ b/include/dfm-search/dfm-search/field_names.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025 - 2026 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later #pragma once diff --git a/include/dfm-search/dfm-search/timerangefilter.h b/include/dfm-search/dfm-search/timerangefilter.h index 35f94b38..230a65c4 100644 --- a/include/dfm-search/dfm-search/timerangefilter.h +++ b/include/dfm-search/dfm-search/timerangefilter.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025 - 2026 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TIMERANGEFILTER_H diff --git a/include/dfm-search/dfm-search/timeresultapi.h b/include/dfm-search/dfm-search/timeresultapi.h index 244dcecb..71f51064 100644 --- a/include/dfm-search/dfm-search/timeresultapi.h +++ b/include/dfm-search/dfm-search/timeresultapi.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025 - 2026 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TIMERESULTAPI_H diff --git a/src/dfm-burn/3rdparty/udfclient/config.guess b/src/dfm-burn/3rdparty/udfclient/config.guess index 56a85784..48a68460 100755 --- a/src/dfm-burn/3rdparty/udfclient/config.guess +++ b/src/dfm-burn/3rdparty/udfclient/config.guess @@ -1,10 +1,10 @@ #! /bin/sh # Attempt to guess a canonical system name. -# Copyright 1992-2022 Free Software Foundation, Inc. +# Copyright 1992-2024 Free Software Foundation, Inc. # shellcheck disable=SC2006,SC2268 # see below for rationale -timestamp='2022-01-09' +timestamp='2024-07-27' # This file is free software; you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by @@ -47,7 +47,7 @@ me=`echo "$0" | sed -e 's,.*/,,'` usage="\ Usage: $0 [OPTION] -Output the configuration name of the system \`$me' is run on. +Output the configuration name of the system '$me' is run on. Options: -h, --help print this help, then exit @@ -60,13 +60,13 @@ version="\ GNU config.guess ($timestamp) Originally written by Per Bothner. -Copyright 1992-2022 Free Software Foundation, Inc. +Copyright 1992-2024 Free Software Foundation, Inc. This is free software; see the source for copying conditions. There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE." help=" -Try \`$me --help' for more information." +Try '$me --help' for more information." # Parse command line while test $# -gt 0 ; do @@ -102,8 +102,8 @@ GUESS= # temporary files to be created and, as you can see below, it is a # headache to deal with in a portable fashion. -# Historically, `CC_FOR_BUILD' used to be named `HOST_CC'. We still -# use `HOST_CC' if defined, but it is deprecated. +# Historically, 'CC_FOR_BUILD' used to be named 'HOST_CC'. We still +# use 'HOST_CC' if defined, but it is deprecated. # Portable tmp directory creation inspired by the Autoconf team. @@ -123,7 +123,7 @@ set_cc_for_build() { dummy=$tmp/dummy case ${CC_FOR_BUILD-},${HOST_CC-},${CC-} in ,,) echo "int x;" > "$dummy.c" - for driver in cc gcc c89 c99 ; do + for driver in cc gcc c17 c99 c89 ; do if ($driver -c -o "$dummy.o" "$dummy.c") >/dev/null 2>&1 ; then CC_FOR_BUILD=$driver break @@ -155,6 +155,9 @@ Linux|GNU|GNU/*) set_cc_for_build cat <<-EOF > "$dummy.c" + #if defined(__ANDROID__) + LIBC=android + #else #include <features.h> #if defined(__UCLIBC__) LIBC=uclibc @@ -162,6 +165,8 @@ Linux|GNU|GNU/*) LIBC=dietlibc #elif defined(__GLIBC__) LIBC=gnu + #elif defined(__LLVM_LIBC__) + LIBC=llvm #else #include <stdarg.h> /* First heuristic to detect musl libc. */ @@ -169,6 +174,7 @@ Linux|GNU|GNU/*) LIBC=musl #endif #endif + #endif EOF cc_set_libc=`$CC_FOR_BUILD -E "$dummy.c" 2>/dev/null | grep '^LIBC' | sed 's, ,,g'` eval "$cc_set_libc" @@ -459,7 +465,7 @@ case $UNAME_MACHINE:$UNAME_SYSTEM:$UNAME_RELEASE:$UNAME_VERSION in UNAME_RELEASE=`uname -v` ;; esac - # Japanese Language versions have a version number like `4.1.3-JL'. + # Japanese Language versions have a version number like '4.1.3-JL'. SUN_REL=`echo "$UNAME_RELEASE" | sed -e 's/-/_/'` GUESS=sparc-sun-sunos$SUN_REL ;; @@ -628,7 +634,8 @@ EOF sed 's/^ //' << EOF > "$dummy.c" #include <sys/systemcfg.h> - main() + int + main () { if (!__power_pc()) exit(1); @@ -712,7 +719,8 @@ EOF #include <stdlib.h> #include <unistd.h> - int main () + int + main () { #if defined(_SC_KERNEL_BITS) long bits = sysconf(_SC_KERNEL_BITS); @@ -904,7 +912,7 @@ EOF fi ;; *:FreeBSD:*:*) - UNAME_PROCESSOR=`/usr/bin/uname -p` + UNAME_PROCESSOR=`uname -p` case $UNAME_PROCESSOR in amd64) UNAME_PROCESSOR=x86_64 ;; @@ -966,11 +974,37 @@ EOF GNU_REL=`echo "$UNAME_RELEASE" | sed -e 's/[-(].*//'` GUESS=$UNAME_MACHINE-unknown-$GNU_SYS$GNU_REL-$LIBC ;; + x86_64:[Mm]anagarm:*:*|i?86:[Mm]anagarm:*:*) + GUESS="$UNAME_MACHINE-pc-managarm-mlibc" + ;; + *:[Mm]anagarm:*:*) + GUESS="$UNAME_MACHINE-unknown-managarm-mlibc" + ;; *:Minix:*:*) GUESS=$UNAME_MACHINE-unknown-minix ;; aarch64:Linux:*:*) - GUESS=$UNAME_MACHINE-unknown-linux-$LIBC + set_cc_for_build + CPU=$UNAME_MACHINE + LIBCABI=$LIBC + if test "$CC_FOR_BUILD" != no_compiler_found; then + ABI=64 + sed 's/^ //' << EOF > "$dummy.c" + #ifdef __ARM_EABI__ + #ifdef __ARM_PCS_VFP + ABI=eabihf + #else + ABI=eabi + #endif + #endif +EOF + cc_set_abi=`$CC_FOR_BUILD -E "$dummy.c" 2>/dev/null | grep '^ABI' | sed 's, ,,g'` + eval "$cc_set_abi" + case $ABI in + eabi | eabihf) CPU=armv8l; LIBCABI=$LIBC$ABI ;; + esac + fi + GUESS=$CPU-unknown-linux-$LIBCABI ;; aarch64_be:Linux:*:*) UNAME_MACHINE=aarch64_be @@ -1036,7 +1070,16 @@ EOF k1om:Linux:*:*) GUESS=$UNAME_MACHINE-unknown-linux-$LIBC ;; - loongarch32:Linux:*:* | loongarch64:Linux:*:* | loongarchx32:Linux:*:*) + kvx:Linux:*:*) + GUESS=$UNAME_MACHINE-unknown-linux-$LIBC + ;; + kvx:cos:*:*) + GUESS=$UNAME_MACHINE-unknown-cos + ;; + kvx:mbr:*:*) + GUESS=$UNAME_MACHINE-unknown-mbr + ;; + loongarch32:Linux:*:* | loongarch64:Linux:*:*) GUESS=$UNAME_MACHINE-unknown-linux-$LIBC ;; m32r*:Linux:*:*) @@ -1143,9 +1186,6 @@ EOF sparc:Linux:*:* | sparc64:Linux:*:*) GUESS=$UNAME_MACHINE-unknown-linux-$LIBC ;; - sw_64:Linux:*:*) - GUESS=$UNAME_MACHINE-unknown-linux-$LIBC - ;; tile*:Linux:*:*) GUESS=$UNAME_MACHINE-unknown-linux-$LIBC ;; @@ -1154,16 +1194,27 @@ EOF ;; x86_64:Linux:*:*) set_cc_for_build + CPU=$UNAME_MACHINE LIBCABI=$LIBC if test "$CC_FOR_BUILD" != no_compiler_found; then - if (echo '#ifdef __ILP32__'; echo IS_X32; echo '#endif') | \ - (CCOPTS="" $CC_FOR_BUILD -E - 2>/dev/null) | \ - grep IS_X32 >/dev/null - then - LIBCABI=${LIBC}x32 - fi + ABI=64 + sed 's/^ //' << EOF > "$dummy.c" + #ifdef __i386__ + ABI=x86 + #else + #ifdef __ILP32__ + ABI=x32 + #endif + #endif +EOF + cc_set_abi=`$CC_FOR_BUILD -E "$dummy.c" 2>/dev/null | grep '^ABI' | sed 's, ,,g'` + eval "$cc_set_abi" + case $ABI in + x86) CPU=i686 ;; + x32) LIBCABI=${LIBC}x32 ;; + esac fi - GUESS=$UNAME_MACHINE-pc-linux-$LIBCABI + GUESS=$CPU-pc-linux-$LIBCABI ;; xtensa*:Linux:*:*) GUESS=$UNAME_MACHINE-unknown-linux-$LIBC @@ -1183,7 +1234,7 @@ EOF GUESS=$UNAME_MACHINE-pc-sysv4.2uw$UNAME_VERSION ;; i*86:OS/2:*:*) - # If we were able to find `uname', then EMX Unix compatibility + # If we were able to find 'uname', then EMX Unix compatibility # is probably installed. GUESS=$UNAME_MACHINE-pc-os2-emx ;; @@ -1324,7 +1375,7 @@ EOF GUESS=ns32k-sni-sysv fi ;; - PENTIUM:*:4.0*:*) # Unisys `ClearPath HMP IX 4000' SVR4/MP effort + PENTIUM:*:4.0*:*) # Unisys 'ClearPath HMP IX 4000' SVR4/MP effort # says <Richard.M.Bartel@ccMail.Census.GOV> GUESS=i586-unisys-sysv4 ;; @@ -1370,8 +1421,11 @@ EOF BePC:Haiku:*:*) # Haiku running on Intel PC compatible. GUESS=i586-pc-haiku ;; - x86_64:Haiku:*:*) - GUESS=x86_64-unknown-haiku + ppc:Haiku:*:*) # Haiku running on Apple PowerPC + GUESS=powerpc-apple-haiku + ;; + *:Haiku:*:*) # Haiku modern gcc (not bound by BeOS compat) + GUESS=$UNAME_MACHINE-unknown-haiku ;; SX-4:SUPER-UX:*:*) GUESS=sx4-nec-superux$UNAME_RELEASE @@ -1543,6 +1597,9 @@ EOF *:Unleashed:*:*) GUESS=$UNAME_MACHINE-unknown-unleashed$UNAME_RELEASE ;; + *:Ironclad:*:*) + GUESS=$UNAME_MACHINE-unknown-ironclad + ;; esac # Do we have a guess based on uname results? @@ -1566,6 +1623,7 @@ cat > "$dummy.c" <<EOF #endif #endif #endif +int main () { #if defined (sony) diff --git a/src/dfm-burn/3rdparty/udfclient/config.sub b/src/dfm-burn/3rdparty/udfclient/config.sub index 370aead4..4aaae46f 100755 --- a/src/dfm-burn/3rdparty/udfclient/config.sub +++ b/src/dfm-burn/3rdparty/udfclient/config.sub @@ -1,10 +1,10 @@ #! /bin/sh # Configuration validation subroutine script. -# Copyright 1992-2022 Free Software Foundation, Inc. +# Copyright 1992-2024 Free Software Foundation, Inc. -# shellcheck disable=SC2006,SC2268 # see below for rationale +# shellcheck disable=SC2006,SC2268,SC2162 # see below for rationale -timestamp='2022-01-03' +timestamp='2024-05-27' # This file is free software; you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by @@ -76,13 +76,13 @@ Report bugs and patches to <config-patches@gnu.org>." version="\ GNU config.sub ($timestamp) -Copyright 1992-2022 Free Software Foundation, Inc. +Copyright 1992-2024 Free Software Foundation, Inc. This is free software; see the source for copying conditions. There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE." help=" -Try \`$me --help' for more information." +Try '$me --help' for more information." # Parse command line while test $# -gt 0 ; do @@ -120,7 +120,6 @@ case $# in esac # Split fields of configuration type -# shellcheck disable=SC2162 saved_IFS=$IFS IFS="-" read field1 field2 field3 field4 <<EOF $1 @@ -130,7 +129,7 @@ IFS=$saved_IFS # Separate into logical components for further validation case $1 in *-*-*-*-*) - echo Invalid configuration \`"$1"\': more than four components >&2 + echo "Invalid configuration '$1': more than four components" >&2 exit 1 ;; *-*-*-*) @@ -142,10 +141,21 @@ case $1 in # parts maybe_os=$field2-$field3 case $maybe_os in - nto-qnx* | linux-* | uclinux-uclibc* \ - | uclinux-gnu* | kfreebsd*-gnu* | knetbsd*-gnu* | netbsd*-gnu* \ - | netbsd*-eabi* | kopensolaris*-gnu* | cloudabi*-eabi* \ - | storm-chaos* | os2-emx* | rtmk-nova*) + cloudabi*-eabi* \ + | kfreebsd*-gnu* \ + | knetbsd*-gnu* \ + | kopensolaris*-gnu* \ + | linux-* \ + | managarm-* \ + | netbsd*-eabi* \ + | netbsd*-gnu* \ + | nto-qnx* \ + | os2-emx* \ + | rtmk-nova* \ + | storm-chaos* \ + | uclinux-gnu* \ + | uclinux-uclibc* \ + | windows-* ) basic_machine=$field1 basic_os=$maybe_os ;; @@ -160,8 +170,12 @@ case $1 in esac ;; *-*) - # A lone config we happen to match not fitting any pattern case $field1-$field2 in + # Shorthands that happen to contain a single dash + convex-c[12] | convex-c3[248]) + basic_machine=$field2-convex + basic_os= + ;; decstation-3100) basic_machine=mips-dec basic_os= @@ -169,28 +183,88 @@ case $1 in *-*) # Second component is usually, but not always the OS case $field2 in - # Prevent following clause from handling this valid os + # Do not treat sunos as a manufacturer sun*os*) basic_machine=$field1 basic_os=$field2 ;; - zephyr*) - basic_machine=$field1-unknown - basic_os=$field2 - ;; # Manufacturers - dec* | mips* | sequent* | encore* | pc533* | sgi* | sony* \ - | att* | 7300* | 3300* | delta* | motorola* | sun[234]* \ - | unicom* | ibm* | next | hp | isi* | apollo | altos* \ - | convergent* | ncr* | news | 32* | 3600* | 3100* \ - | hitachi* | c[123]* | convex* | sun | crds | omron* | dg \ - | ultra | tti* | harris | dolphin | highlevel | gould \ - | cbm | ns | masscomp | apple | axis | knuth | cray \ - | microblaze* | sim | cisco \ - | oki | wec | wrs | winbond) + 3100* \ + | 32* \ + | 3300* \ + | 3600* \ + | 7300* \ + | acorn \ + | altos* \ + | apollo \ + | apple \ + | atari \ + | att* \ + | axis \ + | be \ + | bull \ + | cbm \ + | ccur \ + | cisco \ + | commodore \ + | convergent* \ + | convex* \ + | cray \ + | crds \ + | dec* \ + | delta* \ + | dg \ + | digital \ + | dolphin \ + | encore* \ + | gould \ + | harris \ + | highlevel \ + | hitachi* \ + | hp \ + | ibm* \ + | intergraph \ + | isi* \ + | knuth \ + | masscomp \ + | microblaze* \ + | mips* \ + | motorola* \ + | ncr* \ + | news \ + | next \ + | ns \ + | oki \ + | omron* \ + | pc533* \ + | rebel \ + | rom68k \ + | rombug \ + | semi \ + | sequent* \ + | siemens \ + | sgi* \ + | siemens \ + | sim \ + | sni \ + | sony* \ + | stratus \ + | sun \ + | sun[234]* \ + | tektronix \ + | tti* \ + | ultra \ + | unicom* \ + | wec \ + | winbond \ + | wrs) basic_machine=$field1-$field2 basic_os= ;; + zephyr*) + basic_machine=$field1-unknown + basic_os=$field2 + ;; *) basic_machine=$field1 basic_os=$field2 @@ -271,26 +345,6 @@ case $1 in basic_machine=arm-unknown basic_os=cegcc ;; - convex-c1) - basic_machine=c1-convex - basic_os=bsd - ;; - convex-c2) - basic_machine=c2-convex - basic_os=bsd - ;; - convex-c32) - basic_machine=c32-convex - basic_os=bsd - ;; - convex-c34) - basic_machine=c34-convex - basic_os=bsd - ;; - convex-c38) - basic_machine=c38-convex - basic_os=bsd - ;; cray) basic_machine=j90-cray basic_os=unicos @@ -713,15 +767,26 @@ case $basic_machine in vendor=dec basic_os=tops20 ;; - delta | 3300 | motorola-3300 | motorola-delta \ - | 3300-motorola | delta-motorola) + delta | 3300 | delta-motorola | 3300-motorola | motorola-delta | motorola-3300) cpu=m68k vendor=motorola ;; - dpx2*) + # This used to be dpx2*, but that gets the RS6000-based + # DPX/20 and the x86-based DPX/2-100 wrong. See + # https://oldskool.silicium.org/stations/bull_dpx20.htm + # https://www.feb-patrimoine.com/english/bull_dpx2.htm + # https://www.feb-patrimoine.com/english/unix_and_bull.htm + dpx2 | dpx2[23]00 | dpx2[23]xx) cpu=m68k vendor=bull - basic_os=sysv3 + ;; + dpx2100 | dpx21xx) + cpu=i386 + vendor=bull + ;; + dpx20) + cpu=rs6000 + vendor=bull ;; encore | umax | mmax) cpu=ns32k @@ -836,18 +901,6 @@ case $basic_machine in next | m*-next) cpu=m68k vendor=next - case $basic_os in - openstep*) - ;; - nextstep*) - ;; - ns2*) - basic_os=nextstep2 - ;; - *) - basic_os=nextstep3 - ;; - esac ;; np1) cpu=np1 @@ -936,14 +989,13 @@ case $basic_machine in ;; *-*) - # shellcheck disable=SC2162 saved_IFS=$IFS IFS="-" read cpu vendor <<EOF $basic_machine EOF IFS=$saved_IFS ;; - # We use `pc' rather than `unknown' + # We use 'pc' rather than 'unknown' # because (1) that's what they normally are, and # (2) the word "unknown" tends to confuse beginning users. i*86 | x86_64) @@ -971,15 +1023,19 @@ unset -v basic_machine # Decode basic machines in the full and proper CPU-Company form. case $cpu-$vendor in - # Here we handle the default manufacturer of certain CPU types in canonical form. It is in - # some cases the only manufacturer, in others, it is the most popular. + # Here we handle the default manufacturer of certain CPU types in canonical form. + # It is in some cases the only manufacturer, in others, it is the most popular. + c[12]-convex | c[12]-unknown | c3[248]-convex | c3[248]-unknown) + vendor=convex + basic_os=${basic_os:-bsd} + ;; craynv-unknown) vendor=cray basic_os=${basic_os:-unicosmp} ;; c90-unknown | c90-cray) vendor=cray - basic_os=${Basic_os:-unicos} + basic_os=${basic_os:-unicos} ;; fx80-unknown) vendor=alliant @@ -1025,11 +1081,29 @@ case $cpu-$vendor in vendor=alt basic_os=${basic_os:-linux-gnueabihf} ;; - dpx20-unknown | dpx20-bull) - cpu=rs6000 - vendor=bull + + # Normalized CPU+vendor pairs that imply an OS, if not otherwise specified + m68k-isi) + basic_os=${basic_os:-sysv} + ;; + m68k-sony) + basic_os=${basic_os:-newsos} + ;; + m68k-tektronix) + basic_os=${basic_os:-bsd} + ;; + m88k-harris) + basic_os=${basic_os:-sysv3} + ;; + i386-bull | m68k-bull) + basic_os=${basic_os:-sysv3} + ;; + rs6000-bull) basic_os=${basic_os:-bosx} ;; + mips-sni) + basic_os=${basic_os:-sysv4} + ;; # Here we normalize CPU types irrespective of the vendor amd64-*) @@ -1037,7 +1111,7 @@ case $cpu-$vendor in ;; blackfin-*) cpu=bfin - basic_os=linux + basic_os=${basic_os:-linux} ;; c54x-*) cpu=tic54x @@ -1060,7 +1134,7 @@ case $cpu-$vendor in ;; m68knommu-*) cpu=m68k - basic_os=linux + basic_os=${basic_os:-linux} ;; m9s12z-* | m68hcs12z-* | hcs12z-* | s12z-*) cpu=s12z @@ -1070,12 +1144,12 @@ case $cpu-$vendor in ;; parisc-*) cpu=hppa - basic_os=linux + basic_os=${basic_os:-linux} ;; pentium-* | p5-* | k5-* | k6-* | nexgen-* | viac3-*) cpu=i586 ;; - pentiumpro-* | p6-* | 6x86-* | athlon-* | athalon_*-*) + pentiumpro-* | p6-* | 6x86-* | athlon-* | athlon_*-*) cpu=i686 ;; pentiumii-* | pentium2-* | pentiumiii-* | pentium3-*) @@ -1084,9 +1158,6 @@ case $cpu-$vendor in pentium4-*) cpu=i786 ;; - pc98-*) - cpu=i386 - ;; ppc-* | ppcbe-*) cpu=powerpc ;; @@ -1120,9 +1191,6 @@ case $cpu-$vendor in tx39el-*) cpu=mipstx39el ;; - x64-*) - cpu=x86_64 - ;; xscale-* | xscalee[bl]-*) cpu=`echo "$cpu" | sed 's/^xscale/arm/'` ;; @@ -1178,115 +1246,231 @@ case $cpu-$vendor in # Recognize the canonical CPU types that are allowed with any # company name. case $cpu in - 1750a | 580 \ + 1750a \ + | 580 \ + | [cjt]90 \ | a29k \ - | aarch64 | aarch64_be \ + | aarch64 \ + | aarch64_be \ + | aarch64c \ | abacus \ - | alpha | alphaev[4-8] | alphaev56 | alphaev6[78] \ - | alpha64 | alpha64ev[4-8] | alpha64ev56 | alpha64ev6[78] \ - | alphapca5[67] | alpha64pca5[67] \ + | alpha \ + | alpha64 \ + | alpha64ev56 \ + | alpha64ev6[78] \ + | alpha64ev[4-8] \ + | alpha64pca5[67] \ + | alphaev56 \ + | alphaev6[78] \ + | alphaev[4-8] \ + | alphapca5[67] \ | am33_2.0 \ | amdgcn \ - | arc | arceb | arc32 | arc64 \ - | arm | arm[lb]e | arme[lb] | armv* \ - | avr | avr32 \ + | arc \ + | arc32 \ + | arc64 \ + | arceb \ + | arm \ + | arm64e \ + | arm64ec \ + | arm[lb]e \ + | arme[lb] \ + | armv* \ | asmjs \ + | avr \ + | avr32 \ | ba \ - | be32 | be64 \ - | bfin | bpf | bs2000 \ - | c[123]* | c30 | [cjt]90 | c4x \ - | c8051 | clipper | craynv | csky | cydra \ - | d10v | d30v | dlx | dsp16xx \ - | e2k | elxsi | epiphany \ - | f30[01] | f700 | fido | fr30 | frv | ft32 | fx80 \ - | h8300 | h8500 \ - | hppa | hppa1.[01] | hppa2.0 | hppa2.0[nw] | hppa64 \ + | be32 \ + | be64 \ + | bfin \ + | bpf \ + | bs2000 \ + | c30 \ + | c4x \ + | c8051 \ + | c[123]* \ + | clipper \ + | craynv \ + | csky \ + | cydra \ + | d10v \ + | d30v \ + | dlx \ + | dsp16xx \ + | e2k \ + | elxsi \ + | epiphany \ + | f30[01] \ + | f700 \ + | fido \ + | fr30 \ + | frv \ + | ft32 \ + | fx80 \ + | h8300 \ + | h8500 \ | hexagon \ - | i370 | i*86 | i860 | i960 | ia16 | ia64 \ - | ip2k | iq2000 \ + | hppa \ + | hppa1.[01] \ + | hppa2.0 \ + | hppa2.0[nw] \ + | hppa64 \ + | i*86 \ + | i370 \ + | i860 \ + | i960 \ + | ia16 \ + | ia64 \ + | ip2k \ + | iq2000 \ + | javascript \ | k1om \ - | le32 | le64 \ + | kvx \ + | le32 \ + | le64 \ | lm32 \ - | loongarch32 | loongarch64 | loongarchx32 \ - | m32c | m32r | m32rle \ - | m5200 | m68000 | m680[012346]0 | m68360 | m683?2 | m68k \ - | m6811 | m68hc11 | m6812 | m68hc12 | m68hcs12x \ - | m88110 | m88k | maxq | mb | mcore | mep | metag \ - | microblaze | microblazeel \ - | mips | mipsbe | mipseb | mipsel | mipsle \ - | mips16 \ - | mips64 | mips64eb | mips64el \ - | mips64octeon | mips64octeonel \ - | mips64orion | mips64orionel \ - | mips64r5900 | mips64r5900el \ - | mips64vr | mips64vrel \ - | mips64vr4100 | mips64vr4100el \ - | mips64vr4300 | mips64vr4300el \ - | mips64vr5000 | mips64vr5000el \ - | mips64vr5900 | mips64vr5900el \ - | mipsisa32 | mipsisa32el \ - | mipsisa32r2 | mipsisa32r2el \ - | mipsisa32r3 | mipsisa32r3el \ - | mipsisa32r5 | mipsisa32r5el \ - | mipsisa32r6 | mipsisa32r6el \ - | mipsisa64 | mipsisa64el \ - | mipsisa64r2 | mipsisa64r2el \ - | mipsisa64r3 | mipsisa64r3el \ - | mipsisa64r5 | mipsisa64r5el \ - | mipsisa64r6 | mipsisa64r6el \ - | mipsisa64sb1 | mipsisa64sb1el \ - | mipsisa64sr71k | mipsisa64sr71kel \ - | mipsr5900 | mipsr5900el \ - | mipstx39 | mipstx39el \ + | loongarch32 \ + | loongarch64 \ + | m32c \ + | m32r \ + | m32rle \ + | m5200 \ + | m68000 \ + | m680[012346]0 \ + | m6811 \ + | m6812 \ + | m68360 \ + | m683?2 \ + | m68hc11 \ + | m68hc12 \ + | m68hcs12x \ + | m68k \ + | m88110 \ + | m88k \ + | maxq \ + | mb \ + | mcore \ + | mep \ + | metag \ + | microblaze \ + | microblazeel \ + | mips* \ | mmix \ - | mn10200 | mn10300 \ + | mn10200 \ + | mn10300 \ | moxie \ - | mt \ | msp430 \ - | nds32 | nds32le | nds32be \ + | mt \ + | nanomips* \ + | nds32 \ + | nds32be \ + | nds32le \ | nfp \ - | nios | nios2 | nios2eb | nios2el \ - | none | np1 | ns16k | ns32k | nvptx \ + | nios \ + | nios2 \ + | nios2eb \ + | nios2el \ + | none \ + | np1 \ + | ns16k \ + | ns32k \ + | nvptx \ | open8 \ | or1k* \ | or32 \ | orion \ + | pdp10 \ + | pdp11 \ | picochip \ - | pdp10 | pdp11 | pj | pjl | pn | power \ - | powerpc | powerpc64 | powerpc64le | powerpcle | powerpcspe \ + | pj \ + | pjl \ + | pn \ + | power \ + | powerpc \ + | powerpc64 \ + | powerpc64le \ + | powerpcle \ + | powerpcspe \ | pru \ | pyramid \ - | riscv | riscv32 | riscv32be | riscv64 | riscv64be \ - | rl78 | romp | rs6000 | rx \ - | s390 | s390x \ + | riscv \ + | riscv32 \ + | riscv32be \ + | riscv64 \ + | riscv64be \ + | rl78 \ + | romp \ + | rs6000 \ + | rx \ + | s390 \ + | s390x \ | score \ - | sh | shl \ - | sh[1234] | sh[24]a | sh[24]ae[lb] | sh[23]e | she[lb] | sh[lb]e \ - | sh[1234]e[lb] | sh[12345][lb]e | sh[23]ele | sh64 | sh64le \ - | sparc | sparc64 | sparc64b | sparc64v | sparc86x | sparclet \ + | sh \ + | sh64 \ + | sh64le \ + | sh[12345][lb]e \ + | sh[1234] \ + | sh[1234]e[lb] \ + | sh[23]e \ + | sh[23]ele \ + | sh[24]a \ + | sh[24]ae[lb] \ + | sh[lb]e \ + | she[lb] \ + | shl \ + | sparc \ + | sparc64 \ + | sparc64b \ + | sparc64v \ + | sparc86x \ + | sparclet \ | sparclite \ - | sparcv8 | sparcv9 | sparcv9b | sparcv9v | sv1 | sx* \ + | sparcv8 \ + | sparcv9 \ + | sparcv9b \ + | sparcv9v \ | spu \ - | sw_64 \ + | sv1 \ + | sx* \ | tahoe \ | thumbv7* \ - | tic30 | tic4x | tic54x | tic55x | tic6x | tic80 \ + | tic30 \ + | tic4x \ + | tic54x \ + | tic55x \ + | tic6x \ + | tic80 \ | tron \ | ubicom32 \ - | v70 | v850 | v850e | v850e1 | v850es | v850e2 | v850e2v3 \ + | v70 \ + | v810 \ + | v850 \ + | v850e \ + | v850e1 \ + | v850e2 \ + | v850e2v3 \ + | v850es \ | vax \ + | vc4 \ | visium \ | w65 \ - | wasm32 | wasm64 \ + | wasm32 \ + | wasm64 \ | we32k \ - | x86 | x86_64 | xc16x | xgate | xps100 \ - | xstormy16 | xtensa* \ + | x86 \ + | x86_64 \ + | xc16x \ + | xgate \ + | xps100 \ + | xstormy16 \ + | xtensa* \ | ymp \ - | z8k | z80) + | z80 \ + | z8k) ;; *) - echo Invalid configuration \`"$1"\': machine \`"$cpu-$vendor"\' not recognized 1>&2 + echo "Invalid configuration '$1': machine '$cpu-$vendor' not recognized" 1>&2 exit 1 ;; esac @@ -1307,11 +1491,12 @@ esac # Decode manufacturer-specific aliases for certain operating systems. -if test x$basic_os != x +if test x"$basic_os" != x then # First recognize some ad-hoc cases, or perhaps split kernel-os, or else just # set os. +obj= case $basic_os in gnu/linux*) kernel=linux @@ -1326,7 +1511,6 @@ case $basic_os in os=`echo "$basic_os" | sed -e 's|nto-qnx|qnx|'` ;; *-*) - # shellcheck disable=SC2162 saved_IFS=$IFS IFS="-" read kernel os <<EOF $basic_os @@ -1342,6 +1526,10 @@ EOF kernel=linux os=`echo "$basic_os" | sed -e 's|linux|gnu|'` ;; + managarm*) + kernel=managarm + os=`echo "$basic_os" | sed -e 's|managarm|mlibc|'` + ;; *) kernel= os=$basic_os @@ -1369,6 +1557,23 @@ case $os in unixware*) os=sysv4.2uw ;; + # The marketing names for NeXT's operating systems were + # NeXTSTEP, NeXTSTEP 2, OpenSTEP 3, OpenSTEP 4. 'openstep' is + # mapped to 'openstep3', but 'openstep1' and 'openstep2' are + # mapped to 'nextstep' and 'nextstep2', consistent with the + # treatment of SunOS/Solaris. + ns | ns1 | nextstep | nextstep1 | openstep1) + os=nextstep + ;; + ns2 | nextstep2 | openstep2) + os=nextstep2 + ;; + ns3 | nextstep3 | openstep | openstep3) + os=openstep3 + ;; + ns4 | nextstep4 | openstep4) + os=openstep4 + ;; # es1800 is here to avoid being matched by es* (a different OS) es1800*) os=ose @@ -1439,6 +1644,7 @@ case $os in ;; utek*) os=bsd + vendor=`echo "$vendor" | sed -e 's|^unknown$|tektronix|'` ;; dynix*) os=bsd @@ -1455,21 +1661,25 @@ case $os in 386bsd) os=bsd ;; - ctix* | uts*) + ctix*) os=sysv + vendor=`echo "$vendor" | sed -e 's|^unknown$|convergent|'` ;; - nova*) - os=rtmk-nova + uts*) + os=sysv ;; - ns2) - os=nextstep2 + nova*) + kernel=rtmk + os=nova ;; # Preserve the version number of sinix5. sinix5.*) os=`echo "$os" | sed -e 's|sinix|sysv|'` + vendor=`echo "$vendor" | sed -e 's|^unknown$|sni|'` ;; sinix*) os=sysv4 + vendor=`echo "$vendor" | sed -e 's|^unknown$|sni|'` ;; tpf*) os=tpf @@ -1507,10 +1717,16 @@ case $os in os=eabi ;; *) - os=elf + os= + obj=elf ;; esac ;; + aout* | coff* | elf* | pe*) + # These are machine code file formats, not OSes + obj=$os + os= + ;; *) # No normalization, but not necessarily accepted, that comes below. ;; @@ -1529,12 +1745,15 @@ else # system, and we'll never get to this point. kernel= +obj= case $cpu-$vendor in score-*) - os=elf + os= + obj=elf ;; spu-*) - os=elf + os= + obj=elf ;; *-acorn) os=riscix1.2 @@ -1544,28 +1763,35 @@ case $cpu-$vendor in os=gnu ;; arm*-semi) - os=aout + os= + obj=aout ;; c4x-* | tic4x-*) - os=coff + os= + obj=coff ;; c8051-*) - os=elf + os= + obj=elf ;; clipper-intergraph) os=clix ;; hexagon-*) - os=elf + os= + obj=elf ;; tic54x-*) - os=coff + os= + obj=coff ;; tic55x-*) - os=coff + os= + obj=coff ;; tic6x-*) - os=coff + os= + obj=coff ;; # This must come before the *-dec entry. pdp10-*) @@ -1587,28 +1813,43 @@ case $cpu-$vendor in os=sunos3 ;; m68*-cisco) - os=aout + os= + obj=aout ;; mep-*) - os=elf + os= + obj=elf + ;; + # The -sgi and -siemens entries must be before the mips- entry + # or we get the wrong os. + *-sgi) + os=irix + ;; + *-siemens) + os=sysv4 ;; mips*-cisco) - os=elf + os= + obj=elf ;; - mips*-*) - os=elf + mips*-*|nanomips*-*) + os= + obj=elf ;; or32-*) - os=coff + os= + obj=coff ;; - *-tti) # must be before sparc entry or we get the wrong os. + # This must be before the sparc-* entry or we get the wrong os. + *-tti) os=sysv3 ;; sparc-* | *-sun) os=sunos4.1.1 ;; pru-*) - os=elf + os= + obj=elf ;; *-be) os=beos @@ -1632,7 +1873,7 @@ case $cpu-$vendor in os=hpux ;; *-hitachi) - os=hiux + os=hiuxwe2 ;; i860-* | *-att | *-ncr | *-altos | *-motorola | *-convergent) os=sysv @@ -1676,12 +1917,6 @@ case $cpu-$vendor in *-encore) os=bsd ;; - *-sgi) - os=irix - ;; - *-siemens) - os=sysv4 - ;; *-masscomp) os=rtu ;; @@ -1689,10 +1924,12 @@ case $cpu-$vendor in os=uxpv ;; *-rom68k) - os=coff + os= + obj=coff ;; *-*bug) - os=coff + os= + obj=coff ;; *-apple) os=macos @@ -1710,10 +1947,11 @@ esac fi -# Now, validate our (potentially fixed-up) OS. +# Now, validate our (potentially fixed-up) individual pieces (OS, OBJ). + case $os in # Sometimes we do "kernel-libc", so those need to count as OSes. - musl* | newlib* | relibc* | uclibc*) + llvm* | musl* | newlib* | relibc* | uclibc*) ;; # Likewise for "kernel-abi" eabi* | gnueabi*) @@ -1721,83 +1959,308 @@ case $os in # VxWorks passes extra cpu info in the 4th filed. simlinux | simwindows | spe) ;; + # See `case $cpu-$os` validation below + ghcjs) + ;; # Now accept the basic system types. - # The portable systems comes first. # Each alternative MUST end in a * to match a version number. - gnu* | android* | bsd* | mach* | minix* | genix* | ultrix* | irix* \ - | *vms* | esix* | aix* | cnk* | sunos | sunos[34]* \ - | hpux* | unos* | osf* | luna* | dgux* | auroraux* | solaris* \ - | sym* | plan9* | psp* | sim* | xray* | os68k* | v88r* \ - | hiux* | abug | nacl* | netware* | windows* \ - | os9* | macos* | osx* | ios* \ - | mpw* | magic* | mmixware* | mon960* | lnews* \ - | amigaos* | amigados* | msdos* | newsos* | unicos* | aof* \ - | aos* | aros* | cloudabi* | sortix* | twizzler* \ - | nindy* | vxsim* | vxworks* | ebmon* | hms* | mvs* \ - | clix* | riscos* | uniplus* | iris* | isc* | rtu* | xenix* \ - | mirbsd* | netbsd* | dicos* | openedition* | ose* \ - | bitrig* | openbsd* | secbsd* | solidbsd* | libertybsd* | os108* \ - | ekkobsd* | freebsd* | riscix* | lynxos* | os400* \ - | bosx* | nextstep* | cxux* | aout* | elf* | oabi* \ - | ptx* | coff* | ecoff* | winnt* | domain* | vsta* \ - | udi* | lites* | ieee* | go32* | aux* | hcos* \ - | chorusrdb* | cegcc* | glidix* | serenity* \ - | cygwin* | msys* | pe* | moss* | proelf* | rtems* \ - | midipix* | mingw32* | mingw64* | mint* \ - | uxpv* | beos* | mpeix* | udk* | moxiebox* \ - | interix* | uwin* | mks* | rhapsody* | darwin* \ - | openstep* | oskit* | conix* | pw32* | nonstopux* \ - | storm-chaos* | tops10* | tenex* | tops20* | its* \ - | os2* | vos* | palmos* | uclinux* | nucleus* | morphos* \ - | scout* | superux* | sysv* | rtmk* | tpf* | windiss* \ - | powermax* | dnix* | nx6 | nx7 | sei* | dragonfly* \ - | skyos* | haiku* | rdos* | toppers* | drops* | es* \ - | onefs* | tirtos* | phoenix* | fuchsia* | redox* | bme* \ - | midnightbsd* | amdhsa* | unleashed* | emscripten* | wasi* \ - | nsk* | powerunix* | genode* | zvmoe* | qnx* | emx* | zephyr* \ - | fiwix* ) + abug \ + | aix* \ + | amdhsa* \ + | amigados* \ + | amigaos* \ + | android* \ + | aof* \ + | aos* \ + | aros* \ + | atheos* \ + | auroraux* \ + | aux* \ + | beos* \ + | bitrig* \ + | bme* \ + | bosx* \ + | bsd* \ + | cegcc* \ + | chorusos* \ + | chorusrdb* \ + | clix* \ + | cloudabi* \ + | cnk* \ + | conix* \ + | cos* \ + | cxux* \ + | cygwin* \ + | darwin* \ + | dgux* \ + | dicos* \ + | dnix* \ + | domain* \ + | dragonfly* \ + | drops* \ + | ebmon* \ + | ecoff* \ + | ekkobsd* \ + | emscripten* \ + | emx* \ + | es* \ + | fiwix* \ + | freebsd* \ + | fuchsia* \ + | genix* \ + | genode* \ + | glidix* \ + | gnu* \ + | go32* \ + | haiku* \ + | hcos* \ + | hiux* \ + | hms* \ + | hpux* \ + | ieee* \ + | interix* \ + | ios* \ + | iris* \ + | irix* \ + | ironclad* \ + | isc* \ + | its* \ + | l4re* \ + | libertybsd* \ + | lites* \ + | lnews* \ + | luna* \ + | lynxos* \ + | mach* \ + | macos* \ + | magic* \ + | mbr* \ + | midipix* \ + | midnightbsd* \ + | mingw32* \ + | mingw64* \ + | minix* \ + | mint* \ + | mirbsd* \ + | mks* \ + | mlibc* \ + | mmixware* \ + | mon960* \ + | morphos* \ + | moss* \ + | moxiebox* \ + | mpeix* \ + | mpw* \ + | msdos* \ + | msys* \ + | mvs* \ + | nacl* \ + | netbsd* \ + | netware* \ + | newsos* \ + | nextstep* \ + | nindy* \ + | nonstopux* \ + | nova* \ + | nsk* \ + | nucleus* \ + | nx6 \ + | nx7 \ + | oabi* \ + | ohos* \ + | onefs* \ + | openbsd* \ + | openedition* \ + | openstep* \ + | os108* \ + | os2* \ + | os400* \ + | os68k* \ + | os9* \ + | ose* \ + | osf* \ + | oskit* \ + | osx* \ + | palmos* \ + | phoenix* \ + | plan9* \ + | powermax* \ + | powerunix* \ + | proelf* \ + | psos* \ + | psp* \ + | ptx* \ + | pw32* \ + | qnx* \ + | rdos* \ + | redox* \ + | rhapsody* \ + | riscix* \ + | riscos* \ + | rtems* \ + | rtmk* \ + | rtu* \ + | scout* \ + | secbsd* \ + | sei* \ + | serenity* \ + | sim* \ + | skyos* \ + | solaris* \ + | solidbsd* \ + | sortix* \ + | storm-chaos* \ + | sunos \ + | sunos[34]* \ + | superux* \ + | syllable* \ + | sym* \ + | sysv* \ + | tenex* \ + | tirtos* \ + | toppers* \ + | tops10* \ + | tops20* \ + | tpf* \ + | tvos* \ + | twizzler* \ + | uclinux* \ + | udi* \ + | udk* \ + | ultrix* \ + | unicos* \ + | uniplus* \ + | unleashed* \ + | unos* \ + | uwin* \ + | uxpv* \ + | v88r* \ + |*vms* \ + | vos* \ + | vsta* \ + | vxsim* \ + | vxworks* \ + | wasi* \ + | watchos* \ + | wince* \ + | windiss* \ + | windows* \ + | winnt* \ + | xenix* \ + | xray* \ + | zephyr* \ + | zvmoe* ) ;; # This one is extra strict with allowed versions sco3.2v2 | sco3.2v[4-9]* | sco5v6*) # Don't forget version if it is 3.2v4 or newer. ;; + # This refers to builds using the UEFI calling convention + # (which depends on the architecture) and PE file format. + # Note that this is both a different calling convention and + # different file format than that of GNU-EFI + # (x86_64-w64-mingw32). + uefi) + ;; none) ;; + kernel* | msvc* ) + # Restricted further below + ;; + '') + if test x"$obj" = x + then + echo "Invalid configuration '$1': Blank OS only allowed with explicit machine code file format" 1>&2 + fi + ;; + *) + echo "Invalid configuration '$1': OS '$os' not recognized" 1>&2 + exit 1 + ;; +esac + +case $obj in + aout* | coff* | elf* | pe*) + ;; + '') + # empty is fine + ;; *) - echo Invalid configuration \`"$1"\': OS \`"$os"\' not recognized 1>&2 + echo "Invalid configuration '$1': Machine code format '$obj' not recognized" 1>&2 + exit 1 + ;; +esac + +# Here we handle the constraint that a (synthetic) cpu and os are +# valid only in combination with each other and nowhere else. +case $cpu-$os in + # The "javascript-unknown-ghcjs" triple is used by GHC; we + # accept it here in order to tolerate that, but reject any + # variations. + javascript-ghcjs) + ;; + javascript-* | *-ghcjs) + echo "Invalid configuration '$1': cpu '$cpu' is not valid with os '$os$obj'" 1>&2 exit 1 ;; esac # As a final step for OS-related things, validate the OS-kernel combination # (given a valid OS), if there is a kernel. -case $kernel-$os in - linux-gnu* | linux-dietlibc* | linux-android* | linux-newlib* \ - | linux-musl* | linux-relibc* | linux-uclibc* ) +case $kernel-$os-$obj in + linux-gnu*- | linux-android*- | linux-dietlibc*- | linux-llvm*- \ + | linux-mlibc*- | linux-musl*- | linux-newlib*- \ + | linux-relibc*- | linux-uclibc*- | linux-ohos*- ) + ;; + uclinux-uclibc*- | uclinux-gnu*- ) + ;; + managarm-mlibc*- | managarm-kernel*- ) ;; - uclinux-uclibc* ) + windows*-msvc*-) ;; - -dietlibc* | -newlib* | -musl* | -relibc* | -uclibc* ) + -dietlibc*- | -llvm*- | -mlibc*- | -musl*- | -newlib*- | -relibc*- \ + | -uclibc*- ) # These are just libc implementations, not actual OSes, and thus # require a kernel. - echo "Invalid configuration \`$1': libc \`$os' needs explicit kernel." 1>&2 + echo "Invalid configuration '$1': libc '$os' needs explicit kernel." 1>&2 exit 1 ;; - kfreebsd*-gnu* | kopensolaris*-gnu*) + -kernel*- ) + echo "Invalid configuration '$1': '$os' needs explicit kernel." 1>&2 + exit 1 ;; - vxworks-simlinux | vxworks-simwindows | vxworks-spe) + *-kernel*- ) + echo "Invalid configuration '$1': '$kernel' does not support '$os'." 1>&2 + exit 1 ;; - nto-qnx*) + *-msvc*- ) + echo "Invalid configuration '$1': '$os' needs 'windows'." 1>&2 + exit 1 ;; - os2-emx) + kfreebsd*-gnu*- | knetbsd*-gnu*- | netbsd*-gnu*- | kopensolaris*-gnu*-) + ;; + vxworks-simlinux- | vxworks-simwindows- | vxworks-spe-) + ;; + nto-qnx*-) ;; - *-eabi* | *-gnueabi*) + os2-emx-) ;; - -*) + rtmk-nova-) + ;; + *-eabi*- | *-gnueabi*-) + ;; + none--*) + # None (no kernel, i.e. freestanding / bare metal), + # can be paired with an machine code file format + ;; + -*-) # Blank kernel with real OS is always fine. ;; - *-*) - echo "Invalid configuration \`$1': Kernel \`$kernel' not known to work with OS \`$os'." 1>&2 + --*) + # Blank kernel and OS with real machine code file format is always fine. + ;; + *-*-*) + echo "Invalid configuration '$1': Kernel '$kernel' not known to work with OS '$os'." 1>&2 exit 1 ;; esac @@ -1810,7 +2273,7 @@ case $vendor in *-riscix*) vendor=acorn ;; - *-sunos*) + *-sunos* | *-solaris*) vendor=sun ;; *-cnk* | *-aix*) @@ -1880,7 +2343,7 @@ case $vendor in ;; esac -echo "$cpu-$vendor-${kernel:+$kernel-}$os" +echo "$cpu-$vendor${kernel:+-$kernel}${os:+-$os}${obj:+-$obj}" exit # Local variables: diff --git a/src/dfm-search/dfm-search-client/cli_options.cpp b/src/dfm-search/dfm-search-client/cli_options.cpp index ec6b19b8..0327cb3c 100644 --- a/src/dfm-search/dfm-search-client/cli_options.cpp +++ b/src/dfm-search/dfm-search-client/cli_options.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2022 - 2026 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later diff --git a/src/dfm-search/dfm-search-client/cli_options.h b/src/dfm-search/dfm-search-client/cli_options.h index 30611105..f93a6817 100644 --- a/src/dfm-search/dfm-search-client/cli_options.h +++ b/src/dfm-search/dfm-search-client/cli_options.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2022 - 2026 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later diff --git a/src/dfm-search/dfm-search-client/output/json_output.cpp b/src/dfm-search/dfm-search-client/output/json_output.cpp index 83f184e6..f1811862 100644 --- a/src/dfm-search/dfm-search-client/output/json_output.cpp +++ b/src/dfm-search/dfm-search-client/output/json_output.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2022 - 2026 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later @@ -14,7 +14,7 @@ using namespace dfmsearch; using namespace std; void JsonOutput::setSearchContext(const QString &keyword, const QString &searchPath, - SearchType searchType, SearchMethod searchMethod) + SearchType searchType, SearchMethod searchMethod) { m_keyword = keyword; m_searchPath = searchPath; diff --git a/src/dfm-search/dfm-search-client/output/json_output.h b/src/dfm-search/dfm-search-client/output/json_output.h index 6890ef7b..69d4db2f 100644 --- a/src/dfm-search/dfm-search-client/output/json_output.h +++ b/src/dfm-search/dfm-search-client/output/json_output.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2022 - 2026 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later @@ -24,8 +24,7 @@ class JsonOutput : public OutputFormatter public: explicit JsonOutput(bool streaming = false, QObject *parent = nullptr) - : OutputFormatter(parent) - , m_streaming(streaming) { } + : OutputFormatter(parent), m_streaming(streaming) { } void setSearchContext(const QString &keyword, const QString &searchPath, SearchType searchType, SearchMethod searchMethod) override; diff --git a/src/dfm-search/dfm-search-client/output/output_formatter.h b/src/dfm-search/dfm-search-client/output/output_formatter.h index d3383991..9312e35e 100644 --- a/src/dfm-search/dfm-search-client/output/output_formatter.h +++ b/src/dfm-search/dfm-search-client/output/output_formatter.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2022 - 2026 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later diff --git a/src/dfm-search/dfm-search-client/output/text_output.cpp b/src/dfm-search/dfm-search-client/output/text_output.cpp index c3994219..15b0bd87 100644 --- a/src/dfm-search/dfm-search-client/output/text_output.cpp +++ b/src/dfm-search/dfm-search-client/output/text_output.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2022 - 2026 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later @@ -13,7 +13,7 @@ using namespace dfmsearch; using namespace std; void TextOutput::setSearchContext(const QString &keyword, const QString &searchPath, - SearchType searchType, SearchMethod searchMethod) + SearchType searchType, SearchMethod searchMethod) { m_keyword = keyword; m_searchPath = searchPath; diff --git a/src/dfm-search/dfm-search-client/output/text_output.h b/src/dfm-search/dfm-search-client/output/text_output.h index 817ab666..4168fa28 100644 --- a/src/dfm-search/dfm-search-client/output/text_output.h +++ b/src/dfm-search/dfm-search-client/output/text_output.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2022 - 2026 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later diff --git a/src/dfm-search/dfm-search-client/time_parser.cpp b/src/dfm-search/dfm-search-client/time_parser.cpp index 9978d00c..c5485784 100644 --- a/src/dfm-search/dfm-search-client/time_parser.cpp +++ b/src/dfm-search/dfm-search-client/time_parser.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2022 - 2026 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later diff --git a/src/dfm-search/dfm-search-client/time_parser.h b/src/dfm-search/dfm-search-client/time_parser.h index 26cc9b1c..eacd9406 100644 --- a/src/dfm-search/dfm-search-client/time_parser.h +++ b/src/dfm-search/dfm-search-client/time_parser.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2022 - 2026 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later diff --git a/src/dfm-search/dfm-search-lib/core/timerangefilter.cpp b/src/dfm-search/dfm-search-lib/core/timerangefilter.cpp index 0ba06c32..aaede170 100644 --- a/src/dfm-search/dfm-search-lib/core/timerangefilter.cpp +++ b/src/dfm-search/dfm-search-lib/core/timerangefilter.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025 - 2026 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later #include <dfm-search/timerangefilter.h> @@ -11,34 +11,22 @@ DFM_SEARCH_BEGIN_NS * @brief Internal enum for range mode */ enum class RangeMode { - Invalid, // No range set - Relative, // Relative time (setLast) - rolling range from N units ago to now - FixedUnit, // Fixed unit range (yesterday, last week, etc.) - complete unit - Custom // Custom start/end + Invalid, // No range set + Relative, // Relative time (setLast) - rolling range from N units ago to now + FixedUnit, // Fixed unit range (yesterday, last week, etc.) - complete unit + Custom // Custom start/end }; class TimeRangeFilterData { public: TimeRangeFilterData() - : field(TimeField::ModifyTime) - , mode(RangeMode::Invalid) - , relativeValue(0) - , relativeUnit(TimeUnit::Days) - , includeLower(true) - , includeUpper(false) + : field(TimeField::ModifyTime), mode(RangeMode::Invalid), relativeValue(0), relativeUnit(TimeUnit::Days), includeLower(true), includeUpper(false) { } TimeRangeFilterData(const TimeRangeFilterData &other) - : field(other.field) - , mode(other.mode) - , relativeValue(other.relativeValue) - , relativeUnit(other.relativeUnit) - , startTime(other.startTime) - , endTime(other.endTime) - , includeLower(other.includeLower) - , includeUpper(other.includeUpper) + : field(other.field), mode(other.mode), relativeValue(other.relativeValue), relativeUnit(other.relativeUnit), startTime(other.startTime), endTime(other.endTime), includeLower(other.includeLower), includeUpper(other.includeUpper) { } @@ -103,7 +91,7 @@ TimeField TimeRangeFilter::timeField() const TimeRangeFilter &TimeRangeFilter::setLast(int value, TimeUnit unit) { - d->mode = RangeMode::Relative; // Rolling range from N units ago to now + d->mode = RangeMode::Relative; // Rolling range from N units ago to now d->relativeValue = value; d->relativeUnit = unit; d->startTime = QDateTime(); @@ -113,7 +101,7 @@ TimeRangeFilter &TimeRangeFilter::setLast(int value, TimeUnit unit) TimeRangeFilter &TimeRangeFilter::setToday() { - d->mode = RangeMode::FixedUnit; // Complete unit (today) + d->mode = RangeMode::FixedUnit; // Complete unit (today) d->relativeValue = 0; d->relativeUnit = TimeUnit::Days; return *this; @@ -121,7 +109,7 @@ TimeRangeFilter &TimeRangeFilter::setToday() TimeRangeFilter &TimeRangeFilter::setYesterday() { - d->mode = RangeMode::FixedUnit; // Complete unit (yesterday) + d->mode = RangeMode::FixedUnit; // Complete unit (yesterday) d->relativeValue = 1; d->relativeUnit = TimeUnit::Days; return *this; diff --git a/src/dfm-search/dfm-search-lib/core/timeresultapi.cpp b/src/dfm-search/dfm-search-lib/core/timeresultapi.cpp index 067a13d9..7f7320c8 100644 --- a/src/dfm-search/dfm-search-lib/core/timeresultapi.cpp +++ b/src/dfm-search/dfm-search-lib/core/timeresultapi.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025 - 2026 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later #include <dfm-search/timeresultapi.h> diff --git a/src/dfm-search/dfm-search-lib/utils/timerangeutils.cpp b/src/dfm-search/dfm-search-lib/utils/timerangeutils.cpp index 772c7146..3fddbf35 100644 --- a/src/dfm-search/dfm-search-lib/utils/timerangeutils.cpp +++ b/src/dfm-search/dfm-search-lib/utils/timerangeutils.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025 - 2026 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later #include "timerangeutils.h" @@ -19,11 +19,11 @@ qint64 toEpochSecs(const QDateTime &dt) } Lucene::QueryPtr buildNumericRangeQuery( - const wchar_t *fieldName, - qint64 startEpoch, - qint64 endEpoch, - bool includeLower, - bool includeUpper) + const wchar_t *fieldName, + qint64 startEpoch, + qint64 endEpoch, + bool includeLower, + bool includeUpper) { // Use INT64_MIN and INT64_MAX for unbounded ranges int64_t minVal = (startEpoch == 0) ? INT64_MIN : startEpoch; @@ -31,11 +31,11 @@ Lucene::QueryPtr buildNumericRangeQuery( // Use the default precisionStep (4) return Lucene::NumericRangeQuery::newLongRange( - Lucene::String(fieldName), - minVal, - maxVal, - includeLower, - includeUpper); + Lucene::String(fieldName), + minVal, + maxVal, + includeLower, + includeUpper); } } // namespace TimeRangeUtils diff --git a/src/dfm-search/dfm-search-lib/utils/timerangeutils.h b/src/dfm-search/dfm-search-lib/utils/timerangeutils.h index 3d6f5b7c..72e9196c 100644 --- a/src/dfm-search/dfm-search-lib/utils/timerangeutils.h +++ b/src/dfm-search/dfm-search-lib/utils/timerangeutils.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025 - 2026 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TIMERANGEUTILS_H @@ -34,11 +34,11 @@ qint64 toEpochSecs(const QDateTime &dt); * @return A NumericRangeQuery pointer, or nullptr if range is invalid */ Lucene::QueryPtr buildNumericRangeQuery( - const wchar_t *fieldName, - qint64 startEpoch, - qint64 endEpoch, - bool includeLower, - bool includeUpper); + const wchar_t *fieldName, + qint64 startEpoch, + qint64 endEpoch, + bool includeLower, + bool includeUpper); } // namespace TimeRangeUtils From 8cc43e17e98996124dd0bc4889278c0b84baf473 Mon Sep 17 00:00:00 2001 From: Zhang Sheng <zhangsheng@uniontech.com> Date: Tue, 21 Apr 2026 16:21:52 +0800 Subject: [PATCH 21/21] refactor: optimize file sorting implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Refactor file sorting logic into dedicated DFileSorter class with SortRole enum and SortConfig struct 2. Implement DSortKeyCache for performance optimization of string comparisons 3. Add thread-safe QCollator usage with thread_local storage 4. Simplify enumerator code by delegating sorting to DFileSorter 5. Add comprehensive unit tests covering sorting by name, size, time etc. 6. Support both mixed and separated directory/file sorting modes 7. Implement natural number sorting via QCollator numeric mode Log: Improved file sorting performance and maintainability Influence: 1. Test various sorting combinations (name/size/time ascending/ descending) 2. Verify mixed vs separated directory/file sorting modes 3. Check natural number sorting (e.g. file2 < file10) 4. Test edge cases (empty lists, single item) 5. Verify thread safety with concurrent sorting 6. Check localized string sorting behavior refactor: 优化文件排序实现 1. 将文件排序逻辑重构为专用的 DFileSorter 类,包含 SortRole 枚举和 SortConfig 结构体 2. 实现 DSortKeyCache 用于字符串比较的性能优化 3. 使用 thread_local 存储实现线程安全的 QCollator 4. 简化枚举器代码,将排序委托给 DFileSorter 5. 添加全面的单元测试,覆盖按名称、大小、时间等排序 6. 支持混合和分离的目录/文件排序模式 7. 通过 QCollator 数字模式实现自然数字排序 Log: 提升文件排序性能和可维护性 Influence: 1. 测试各种排序组合(名称/大小/时间 升序/降序) 2. 验证混合与分离的目录/文件排序模式 3. 检查自然数字排序(例如 file2 < file10) 4. 测试边界情况(空列表、单项) 5. 验证并发排序时的线程安全性 6. 检查本地化字符串排序行为 --- autotests/dfm-io-tests/CMakeLists.txt | 32 +- autotests/dfm-io-tests/test_main.cpp | 23 ++ autotests/dfm-io-tests/tst_dfilesorter.cpp | 396 +++++++++++++++++++++ autotests/dfm-io-tests/tst_dfm_io.cpp | 7 +- src/dfm-io/dfm-io/denumerator.cpp | 53 ++- src/dfm-io/dfm-io/sort/dfilesorter.cpp | 153 ++++++++ src/dfm-io/dfm-io/sort/dfilesorter.h | 139 ++++++++ src/dfm-io/dfm-io/sort/dsortkeycache.cpp | 60 ++++ src/dfm-io/dfm-io/sort/dsortkeycache.h | 75 ++++ 9 files changed, 908 insertions(+), 30 deletions(-) create mode 100644 autotests/dfm-io-tests/test_main.cpp create mode 100644 autotests/dfm-io-tests/tst_dfilesorter.cpp create mode 100644 src/dfm-io/dfm-io/sort/dfilesorter.cpp create mode 100644 src/dfm-io/dfm-io/sort/dfilesorter.h create mode 100644 src/dfm-io/dfm-io/sort/dsortkeycache.cpp create mode 100644 src/dfm-io/dfm-io/sort/dsortkeycache.h diff --git a/autotests/dfm-io-tests/CMakeLists.txt b/autotests/dfm-io-tests/CMakeLists.txt index e93283f6..3336d999 100644 --- a/autotests/dfm-io-tests/CMakeLists.txt +++ b/autotests/dfm-io-tests/CMakeLists.txt @@ -9,15 +9,21 @@ endif() message(STATUS "Adding unit tests for ${IO_TEST_LIB}") -# Collect test source files -file(GLOB_RECURSE TEST_SRCS - ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/*.h +# 共享的测试入口点 +set(TEST_COMMON_SRCS + ${CMAKE_CURRENT_SOURCE_DIR}/test_main.cpp +) + +# 各测试类源文件(不含 main) +set(TEST_CLASS_SRCS + ${CMAKE_CURRENT_SOURCE_DIR}/tst_dfm_io.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tst_dfilesorter.cpp ) # Create the test executable add_executable(dfm-io-test - ${TEST_SRCS} + ${TEST_COMMON_SRCS} + ${TEST_CLASS_SRCS} ) # Link against the dfm-io library and Qt Test @@ -27,17 +33,11 @@ target_link_libraries(dfm-io-test ) # Add include directories -if(DFM_BUILD_WITH_QT6) - target_include_directories(dfm-io-test - PRIVATE - ${CMAKE_SOURCE_DIR}/src/dfm-io/dfm-io - ) -else() - target_include_directories(dfm-io-test - PRIVATE - ${CMAKE_SOURCE_DIR}/src/dfm-io/dfm-io - ) -endif() +target_include_directories(dfm-io-test + PRIVATE + ${CMAKE_SOURCE_DIR}/src/dfm-io/dfm-io + ${CMAKE_SOURCE_DIR}/src/dfm-io/dfm-io/sort +) # Register the test with CTest add_test(NAME dfm-io-test COMMAND dfm-io-test) diff --git a/autotests/dfm-io-tests/test_main.cpp b/autotests/dfm-io-tests/test_main.cpp new file mode 100644 index 00000000..1432aae5 --- /dev/null +++ b/autotests/dfm-io-tests/test_main.cpp @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include <QTest> + +// 前置声明测试类 +class tst_DfmIO; +class tst_DFileSorter; + +int main(int argc, char *argv[]) +{ + int status = 0; + + // 运行各测试类 + extern int run_tst_DfmIO(int argc, char *argv[]); + extern int run_tst_DFileSorter(int argc, char *argv[]); + + status |= run_tst_DfmIO(argc, argv); + status |= run_tst_DFileSorter(argc, argv); + + return status; +} diff --git a/autotests/dfm-io-tests/tst_dfilesorter.cpp b/autotests/dfm-io-tests/tst_dfilesorter.cpp new file mode 100644 index 00000000..e6c9d9da --- /dev/null +++ b/autotests/dfm-io-tests/tst_dfilesorter.cpp @@ -0,0 +1,396 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include <QTest> +#include <QUrl> +#include <QCollator> +#include <QDebug> + +#include "sort/dfilesorter.h" +#include "sort/dsortkeycache.h" + +using namespace dfmio; + +class tst_DFileSorter : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void initTestCase(); + void cleanupTestCase(); + + // DSortKeyCache 测试 + void sortKeyCache_basic(); + void sortKeyCache_numericMode(); + void sortKeyCache_threadSafety(); + + // DFileSorter 测试 + void fileSorter_sortByName(); + void fileSorter_sortByName_descending(); + void fileSorter_sortBySize(); + void fileSorter_sortByLastModified(); + void fileSorter_sortByLastRead(); + void fileSorter_mixDirAndFile(); + void fileSorter_separateDirAndFile(); + void fileSorter_emptyList(); + void fileSorter_singleItem(); +}; + +void tst_DFileSorter::initTestCase() +{ +} + +void tst_DFileSorter::cleanupTestCase() +{ +} + +// ==================== DSortKeyCache 测试 ==================== + +void tst_DFileSorter::sortKeyCache_basic() +{ + DSortKeyCache &cache = DSortKeyCache::instance(); + + // 相同字符串应返回相同的排序键 + QCollatorSortKey key1 = cache.sortKey("test"); + QCollatorSortKey key2 = cache.sortKey("test"); + QCOMPARE(key1.compare(key2), 0); +} + +void tst_DFileSorter::sortKeyCache_numericMode() +{ + DSortKeyCache &cache = DSortKeyCache::instance(); + + // 数字自然排序:file2 < file10 + QCollatorSortKey key2 = cache.sortKey("file2"); + QCollatorSortKey key10 = cache.sortKey("file10"); + + // file2 应该排在 file10 前面(numericMode 开启) + QVERIFY(key2.compare(key10) < 0); +} + +void tst_DFileSorter::sortKeyCache_threadSafety() +{ + // 测试 thread_local QCollator 的线程安全性 + // 每个线程应该有独立的 QCollator 实例 + DSortKeyCache &cache = DSortKeyCache::instance(); + + // 验证排序键可以正常比较(不假设大小写不敏感) + QCollatorSortKey key1 = cache.sortKey("abc"); + + // 相同字符串返回相同排序键 + QCollatorSortKey key2 = cache.sortKey("abc"); + QCOMPARE(key1.compare(key2), 0); +} + +// ==================== DFileSorter 测试 ==================== + +void tst_DFileSorter::fileSorter_sortByName() +{ + DFileSorter::SortConfig config; + config.role = DFileSorter::SortRole::Name; + config.order = Qt::AscendingOrder; + config.mixDirAndFile = true; + + DFileSorter sorter(config); + + // 创建测试文件列表 + QList<QSharedPointer<DEnumerator::SortFileInfo>> files; + + auto file1 = QSharedPointer<DEnumerator::SortFileInfo>::create(); + file1->url = QUrl::fromLocalFile("/test/file10.txt"); + file1->isDir = false; + file1->filesize = 100; + files.append(file1); + + auto file2 = QSharedPointer<DEnumerator::SortFileInfo>::create(); + file2->url = QUrl::fromLocalFile("/test/file2.txt"); + file2->isDir = false; + file2->filesize = 200; + files.append(file2); + + auto file3 = QSharedPointer<DEnumerator::SortFileInfo>::create(); + file3->url = QUrl::fromLocalFile("/test/file1.txt"); + file3->isDir = false; + file3->filesize = 300; + files.append(file3); + + auto sorted = sorter.sort(std::move(files)); + + // 验证排序结果:file1 < file2 < file10(自然排序) + QCOMPARE(sorted.size(), 3); + QCOMPARE(sorted[0]->url.fileName(), QString("file1.txt")); + QCOMPARE(sorted[1]->url.fileName(), QString("file2.txt")); + QCOMPARE(sorted[2]->url.fileName(), QString("file10.txt")); +} + +void tst_DFileSorter::fileSorter_sortByName_descending() +{ + DFileSorter::SortConfig config; + config.role = DFileSorter::SortRole::Name; + config.order = Qt::DescendingOrder; + config.mixDirAndFile = true; + + DFileSorter sorter(config); + + QList<QSharedPointer<DEnumerator::SortFileInfo>> files; + + auto file1 = QSharedPointer<DEnumerator::SortFileInfo>::create(); + file1->url = QUrl::fromLocalFile("/test/a.txt"); + file1->isDir = false; + files.append(file1); + + auto file2 = QSharedPointer<DEnumerator::SortFileInfo>::create(); + file2->url = QUrl::fromLocalFile("/test/b.txt"); + file2->isDir = false; + files.append(file2); + + auto file3 = QSharedPointer<DEnumerator::SortFileInfo>::create(); + file3->url = QUrl::fromLocalFile("/test/c.txt"); + file3->isDir = false; + files.append(file3); + + auto sorted = sorter.sort(std::move(files)); + + // 降序:c > b > a + QCOMPARE(sorted.size(), 3); + QCOMPARE(sorted[0]->url.fileName(), QString("c.txt")); + QCOMPARE(sorted[1]->url.fileName(), QString("b.txt")); + QCOMPARE(sorted[2]->url.fileName(), QString("a.txt")); +} + +void tst_DFileSorter::fileSorter_sortBySize() +{ + DFileSorter::SortConfig config; + config.role = DFileSorter::SortRole::Size; + config.order = Qt::AscendingOrder; + config.mixDirAndFile = true; + + DFileSorter sorter(config); + + QList<QSharedPointer<DEnumerator::SortFileInfo>> files; + + auto file1 = QSharedPointer<DEnumerator::SortFileInfo>::create(); + file1->url = QUrl::fromLocalFile("/test/large.txt"); + file1->isDir = false; + file1->filesize = 1000; + files.append(file1); + + auto file2 = QSharedPointer<DEnumerator::SortFileInfo>::create(); + file2->url = QUrl::fromLocalFile("/test/small.txt"); + file2->isDir = false; + file2->filesize = 100; + files.append(file2); + + auto file3 = QSharedPointer<DEnumerator::SortFileInfo>::create(); + file3->url = QUrl::fromLocalFile("/test/medium.txt"); + file3->isDir = false; + file3->filesize = 500; + files.append(file3); + + auto sorted = sorter.sort(std::move(files)); + + // 按大小升序:small < medium < large + QCOMPARE(sorted.size(), 3); + QCOMPARE(sorted[0]->url.fileName(), QString("small.txt")); + QCOMPARE(sorted[1]->url.fileName(), QString("medium.txt")); + QCOMPARE(sorted[2]->url.fileName(), QString("large.txt")); +} + +void tst_DFileSorter::fileSorter_sortByLastModified() +{ + DFileSorter::SortConfig config; + config.role = DFileSorter::SortRole::LastModified; + config.order = Qt::AscendingOrder; + config.mixDirAndFile = true; + + DFileSorter sorter(config); + + QList<QSharedPointer<DEnumerator::SortFileInfo>> files; + + auto file1 = QSharedPointer<DEnumerator::SortFileInfo>::create(); + file1->url = QUrl::fromLocalFile("/test/newest.txt"); + file1->isDir = false; + file1->lastModifed = 3000; + file1->lastModifedNs = 0; + files.append(file1); + + auto file2 = QSharedPointer<DEnumerator::SortFileInfo>::create(); + file2->url = QUrl::fromLocalFile("/test/oldest.txt"); + file2->isDir = false; + file2->lastModifed = 1000; + file2->lastModifedNs = 0; + files.append(file2); + + auto file3 = QSharedPointer<DEnumerator::SortFileInfo>::create(); + file3->url = QUrl::fromLocalFile("/test/middle.txt"); + file3->isDir = false; + file3->lastModifed = 2000; + file3->lastModifedNs = 0; + files.append(file3); + + auto sorted = sorter.sort(std::move(files)); + + // 按修改时间升序:oldest < middle < newest + QCOMPARE(sorted.size(), 3); + QCOMPARE(sorted[0]->url.fileName(), QString("oldest.txt")); + QCOMPARE(sorted[1]->url.fileName(), QString("middle.txt")); + QCOMPARE(sorted[2]->url.fileName(), QString("newest.txt")); +} + +void tst_DFileSorter::fileSorter_sortByLastRead() +{ + DFileSorter::SortConfig config; + config.role = DFileSorter::SortRole::LastRead; + config.order = Qt::AscendingOrder; + config.mixDirAndFile = true; + + DFileSorter sorter(config); + + QList<QSharedPointer<DEnumerator::SortFileInfo>> files; + + auto file1 = QSharedPointer<DEnumerator::SortFileInfo>::create(); + file1->url = QUrl::fromLocalFile("/test/read_newest.txt"); + file1->isDir = false; + file1->lastRead = 3000; + file1->lastReadNs = 0; + files.append(file1); + + auto file2 = QSharedPointer<DEnumerator::SortFileInfo>::create(); + file2->url = QUrl::fromLocalFile("/test/read_oldest.txt"); + file2->isDir = false; + file2->lastRead = 1000; + file2->lastReadNs = 0; + files.append(file2); + + auto sorted = sorter.sort(std::move(files)); + + // 按访问时间升序:oldest < newest + QCOMPARE(sorted.size(), 2); + QCOMPARE(sorted[0]->url.fileName(), QString("read_oldest.txt")); + QCOMPARE(sorted[1]->url.fileName(), QString("read_newest.txt")); +} + +void tst_DFileSorter::fileSorter_mixDirAndFile() +{ + DFileSorter::SortConfig config; + config.role = DFileSorter::SortRole::Name; + config.order = Qt::AscendingOrder; + config.mixDirAndFile = true; // 混排模式 + + DFileSorter sorter(config); + + QList<QSharedPointer<DEnumerator::SortFileInfo>> files; + + auto dir1 = QSharedPointer<DEnumerator::SortFileInfo>::create(); + dir1->url = QUrl::fromLocalFile("/test/docs"); + dir1->isDir = true; + dir1->isSymLink = false; + files.append(dir1); + + auto file1 = QSharedPointer<DEnumerator::SortFileInfo>::create(); + file1->url = QUrl::fromLocalFile("/test/afile.txt"); + file1->isDir = false; + files.append(file1); + + auto dir2 = QSharedPointer<DEnumerator::SortFileInfo>::create(); + dir2->url = QUrl::fromLocalFile("/test/apps"); + dir2->isDir = true; + dir2->isSymLink = false; + files.append(dir2); + + auto sorted = sorter.sort(std::move(files)); + + // 混排模式:所有项一起排序 + QCOMPARE(sorted.size(), 3); + QCOMPARE(sorted[0]->url.fileName(), QString("afile.txt")); // a 排最前 + QCOMPARE(sorted[1]->url.fileName(), QString("apps")); + QCOMPARE(sorted[2]->url.fileName(), QString("docs")); +} + +void tst_DFileSorter::fileSorter_separateDirAndFile() +{ + DFileSorter::SortConfig config; + config.role = DFileSorter::SortRole::Name; + config.order = Qt::AscendingOrder; + config.mixDirAndFile = false; // 分离模式:目录在前 + + DFileSorter sorter(config); + + QList<QSharedPointer<DEnumerator::SortFileInfo>> files; + + auto file1 = QSharedPointer<DEnumerator::SortFileInfo>::create(); + file1->url = QUrl::fromLocalFile("/test/a_file.txt"); + file1->isDir = false; + files.append(file1); + + auto dir1 = QSharedPointer<DEnumerator::SortFileInfo>::create(); + dir1->url = QUrl::fromLocalFile("/test/z_dir"); + dir1->isDir = true; + dir1->isSymLink = false; + files.append(dir1); + + auto file2 = QSharedPointer<DEnumerator::SortFileInfo>::create(); + file2->url = QUrl::fromLocalFile("/test/b_file.txt"); + file2->isDir = false; + files.append(file2); + + auto dir2 = QSharedPointer<DEnumerator::SortFileInfo>::create(); + dir2->url = QUrl::fromLocalFile("/test/a_dir"); + dir2->isDir = true; + dir2->isSymLink = false; + files.append(dir2); + + auto sorted = sorter.sort(std::move(files)); + + // 分离模式:目录在前(按名称排序),文件在后(按名称排序) + QCOMPARE(sorted.size(), 4); + // 目录在前 + QCOMPARE(sorted[0]->url.fileName(), QString("a_dir")); + QCOMPARE(sorted[1]->url.fileName(), QString("z_dir")); + // 文件在后 + QCOMPARE(sorted[2]->url.fileName(), QString("a_file.txt")); + QCOMPARE(sorted[3]->url.fileName(), QString("b_file.txt")); +} + +void tst_DFileSorter::fileSorter_emptyList() +{ + DFileSorter::SortConfig config; + config.role = DFileSorter::SortRole::Name; + + DFileSorter sorter(config); + + QList<QSharedPointer<DEnumerator::SortFileInfo>> files; + auto sorted = sorter.sort(std::move(files)); + + QVERIFY(sorted.isEmpty()); +} + +void tst_DFileSorter::fileSorter_singleItem() +{ + DFileSorter::SortConfig config; + config.role = DFileSorter::SortRole::Name; + + DFileSorter sorter(config); + + QList<QSharedPointer<DEnumerator::SortFileInfo>> files; + + auto file1 = QSharedPointer<DEnumerator::SortFileInfo>::create(); + file1->url = QUrl::fromLocalFile("/test/single.txt"); + file1->isDir = false; + files.append(file1); + + auto sorted = sorter.sort(std::move(files)); + + QCOMPARE(sorted.size(), 1); + QCOMPARE(sorted[0]->url.fileName(), QString("single.txt")); +} + +int run_tst_DFileSorter(int argc, char *argv[]) +{ + tst_DFileSorter tc; + return QTest::qExec(&tc, argc, argv); +} + +#include "tst_dfilesorter.moc" + diff --git a/autotests/dfm-io-tests/tst_dfm_io.cpp b/autotests/dfm-io-tests/tst_dfm_io.cpp index da783bca..17b33637 100644 --- a/autotests/dfm-io-tests/tst_dfm_io.cpp +++ b/autotests/dfm-io-tests/tst_dfm_io.cpp @@ -30,5 +30,10 @@ void tst_DfmIO::initialization_test() QVERIFY(true); } -QTEST_MAIN(tst_DfmIO) +int run_tst_DfmIO(int argc, char *argv[]) +{ + tst_DfmIO tc; + return QTest::qExec(&tc, argc, argv); +} + #include "tst_dfm_io.moc" diff --git a/src/dfm-io/dfm-io/denumerator.cpp b/src/dfm-io/dfm-io/denumerator.cpp index 6c129566..bc14e046 100644 --- a/src/dfm-io/dfm-io/denumerator.cpp +++ b/src/dfm-io/dfm-io/denumerator.cpp @@ -5,6 +5,7 @@ #include "private/denumerator_p.h" #include "utils/dlocalhelper.h" +#include "sort/dfilesorter.h" #include <dfm-io/denumerator.h> #include <dfm-io/dfileinfo.h> @@ -803,14 +804,29 @@ QList<QSharedPointer<DFileInfo>> DEnumerator::fileInfoList() QList<QSharedPointer<DEnumerator::SortFileInfo>> DEnumerator::sortFileInfoList() { - if (!d->fts) - d->openDirByfts(); + // 使用 FTS 遍历但不预排序(传 nullptr 作为比较函数) + if (!d->fts) { + char *paths[2] = { nullptr, nullptr }; + paths[0] = d->filePath(d->uri); + if (!paths[0]) { + qWarning() << "Failed to get file path for uri:" << d->uri; + return {}; + } + d->fts = fts_open(paths, FTS_COMFOLLOW, nullptr); + free(paths[0]); + + if (!d->fts) { + qWarning() << "fts_open open error : " << QString::fromLocal8Bit(strerror(errno)); + d->error.setCode(DFMIOErrorCode::DFM_IO_ERROR_FTS_OPEN); + return {}; + } + } if (!d->fts) return {}; - QList<QSharedPointer<DEnumerator::SortFileInfo>> listFile; - QList<QSharedPointer<DEnumerator::SortFileInfo>> listDir; + // 收集所有文件信息 + QList<QSharedPointer<SortFileInfo>> allFiles; QSet<QString> hideList; QUrl urlHidden = d->buildUrl(d->uri, ".hidden"); hideList = DLocalHelper::hideListFromUrl(urlHidden); @@ -819,7 +835,8 @@ QList<QSharedPointer<DEnumerator::SortFileInfo>> DEnumerator::sortFileInfoList() qWarning() << "Failed to get file path for uri:" << d->uri; return {}; } - while (1) { + + while (true) { FTSENT *ent = fts_read(d->fts); if (ent == nullptr) { @@ -834,20 +851,30 @@ QList<QSharedPointer<DEnumerator::SortFileInfo>> DEnumerator::sortFileInfoList() if (strcmp(ent->fts_path, dirPath) == 0 || flag == FTS_DP) continue; - d->insertSortFileInfoList(listFile, listDir, ent, d->fts, hideList); + auto sortInfo = DLocalHelper::createSortFileInfo(ent, hideList); + // 跳过子目录遍历 + if (sortInfo->isDir && !sortInfo->isSymLink) { + fts_set(d->fts, ent, FTS_SKIP); + } + allFiles.append(sortInfo); } fts_close(d->fts); d->fts = nullptr; - - // Clean up allocated memory free(dirPath); - if (d->isMixDirAndFile) - return listFile; - - listDir.append(listFile); - return listDir; + // 使用 DFileSorter 进行排序 + DFileSorter::SortConfig config; + // 映射排序角色:kSortRoleCompareFileName(1) -> Name(0), 以此类推 + // kSortRoleCompareDefault(0) 默认按名称排序 + config.role = (d->sortRoleFlag == SortRoleCompareFlag::kSortRoleCompareDefault) + ? DFileSorter::SortRole::Name + : static_cast<DFileSorter::SortRole>(static_cast<uint8_t>(d->sortRoleFlag) - 1); + config.order = d->sortOrder; + config.mixDirAndFile = d->isMixDirAndFile; + + DFileSorter sorter(config); + return sorter.sort(std::move(allFiles)); } DFMIOError DEnumerator::lastError() const diff --git a/src/dfm-io/dfm-io/sort/dfilesorter.cpp b/src/dfm-io/dfm-io/sort/dfilesorter.cpp new file mode 100644 index 00000000..49f511b3 --- /dev/null +++ b/src/dfm-io/dfm-io/sort/dfilesorter.cpp @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "dfilesorter.h" +#include "dsortkeycache.h" + +#include <algorithm> + +USING_IO_NAMESPACE + +DFileSorter::DFileSorter(const SortConfig &config) + : m_config(config) +{ +} + +QList<QSharedPointer<DEnumerator::SortFileInfo>> DFileSorter::sort( + QList<QSharedPointer<DEnumerator::SortFileInfo>> &&files) +{ + if (files.isEmpty()) { + return files; + } + + QList<QSharedPointer<DEnumerator::SortFileInfo>> result; + + if (m_config.mixDirAndFile) { + // 混排模式:所有文件一起排序 + std::stable_sort(files.begin(), files.end(), getCompareFunc()); + applySortOrder(files); + result = std::move(files); + } else { + // 分离模式:目录在前,文件在后,分别排序 + QList<QSharedPointer<DEnumerator::SortFileInfo>> dirs; + QList<QSharedPointer<DEnumerator::SortFileInfo>> regularFiles; + + separateDirAndFile(std::move(files), dirs, regularFiles); + + std::stable_sort(dirs.begin(), dirs.end(), getCompareFunc()); + applySortOrder(dirs); + + std::stable_sort(regularFiles.begin(), regularFiles.end(), getCompareFunc()); + applySortOrder(regularFiles); + + // 目录排在前面 + result = std::move(dirs); + result.append(std::move(regularFiles)); + } + + return result; +} + +QList<QSharedPointer<DEnumerator::SortFileInfo>> DFileSorter::sort( + const QList<QSharedPointer<DEnumerator::SortFileInfo>> &files) +{ + // 拷贝后排序 + QList<QSharedPointer<DEnumerator::SortFileInfo>> copy = files; + return sort(std::move(copy)); +} + +DFileSorter::CompareFunc DFileSorter::getCompareFunc() const +{ + switch (m_config.role) { + case SortRole::Name: + return [this](const auto &a, const auto &b) { return compareByName(a, b); }; + case SortRole::Size: + return [this](const auto &a, const auto &b) { return compareBySize(a, b); }; + case SortRole::LastModified: + return [this](const auto &a, const auto &b) { return compareByLastModified(a, b); }; + case SortRole::LastRead: + return [this](const auto &a, const auto &b) { return compareByLastRead(a, b); }; + default: + return [this](const auto &a, const auto &b) { return compareByName(a, b); }; + } +} + +bool DFileSorter::compareByName( + const QSharedPointer<DEnumerator::SortFileInfo> &a, + const QSharedPointer<DEnumerator::SortFileInfo> &b) const +{ + // 使用预缓存的排序键进行比较 + QString nameA = a->url.fileName(); + QString nameB = b->url.fileName(); + + QCollatorSortKey keyA = DSortKeyCache::instance().sortKey(nameA); + QCollatorSortKey keyB = DSortKeyCache::instance().sortKey(nameB); + + return keyA < keyB; +} + +bool DFileSorter::compareBySize( + const QSharedPointer<DEnumerator::SortFileInfo> &a, + const QSharedPointer<DEnumerator::SortFileInfo> &b) const +{ + // 先按大小比较,大小相同时按名称排序 + if (a->filesize != b->filesize) { + return a->filesize < b->filesize; + } + return compareByName(a, b); +} + +bool DFileSorter::compareByLastModified( + const QSharedPointer<DEnumerator::SortFileInfo> &a, + const QSharedPointer<DEnumerator::SortFileInfo> &b) const +{ + // 先比较秒,再比较纳秒 + if (a->lastModifed != b->lastModifed) { + return a->lastModifed < b->lastModifed; + } + if (a->lastModifedNs != b->lastModifedNs) { + return a->lastModifedNs < b->lastModifedNs; + } + // 时间相同,按名称排序 + return compareByName(a, b); +} + +bool DFileSorter::compareByLastRead( + const QSharedPointer<DEnumerator::SortFileInfo> &a, + const QSharedPointer<DEnumerator::SortFileInfo> &b) const +{ + // 先比较秒,再比较纳秒 + if (a->lastRead != b->lastRead) { + return a->lastRead < b->lastRead; + } + if (a->lastReadNs != b->lastReadNs) { + return a->lastReadNs < b->lastReadNs; + } + // 时间相同,按名称排序 + return compareByName(a, b); +} + +void DFileSorter::separateDirAndFile( + QList<QSharedPointer<DEnumerator::SortFileInfo>> &&files, + QList<QSharedPointer<DEnumerator::SortFileInfo>> &dirs, + QList<QSharedPointer<DEnumerator::SortFileInfo>> ®ularFiles) const +{ + dirs.reserve(files.size()); + regularFiles.reserve(files.size()); + + for (auto &file : files) { + if (file->isDir && !file->isSymLink) { + dirs.append(std::move(file)); + } else { + regularFiles.append(std::move(file)); + } + } +} + +void DFileSorter::applySortOrder(QList<QSharedPointer<DEnumerator::SortFileInfo>> &list) const +{ + if (m_config.order == Qt::DescendingOrder) { + std::reverse(list.begin(), list.end()); + } +} diff --git a/src/dfm-io/dfm-io/sort/dfilesorter.h b/src/dfm-io/dfm-io/sort/dfilesorter.h new file mode 100644 index 00000000..09067af9 --- /dev/null +++ b/src/dfm-io/dfm-io/sort/dfilesorter.h @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef DFILESORTER_H +#define DFILESORTER_H + +#include <dfm-io/dfmio_global.h> +#include <dfm-io/denumerator.h> + +#include <QSharedPointer> +#include <Qt> + +#include <functional> + +BEGIN_IO_NAMESPACE + +/** + * @brief 文件排序器 + * + * 提供高性能的文件列表排序功能,支持多种排序角色和排序顺序。 + * 使用 QCollator::sortKey() 进行预缓存排序键,优化性能。 + * + * 用法示例: + * @code + * DFileSorter::SortConfig config; + * config.role = DFileSorter::SortRole::Name; + * config.order = Qt::AscendingOrder; + * config.mixDirAndFile = false; + * + * DFileSorter sorter(config); + * auto sortedList = sorter.sort(std::move(fileList)); + * @endcode + */ +class DFileSorter +{ +public: + /** + * @brief 排序角色 + */ + enum class SortRole : uint8_t { + Name = 0, // 按文件名排序 + Size = 1, // 按文件大小排序 + LastModified = 2, // 按最后修改时间排序 + LastRead = 3 // 按最后访问时间排序 + }; + + /** + * @brief 排序配置 + */ + struct SortConfig { + SortRole role { SortRole::Name }; // 排序角色 + Qt::SortOrder order { Qt::AscendingOrder }; // 排序顺序 + bool mixDirAndFile { false }; // 是否混排目录和文件 + }; + + /** + * @brief 构造函数 + * + * @param config 排序配置 + */ + explicit DFileSorter(const SortConfig &config); + + /** + * @brief 对文件列表进行排序 + * + * @param files 文件列表(使用移动语义避免拷贝) + * @return 排序后的文件列表 + */ + QList<QSharedPointer<DEnumerator::SortFileInfo>> sort( + QList<QSharedPointer<DEnumerator::SortFileInfo>> &&files); + + /** + * @brief 对文件列表进行排序(拷贝版本) + * + * @param files 文件列表 + * @return 排序后的文件列表 + */ + QList<QSharedPointer<DEnumerator::SortFileInfo>> sort( + const QList<QSharedPointer<DEnumerator::SortFileInfo>> &files); + +private: + using CompareFunc = std::function<bool( + const QSharedPointer<DEnumerator::SortFileInfo> &, + const QSharedPointer<DEnumerator::SortFileInfo> &)>; + + /** + * @brief 获取排序比较函数 + */ + CompareFunc getCompareFunc() const; + + /** + * @brief 按名称比较 + */ + bool compareByName( + const QSharedPointer<DEnumerator::SortFileInfo> &a, + const QSharedPointer<DEnumerator::SortFileInfo> &b) const; + + /** + * @brief 按大小比较 + */ + bool compareBySize( + const QSharedPointer<DEnumerator::SortFileInfo> &a, + const QSharedPointer<DEnumerator::SortFileInfo> &b) const; + + /** + * @brief 按最后修改时间比较 + */ + bool compareByLastModified( + const QSharedPointer<DEnumerator::SortFileInfo> &a, + const QSharedPointer<DEnumerator::SortFileInfo> &b) const; + + /** + * @brief 按最后访问时间比较 + */ + bool compareByLastRead( + const QSharedPointer<DEnumerator::SortFileInfo> &a, + const QSharedPointer<DEnumerator::SortFileInfo> &b) const; + + /** + * @brief 分离目录和文件 + */ + void separateDirAndFile( + QList<QSharedPointer<DEnumerator::SortFileInfo>> &&files, + QList<QSharedPointer<DEnumerator::SortFileInfo>> &dirs, + QList<QSharedPointer<DEnumerator::SortFileInfo>> ®ularFiles) const; + + /** + * @brief 应用排序顺序 + */ + void applySortOrder(QList<QSharedPointer<DEnumerator::SortFileInfo>> &list) const; + +private: + SortConfig m_config; +}; + +END_IO_NAMESPACE + +#endif // DFILESORTER_H diff --git a/src/dfm-io/dfm-io/sort/dsortkeycache.cpp b/src/dfm-io/dfm-io/sort/dsortkeycache.cpp new file mode 100644 index 00000000..003ee411 --- /dev/null +++ b/src/dfm-io/dfm-io/sort/dsortkeycache.cpp @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "dsortkeycache.h" + +USING_IO_NAMESPACE + +DSortKeyCache &DSortKeyCache::instance() +{ + static DSortKeyCache instance; + return instance; +} + +QCollatorSortKey DSortKeyCache::sortKey(const QString &text) +{ + // 先尝试读锁查找缓存 + { + QReadLocker locker(&m_lock); + auto it = m_cache.find(text); + if (it != m_cache.end()) { + return *it; + } + } + + // 缓存未命中,需要创建 + QWriteLocker locker(&m_lock); + + // 双重检查,防止其他线程已经创建 + auto it = m_cache.find(text); + if (it != m_cache.end()) { + return *it; + } + + QCollatorSortKey key = collator().sortKey(text); + m_cache.insert(text, key); + return key; +} + +void DSortKeyCache::clear() +{ + QWriteLocker locker(&m_lock); + m_cache.clear(); +} + +QCollator &DSortKeyCache::collator() +{ + // 线程局部存储,每个线程有自己的 QCollator 实例 + // 避免多线程竞争问题 + thread_local static QCollator s_collator = []() { + QCollator c; + c.setNumericMode(true); // 支持数字自然排序,如 "file2" < "file10" + return c; + }(); + return s_collator; +} + +DSortKeyCache::DSortKeyCache() +{ +} diff --git a/src/dfm-io/dfm-io/sort/dsortkeycache.h b/src/dfm-io/dfm-io/sort/dsortkeycache.h new file mode 100644 index 00000000..48923278 --- /dev/null +++ b/src/dfm-io/dfm-io/sort/dsortkeycache.h @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef DSORTKEYCACHE_H +#define DSORTKEYCACHE_H + +#include <dfm-io/dfmio_global.h> + +#include <QCollator> +#include <QCollatorSortKey> +#include <QHash> +#include <QReadWriteLock> +#include <QString> + +BEGIN_IO_NAMESPACE + +/** + * @brief 排序键缓存管理器 + * + * 单例模式,提供线程安全的 QCollatorSortKey 缓存。 + * 使用 QCollator 配置 numericMode 进行国际化自然排序。 + * + * 性能优化: + * - 同一字符串只生成一次排序键 + * - 线程安全的 QCollator 实例(thread_local) + * - 读写锁保护缓存访问 + */ +class DSortKeyCache +{ +public: + /** + * @brief 获取单例实例 + */ + static DSortKeyCache &instance(); + + /** + * @brief 获取或创建字符串的排序键 + * + * 如果缓存中存在则直接返回,否则创建新排序键并缓存。 + * + * @param text 需要排序的文本 + * @return QCollatorSortKey 排序键 + */ + QCollatorSortKey sortKey(const QString &text); + + /** + * @brief 清理缓存 + * + * 在语言环境变化时调用。 + */ + void clear(); + + /** + * @brief 获取配置好的 QCollator 实例 + * + * 线程安全,每个线程有自己的实例。 + * + * @return QCollator& QCollator 引用 + */ + static QCollator &collator(); + +private: + DSortKeyCache(); + ~DSortKeyCache() = default; + DSortKeyCache(const DSortKeyCache &) = delete; + DSortKeyCache &operator=(const DSortKeyCache &) = delete; + + QHash<QString, QCollatorSortKey> m_cache; + QReadWriteLock m_lock; +}; + +END_IO_NAMESPACE + +#endif // DSORTKEYCACHE_H