Compare commits
52 Commits
001-unify-
...
022-sync-i
| Author | SHA1 | Date | |
|---|---|---|---|
| f24200d52a | |||
| 5d45b4adb0 | |||
| daa9f7be3a | |||
| 7e43830144 | |||
| 066747de59 | |||
| 442d0e0ac2 | |||
| 8fa951fc93 | |||
| 149d230426 | |||
| 4c601fbe06 | |||
| 36173c0880 | |||
| 81d62c1345 | |||
| a8f7147500 | |||
| ce684bc5d1 | |||
| 484019e750 | |||
| 4ff6d307f8 | |||
| f4612c0737 | |||
| 5ec1254336 | |||
| b7d1ee2b71 | |||
| 87285d8f0a | |||
| 04b01eadb5 | |||
| 4d5b9e88dd | |||
| 4bad4ab4e2 | |||
| 3801ca13d9 | |||
| 999c0c54df | |||
| f9ac282596 | |||
| 5d42a6b930 | |||
| 99f19ac305 | |||
| 590ba49ddb | |||
| 2a5b225800 | |||
| 33433c3173 | |||
| 21e969a769 | |||
| 783644c6ad | |||
| d32d85556f | |||
| bc0367ab72 | |||
| 1c362f4092 | |||
| 95ae9c6af1 | |||
| 7a12ed0931 | |||
| e0c0dd3221 | |||
| 5f6e9c0cc0 | |||
| 4fd9d6b6d5 | |||
| 7e6bd56488 | |||
| 5e3c213b92 | |||
| 37b75b5a5c | |||
| 3d42a487f7 | |||
| 2e93f5ca63 | |||
| 286167b1d5 | |||
| 7df7b4f98c | |||
| ab1c87ffba | |||
| 40e6d8cd4c | |||
| 18e96a58bc | |||
| 83e4875097 | |||
| e635bd7e5f |
35
.agent/rules/specify-rules.md
Normal file
35
.agent/rules/specify-rules.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# ss-tools Development Guidelines
|
||||
|
||||
Auto-generated from all feature plans. Last updated: 2026-02-25
|
||||
|
||||
## Knowledge Graph (GRACE)
|
||||
**CRITICAL**: This project uses a GRACE Knowledge Graph for context. Always load the root map first:
|
||||
- **Root Map**: `.ai/ROOT.md` -> `[DEF:Project_Knowledge_Map:Root]`
|
||||
- **Project Map**: `.ai/PROJECT_MAP.md` -> `[DEF:Project_Map]`
|
||||
- **Standards**: Read `.ai/standards/` for architecture and style rules.
|
||||
|
||||
## Active Technologies
|
||||
|
||||
- (022-sync-id-cross-filters)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
src/
|
||||
tests/
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
# Add commands for
|
||||
|
||||
## Code Style
|
||||
|
||||
: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
|
||||
- 022-sync-id-cross-filters: Added
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
103
.agent/workflows/audit-test.md
Normal file
103
.agent/workflows/audit-test.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
description: Audit AI-generated unit tests. Your goal is to aggressively search for "Test Tautologies", "Logic Echoing", and "Contract Negligence". You are the final gatekeeper. If a test is meaningless, you MUST reject it.
|
||||
---
|
||||
|
||||
**ROLE:** Elite Quality Assurance Architect and Red Teamer.
|
||||
**OBJECTIVE:** Audit AI-generated unit tests. Your goal is to aggressively search for "Test Tautologies", "Logic Echoing", and "Contract Negligence". You are the final gatekeeper. If a test is meaningless, you MUST reject it.
|
||||
|
||||
**INPUT:**
|
||||
1. SOURCE CODE (with GRACE-Poly `[DEF]` Contract: `@PRE`, `@POST`, `@TEST_CONTRACT`, `@TEST_FIXTURE`, `@TEST_EDGE`, `@TEST_INVARIANT`).
|
||||
2. GENERATED TEST CODE.
|
||||
|
||||
### I. CRITICAL ANTI-PATTERNS (REJECT IMMEDIATELY IF FOUND):
|
||||
|
||||
1. **The Tautology (Self-Fulfilling Prophecy):**
|
||||
- *Definition:* The test asserts hardcoded values against hardcoded values without executing the core business logic, or mocks the actual function being tested.
|
||||
- *Example of Failure:* `assert 2 + 2 == 4` or mocking the class under test so that it returns exactly what the test asserts.
|
||||
|
||||
2. **The Logic Mirror (Echoing):**
|
||||
- *Definition:* The test re-implements the exact same algorithmic logic found in the source code to calculate the `expected_result`. If the original logic is flawed, the test will falsely pass.
|
||||
- *Rule:* Tests must assert against **static, predefined outcomes** (from `@TEST_FIXTURE`, `@TEST_EDGE`, `@TEST_INVARIANT` or explicit constants), NOT dynamically calculated outcomes using the same logic as the source.
|
||||
|
||||
3. **The "Happy Path" Illusion:**
|
||||
- *Definition:* The test suite only checks successful executions but ignores the `@PRE` conditions (Negative Testing).
|
||||
- *Rule:* Every `@PRE` tag in the source contract MUST have a corresponding test that deliberately violates it and asserts the correct Exception/Error state.
|
||||
|
||||
4. **Missing Post-Condition Verification:**
|
||||
- *Definition:* The test calls the function but only checks the return value, ignoring `@SIDE_EFFECT` or `@POST` state changes (e.g., failing to verify that a DB call was made or a Store was updated).
|
||||
|
||||
5. **Missing Edge Case Coverage:**
|
||||
- *Definition:* The test suite ignores `@TEST_EDGE` scenarios defined in the contract.
|
||||
- *Rule:* Every `@TEST_EDGE` in the source contract MUST have a corresponding test case.
|
||||
|
||||
6. **Missing Invariant Verification:**
|
||||
- *Definition:* The test suite does not verify `@TEST_INVARIANT` conditions.
|
||||
- *Rule:* Every `@TEST_INVARIANT` MUST be verified by at least one test that attempts to break it.
|
||||
|
||||
7. **Missing UX State Testing (Svelte Components):**
|
||||
- *Definition:* For Svelte components with `@UX_STATE`, the test suite does not verify state transitions.
|
||||
- *Rule:* Every `@UX_STATE` transition MUST have a test verifying the visual/behavioral change.
|
||||
- *Check:* `@UX_FEEDBACK` mechanisms (toast, shake, color) must be tested.
|
||||
- *Check:* `@UX_RECOVERY` mechanisms (retry, clear input) must be tested.
|
||||
|
||||
### II. SEMANTIC PROTOCOL COMPLIANCE
|
||||
|
||||
Verify the test file follows GRACE-Poly semantics:
|
||||
|
||||
1. **Anchor Integrity:**
|
||||
- Test file MUST start with `[DEF:__tests__/test_name:Module]`
|
||||
- Test file MUST end with `[/DEF:__tests__/test_name:Module]`
|
||||
|
||||
2. **Required Tags:**
|
||||
- `@RELATION: VERIFIES -> <path_to_source>` must be present
|
||||
- `@PURPOSE:` must describe what is being tested
|
||||
|
||||
3. **TIER Alignment:**
|
||||
- If source is `@TIER: CRITICAL`, test MUST cover all `@TEST_CONTRACT`, `@TEST_FIXTURE`, `@TEST_EDGE`, `@TEST_INVARIANT`
|
||||
- If source is `@TIER: STANDARD`, test MUST cover `@PRE` and `@POST`
|
||||
- If source is `@TIER: TRIVIAL`, basic smoke test is acceptable
|
||||
|
||||
### III. AUDIT CHECKLIST
|
||||
|
||||
Evaluate the test code against these criteria:
|
||||
1. **Target Invocation:** Does the test actually import and call the function/component declared in the `@RELATION: VERIFIES` tag?
|
||||
2. **Contract Alignment:** Does the test suite cover 100% of the `@PRE` (negative tests) and `@POST` (assertions) conditions from the source contract?
|
||||
3. **Test Contract Compliance:** Does the test follow the interface defined in `@TEST_CONTRACT`?
|
||||
4. **Data Usage:** Does the test use the exact scenarios defined in `@TEST_FIXTURE`?
|
||||
5. **Edge Coverage:** Are all `@TEST_EDGE` scenarios tested?
|
||||
6. **Invariant Coverage:** Are all `@TEST_INVARIANT` conditions verified?
|
||||
7. **UX Coverage (if applicable):** Are all `@UX_STATE`, `@UX_FEEDBACK`, `@UX_RECOVERY` tested?
|
||||
8. **Mocking Sanity:** Are external dependencies mocked correctly WITHOUT mocking the system under test itself?
|
||||
9. **Semantic Anchor:** Does the test file have proper `[DEF]` and `[/DEF]` anchors?
|
||||
|
||||
### IV. OUTPUT FORMAT
|
||||
|
||||
You MUST respond strictly in the following JSON format. Do not add markdown blocks outside the JSON.
|
||||
|
||||
{
|
||||
"verdict": "APPROVED" | "REJECTED",
|
||||
"rejection_reason": "TAUTOLOGY" | "LOGIC_MIRROR" | "WEAK_CONTRACT_COVERAGE" | "OVER_MOCKED" | "MISSING_EDGES" | "MISSING_INVARIANTS" | "MISSING_UX_TESTS" | "SEMANTIC_VIOLATION" | "NONE",
|
||||
"audit_details": {
|
||||
"target_invoked": true/false,
|
||||
"pre_conditions_tested": true/false,
|
||||
"post_conditions_tested": true/false,
|
||||
"test_fixture_used": true/false,
|
||||
"edges_covered": true/false,
|
||||
"invariants_verified": true/false,
|
||||
"ux_states_tested": true/false,
|
||||
"semantic_anchors_present": true/false
|
||||
},
|
||||
"coverage_summary": {
|
||||
"total_edges": number,
|
||||
"edges_tested": number,
|
||||
"total_invariants": number,
|
||||
"invariants_tested": number,
|
||||
"total_ux_states": number,
|
||||
"ux_states_tested": number
|
||||
},
|
||||
"tier_compliance": {
|
||||
"source_tier": "CRITICAL" | "STANDARD" | "TRIVIAL",
|
||||
"meets_tier_requirements": true/false
|
||||
},
|
||||
"feedback": "Strict, actionable feedback for the test generator agent. Explain exactly which anti-pattern was detected and how to fix it."
|
||||
}
|
||||
4
.agent/workflows/read_semantic.md
Normal file
4
.agent/workflows/read_semantic.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
description: USE SEMANTIC
|
||||
---
|
||||
Прочитай .ai/standards/semantics.md. ОБЯЗАТЕЛЬНО используй его при разработке
|
||||
10
.agent/workflows/semantic.md
Normal file
10
.agent/workflows/semantic.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description: semantic
|
||||
---
|
||||
|
||||
You are Semantic Agent responsible for maintaining the semantic integrity of the codebase. Your primary goal is to ensure that all code entities (Modules, Classes, Functions, Components) are properly annotated with semantic anchors and tags as defined in `.ai/standards/semantics.md`.
|
||||
Your core responsibilities are: 1. **Semantic Mapping**: You run and maintain the `generate_semantic_map.py` script to generate up-to-date semantic maps (`semantics/semantic_map.json`, `.ai/PROJECT_MAP.md`) and compliance reports (`semantics/reports/*.md`). 2. **Compliance Auditing**: You analyze the generated compliance reports to identify files with low semantic coverage or parsing errors. 3. **Semantic Enrichment**: You actively edit code files to add missing semantic anchors (`[DEF:...]`, `[/DEF:...]`) and mandatory tags (`@PURPOSE`, `@LAYER`, etc.) to improve the global compliance score. 4. **Protocol Enforcement**: You strictly adhere to the syntax and rules defined in `.ai/standards/semantics.md` when modifying code.
|
||||
You have access to the full codebase and tools to read, write, and execute scripts. You should prioritize fixing "Critical Parsing Errors" (unclosed anchors) before addressing missing metadata.
|
||||
whenToUse: Use this mode when you need to update the project's semantic map, fix semantic compliance issues (missing anchors/tags/DbC ), or analyze the codebase structure. This mode is specialized for maintaining the `.ai/standards/semantics.md` standards.
|
||||
description: Codebase semantic mapping and compliance expert
|
||||
customInstructions: Always check `semantics/reports/` for the latest compliance status before starting work. When fixing a file, try to fix all semantic issues in that file at once. After making a batch of fixes, run `python3 generate_semantic_map.py` to verify improvements.
|
||||
185
.agent/workflows/speckit.analyze.md
Normal file
185
.agent/workflows/speckit.analyze.md
Normal file
@@ -0,0 +1,185 @@
|
||||
---
|
||||
description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation.
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Goal
|
||||
|
||||
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`.
|
||||
|
||||
## Operating Constraints
|
||||
|
||||
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
|
||||
|
||||
**Constitution Authority**: The project constitution (`.ai/standards/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### 1. Initialize Analysis Context
|
||||
|
||||
Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
|
||||
|
||||
- SPEC = FEATURE_DIR/spec.md
|
||||
- PLAN = FEATURE_DIR/plan.md
|
||||
- TASKS = FEATURE_DIR/tasks.md
|
||||
|
||||
Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command).
|
||||
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
### 2. Load Artifacts (Progressive Disclosure)
|
||||
|
||||
Load only the minimal necessary context from each artifact:
|
||||
|
||||
**From spec.md:**
|
||||
|
||||
- Overview/Context
|
||||
- Functional Requirements
|
||||
- Non-Functional Requirements
|
||||
- User Stories
|
||||
- Edge Cases (if present)
|
||||
|
||||
**From plan.md:**
|
||||
|
||||
- Architecture/stack choices
|
||||
- Data Model references
|
||||
- Phases
|
||||
- Technical constraints
|
||||
|
||||
**From tasks.md:**
|
||||
|
||||
- Task IDs
|
||||
- Descriptions
|
||||
- Phase grouping
|
||||
- Parallel markers [P]
|
||||
- Referenced file paths
|
||||
|
||||
**From constitution:**
|
||||
|
||||
- Load `.ai/standards/constitution.md` for principle validation
|
||||
- Load `.ai/standards/semantics.md` for technical standard validation
|
||||
|
||||
### 3. Build Semantic Models
|
||||
|
||||
Create internal representations (do not include raw artifacts in output):
|
||||
|
||||
- **Requirements inventory**: Each functional + non-functional requirement with a stable key (derive slug based on imperative phrase; e.g., "User can upload file" → `user-can-upload-file`)
|
||||
- **User story/action inventory**: Discrete user actions with acceptance criteria
|
||||
- **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases)
|
||||
- **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements
|
||||
|
||||
### 4. Detection Passes (Token-Efficient Analysis)
|
||||
|
||||
Focus on high-signal findings. Limit to 50 findings total; aggregate remainder in overflow summary.
|
||||
|
||||
#### A. Duplication Detection
|
||||
|
||||
- Identify near-duplicate requirements
|
||||
- Mark lower-quality phrasing for consolidation
|
||||
|
||||
#### B. Ambiguity Detection
|
||||
|
||||
- Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria
|
||||
- Flag unresolved placeholders (TODO, TKTK, ???, `<placeholder>`, etc.)
|
||||
|
||||
#### C. Underspecification
|
||||
|
||||
- Requirements with verbs but missing object or measurable outcome
|
||||
- User stories missing acceptance criteria alignment
|
||||
- Tasks referencing files or components not defined in spec/plan
|
||||
|
||||
#### D. Constitution Alignment
|
||||
|
||||
- Any requirement or plan element conflicting with a MUST principle
|
||||
- Missing mandated sections or quality gates from constitution
|
||||
|
||||
#### E. Coverage Gaps
|
||||
|
||||
- Requirements with zero associated tasks
|
||||
- Tasks with no mapped requirement/story
|
||||
- Non-functional requirements not reflected in tasks (e.g., performance, security)
|
||||
|
||||
#### F. Inconsistency
|
||||
|
||||
- Terminology drift (same concept named differently across files)
|
||||
- Data entities referenced in plan but absent in spec (or vice versa)
|
||||
- Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note)
|
||||
- Conflicting requirements (e.g., one requires Next.js while other specifies Vue)
|
||||
|
||||
### 5. Severity Assignment
|
||||
|
||||
Use this heuristic to prioritize findings:
|
||||
|
||||
- **CRITICAL**: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality
|
||||
- **HIGH**: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion
|
||||
- **MEDIUM**: Terminology drift, missing non-functional task coverage, underspecified edge case
|
||||
- **LOW**: Style/wording improvements, minor redundancy not affecting execution order
|
||||
|
||||
### 6. Produce Compact Analysis Report
|
||||
|
||||
Output a Markdown report (no file writes) with the following structure:
|
||||
|
||||
## Specification Analysis Report
|
||||
|
||||
| ID | Category | Severity | Location(s) | Summary | Recommendation |
|
||||
|----|----------|----------|-------------|---------|----------------|
|
||||
| A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version |
|
||||
|
||||
(Add one row per finding; generate stable IDs prefixed by category initial.)
|
||||
|
||||
**Coverage Summary Table:**
|
||||
|
||||
| Requirement Key | Has Task? | Task IDs | Notes |
|
||||
|-----------------|-----------|----------|-------|
|
||||
|
||||
**Constitution Alignment Issues:** (if any)
|
||||
|
||||
**Unmapped Tasks:** (if any)
|
||||
|
||||
**Metrics:**
|
||||
|
||||
- Total Requirements
|
||||
- Total Tasks
|
||||
- Coverage % (requirements with >=1 task)
|
||||
- Ambiguity Count
|
||||
- Duplication Count
|
||||
- Critical Issues Count
|
||||
|
||||
### 7. Provide Next Actions
|
||||
|
||||
At end of report, output a concise Next Actions block:
|
||||
|
||||
- If CRITICAL issues exist: Recommend resolving before `/speckit.implement`
|
||||
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
|
||||
- Provide explicit command suggestions: e.g., "Run /speckit.specify with refinement", "Run /speckit.plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
|
||||
|
||||
### 8. Offer Remediation
|
||||
|
||||
Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
|
||||
|
||||
## Operating Principles
|
||||
|
||||
### Context Efficiency
|
||||
|
||||
- **Minimal high-signal tokens**: Focus on actionable findings, not exhaustive documentation
|
||||
- **Progressive disclosure**: Load artifacts incrementally; don't dump all content into analysis
|
||||
- **Token-efficient output**: Limit findings table to 50 rows; summarize overflow
|
||||
- **Deterministic results**: Rerunning without changes should produce consistent IDs and counts
|
||||
|
||||
### Analysis Guidelines
|
||||
|
||||
- **NEVER modify files** (this is read-only analysis)
|
||||
- **NEVER hallucinate missing sections** (if absent, report them accurately)
|
||||
- **Prioritize constitution violations** (these are always CRITICAL)
|
||||
- **Use examples over exhaustive rules** (cite specific instances, not generic patterns)
|
||||
- **Report zero issues gracefully** (emit success report with coverage statistics)
|
||||
|
||||
## Context
|
||||
|
||||
$ARGUMENTS
|
||||
294
.agent/workflows/speckit.checklist.md
Normal file
294
.agent/workflows/speckit.checklist.md
Normal file
@@ -0,0 +1,294 @@
|
||||
---
|
||||
description: Generate a custom checklist for the current feature based on user requirements.
|
||||
---
|
||||
|
||||
## Checklist Purpose: "Unit Tests for English"
|
||||
|
||||
**CRITICAL CONCEPT**: Checklists are **UNIT TESTS FOR REQUIREMENTS WRITING** - they validate the quality, clarity, and completeness of requirements in a given domain.
|
||||
|
||||
**NOT for verification/testing**:
|
||||
|
||||
- ❌ NOT "Verify the button clicks correctly"
|
||||
- ❌ NOT "Test error handling works"
|
||||
- ❌ NOT "Confirm the API returns 200"
|
||||
- ❌ NOT checking if code/implementation matches the spec
|
||||
|
||||
**FOR requirements quality validation**:
|
||||
|
||||
- ✅ "Are visual hierarchy requirements defined for all card types?" (completeness)
|
||||
- ✅ "Is 'prominent display' quantified with specific sizing/positioning?" (clarity)
|
||||
- ✅ "Are hover state requirements consistent across all interactive elements?" (consistency)
|
||||
- ✅ "Are accessibility requirements defined for keyboard navigation?" (coverage)
|
||||
- ✅ "Does the spec define what happens when logo image fails to load?" (edge cases)
|
||||
|
||||
**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works.
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Execution Steps
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.
|
||||
- All file paths must be absolute.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
|
||||
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks
|
||||
- Only ask about information that materially changes checklist content
|
||||
- Be skipped individually if already unambiguous in `$ARGUMENTS`
|
||||
- Prefer precision over breadth
|
||||
|
||||
Generation algorithm:
|
||||
1. Extract signals: feature domain keywords (e.g., auth, latency, UX, API), risk indicators ("critical", "must", "compliance"), stakeholder hints ("QA", "review", "security team"), and explicit deliverables ("a11y", "rollback", "contracts").
|
||||
2. Cluster signals into candidate focus areas (max 4) ranked by relevance.
|
||||
3. Identify probable audience & timing (author, reviewer, QA, release) if not explicit.
|
||||
4. Detect missing dimensions: scope breadth, depth/rigor, risk emphasis, exclusion boundaries, measurable acceptance criteria.
|
||||
5. Formulate questions chosen from these archetypes:
|
||||
- Scope refinement (e.g., "Should this include integration touchpoints with X and Y or stay limited to local module correctness?")
|
||||
- Risk prioritization (e.g., "Which of these potential risk areas should receive mandatory gating checks?")
|
||||
- Depth calibration (e.g., "Is this a lightweight pre-commit sanity list or a formal release gate?")
|
||||
- Audience framing (e.g., "Will this be used by the author only or peers during PR review?")
|
||||
- Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?")
|
||||
- Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?")
|
||||
|
||||
Question formatting rules:
|
||||
- If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters
|
||||
- Limit to A–E options maximum; omit table if a free-form answer is clearer
|
||||
- Never ask the user to restate what they already said
|
||||
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope."
|
||||
|
||||
Defaults when interaction impossible:
|
||||
- Depth: Standard
|
||||
- Audience: Reviewer (PR) if code-related; Author otherwise
|
||||
- Focus: Top 2 relevance clusters
|
||||
|
||||
Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted follow‑ups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more.
|
||||
|
||||
3. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
|
||||
- Derive checklist theme (e.g., security, review, deploy, ux)
|
||||
- Consolidate explicit must-have items mentioned by user
|
||||
- Map focus selections to category scaffolding
|
||||
- Infer any missing context from spec/plan/tasks (do NOT hallucinate)
|
||||
|
||||
4. **Load feature context**: Read from FEATURE_DIR:
|
||||
- spec.md: Feature requirements and scope
|
||||
- plan.md (if exists): Technical details, dependencies
|
||||
- tasks.md (if exists): Implementation tasks
|
||||
|
||||
**Context Loading Strategy**:
|
||||
- Load only necessary portions relevant to active focus areas (avoid full-file dumping)
|
||||
- Prefer summarizing long sections into concise scenario/requirement bullets
|
||||
- Use progressive disclosure: add follow-on retrieval only if gaps detected
|
||||
- If source docs are large, generate interim summary items instead of embedding raw text
|
||||
|
||||
5. **Generate checklist** - Create "Unit Tests for Requirements":
|
||||
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
|
||||
- Generate unique checklist filename:
|
||||
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
|
||||
- Format: `[domain].md`
|
||||
- If file exists, append to existing file
|
||||
- Number items sequentially starting from CHK001
|
||||
- Each `/speckit.checklist` run creates a NEW file (never overwrites existing checklists)
|
||||
|
||||
**CORE PRINCIPLE - Test the Requirements, Not the Implementation**:
|
||||
Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for:
|
||||
- **Completeness**: Are all necessary requirements present?
|
||||
- **Clarity**: Are requirements unambiguous and specific?
|
||||
- **Consistency**: Do requirements align with each other?
|
||||
- **Measurability**: Can requirements be objectively verified?
|
||||
- **Coverage**: Are all scenarios/edge cases addressed?
|
||||
|
||||
**Category Structure** - Group items by requirement quality dimensions:
|
||||
- **Requirement Completeness** (Are all necessary requirements documented?)
|
||||
- **Requirement Clarity** (Are requirements specific and unambiguous?)
|
||||
- **Requirement Consistency** (Do requirements align without conflicts?)
|
||||
- **Acceptance Criteria Quality** (Are success criteria measurable?)
|
||||
- **Scenario Coverage** (Are all flows/cases addressed?)
|
||||
- **Edge Case Coverage** (Are boundary conditions defined?)
|
||||
- **Non-Functional Requirements** (Performance, Security, Accessibility, etc. - are they specified?)
|
||||
- **Dependencies & Assumptions** (Are they documented and validated?)
|
||||
- **Ambiguities & Conflicts** (What needs clarification?)
|
||||
|
||||
**HOW TO WRITE CHECKLIST ITEMS - "Unit Tests for English"**:
|
||||
|
||||
❌ **WRONG** (Testing implementation):
|
||||
- "Verify landing page displays 3 episode cards"
|
||||
- "Test hover states work on desktop"
|
||||
- "Confirm logo click navigates home"
|
||||
|
||||
✅ **CORRECT** (Testing requirements quality):
|
||||
- "Are the exact number and layout of featured episodes specified?" [Completeness]
|
||||
- "Is 'prominent display' quantified with specific sizing/positioning?" [Clarity]
|
||||
- "Are hover state requirements consistent across all interactive elements?" [Consistency]
|
||||
- "Are keyboard navigation requirements defined for all interactive UI?" [Coverage]
|
||||
- "Is the fallback behavior specified when logo image fails to load?" [Edge Cases]
|
||||
- "Are loading states defined for asynchronous episode data?" [Completeness]
|
||||
- "Does the spec define visual hierarchy for competing UI elements?" [Clarity]
|
||||
|
||||
**ITEM STRUCTURE**:
|
||||
Each item should follow this pattern:
|
||||
- Question format asking about requirement quality
|
||||
- Focus on what's WRITTEN (or not written) in the spec/plan
|
||||
- Include quality dimension in brackets [Completeness/Clarity/Consistency/etc.]
|
||||
- Reference spec section `[Spec §X.Y]` when checking existing requirements
|
||||
- Use `[Gap]` marker when checking for missing requirements
|
||||
|
||||
**EXAMPLES BY QUALITY DIMENSION**:
|
||||
|
||||
Completeness:
|
||||
- "Are error handling requirements defined for all API failure modes? [Gap]"
|
||||
- "Are accessibility requirements specified for all interactive elements? [Completeness]"
|
||||
- "Are mobile breakpoint requirements defined for responsive layouts? [Gap]"
|
||||
|
||||
Clarity:
|
||||
- "Is 'fast loading' quantified with specific timing thresholds? [Clarity, Spec §NFR-2]"
|
||||
- "Are 'related episodes' selection criteria explicitly defined? [Clarity, Spec §FR-5]"
|
||||
- "Is 'prominent' defined with measurable visual properties? [Ambiguity, Spec §FR-4]"
|
||||
|
||||
Consistency:
|
||||
- "Do navigation requirements align across all pages? [Consistency, Spec §FR-10]"
|
||||
- "Are card component requirements consistent between landing and detail pages? [Consistency]"
|
||||
|
||||
Coverage:
|
||||
- "Are requirements defined for zero-state scenarios (no episodes)? [Coverage, Edge Case]"
|
||||
- "Are concurrent user interaction scenarios addressed? [Coverage, Gap]"
|
||||
- "Are requirements specified for partial data loading failures? [Coverage, Exception Flow]"
|
||||
|
||||
Measurability:
|
||||
- "Are visual hierarchy requirements measurable/testable? [Acceptance Criteria, Spec §FR-1]"
|
||||
- "Can 'balanced visual weight' be objectively verified? [Measurability, Spec §FR-2]"
|
||||
|
||||
**Scenario Classification & Coverage** (Requirements Quality Focus):
|
||||
- Check if requirements exist for: Primary, Alternate, Exception/Error, Recovery, Non-Functional scenarios
|
||||
- For each scenario class, ask: "Are [scenario type] requirements complete, clear, and consistent?"
|
||||
- If scenario class missing: "Are [scenario type] requirements intentionally excluded or missing? [Gap]"
|
||||
- Include resilience/rollback when state mutation occurs: "Are rollback requirements defined for migration failures? [Gap]"
|
||||
|
||||
**Traceability Requirements**:
|
||||
- MINIMUM: ≥80% of items MUST include at least one traceability reference
|
||||
- Each item should reference: spec section `[Spec §X.Y]`, or use markers: `[Gap]`, `[Ambiguity]`, `[Conflict]`, `[Assumption]`
|
||||
- If no ID system exists: "Is a requirement & acceptance criteria ID scheme established? [Traceability]"
|
||||
|
||||
**Surface & Resolve Issues** (Requirements Quality Problems):
|
||||
Ask questions about the requirements themselves:
|
||||
- Ambiguities: "Is the term 'fast' quantified with specific metrics? [Ambiguity, Spec §NFR-1]"
|
||||
- Conflicts: "Do navigation requirements conflict between §FR-10 and §FR-10a? [Conflict]"
|
||||
- Assumptions: "Is the assumption of 'always available podcast API' validated? [Assumption]"
|
||||
- Dependencies: "Are external podcast API requirements documented? [Dependency, Gap]"
|
||||
- Missing definitions: "Is 'visual hierarchy' defined with measurable criteria? [Gap]"
|
||||
|
||||
**Content Consolidation**:
|
||||
- Soft cap: If raw candidate items > 40, prioritize by risk/impact
|
||||
- Merge near-duplicates checking the same requirement aspect
|
||||
- If >5 low-impact edge cases, create one item: "Are edge cases X, Y, Z addressed in requirements? [Coverage]"
|
||||
|
||||
**🚫 ABSOLUTELY PROHIBITED** - These make it an implementation test, not a requirements test:
|
||||
- ❌ Any item starting with "Verify", "Test", "Confirm", "Check" + implementation behavior
|
||||
- ❌ References to code execution, user actions, system behavior
|
||||
- ❌ "Displays correctly", "works properly", "functions as expected"
|
||||
- ❌ "Click", "navigate", "render", "load", "execute"
|
||||
- ❌ Test cases, test plans, QA procedures
|
||||
- ❌ Implementation details (frameworks, APIs, algorithms)
|
||||
|
||||
**✅ REQUIRED PATTERNS** - These test requirements quality:
|
||||
- ✅ "Are [requirement type] defined/specified/documented for [scenario]?"
|
||||
- ✅ "Is [vague term] quantified/clarified with specific criteria?"
|
||||
- ✅ "Are requirements consistent between [section A] and [section B]?"
|
||||
- ✅ "Can [requirement] be objectively measured/verified?"
|
||||
- ✅ "Are [edge cases/scenarios] addressed in requirements?"
|
||||
- ✅ "Does the spec define [missing aspect]?"
|
||||
|
||||
6. **Structure Reference**: Generate the checklist following the canonical template in `.specify/templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001.
|
||||
|
||||
7. **Report**: Output full path to created checklist, item count, and remind user that each run creates a new file. Summarize:
|
||||
- Focus areas selected
|
||||
- Depth level
|
||||
- Actor/timing
|
||||
- Any explicit user-specified must-have items incorporated
|
||||
|
||||
**Important**: Each `/speckit.checklist` command invocation creates a checklist file using short, descriptive names unless file already exists. This allows:
|
||||
|
||||
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
|
||||
- Simple, memorable filenames that indicate checklist purpose
|
||||
- Easy identification and navigation in the `checklists/` folder
|
||||
|
||||
To avoid clutter, use descriptive types and clean up obsolete checklists when done.
|
||||
|
||||
## Example Checklist Types & Sample Items
|
||||
|
||||
**UX Requirements Quality:** `ux.md`
|
||||
|
||||
Sample items (testing the requirements, NOT the implementation):
|
||||
|
||||
- "Are visual hierarchy requirements defined with measurable criteria? [Clarity, Spec §FR-1]"
|
||||
- "Is the number and positioning of UI elements explicitly specified? [Completeness, Spec §FR-1]"
|
||||
- "Are interaction state requirements (hover, focus, active) consistently defined? [Consistency]"
|
||||
- "Are accessibility requirements specified for all interactive elements? [Coverage, Gap]"
|
||||
- "Is fallback behavior defined when images fail to load? [Edge Case, Gap]"
|
||||
- "Can 'prominent display' be objectively measured? [Measurability, Spec §FR-4]"
|
||||
|
||||
**API Requirements Quality:** `api.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are error response formats specified for all failure scenarios? [Completeness]"
|
||||
- "Are rate limiting requirements quantified with specific thresholds? [Clarity]"
|
||||
- "Are authentication requirements consistent across all endpoints? [Consistency]"
|
||||
- "Are retry/timeout requirements defined for external dependencies? [Coverage, Gap]"
|
||||
- "Is versioning strategy documented in requirements? [Gap]"
|
||||
|
||||
**Performance Requirements Quality:** `performance.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are performance requirements quantified with specific metrics? [Clarity]"
|
||||
- "Are performance targets defined for all critical user journeys? [Coverage]"
|
||||
- "Are performance requirements under different load conditions specified? [Completeness]"
|
||||
- "Can performance requirements be objectively measured? [Measurability]"
|
||||
- "Are degradation requirements defined for high-load scenarios? [Edge Case, Gap]"
|
||||
|
||||
**Security Requirements Quality:** `security.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are authentication requirements specified for all protected resources? [Coverage]"
|
||||
- "Are data protection requirements defined for sensitive information? [Completeness]"
|
||||
- "Is the threat model documented and requirements aligned to it? [Traceability]"
|
||||
- "Are security requirements consistent with compliance obligations? [Consistency]"
|
||||
- "Are security failure/breach response requirements defined? [Gap, Exception Flow]"
|
||||
|
||||
## Anti-Examples: What NOT To Do
|
||||
|
||||
**❌ WRONG - These test implementation, not requirements:**
|
||||
|
||||
```markdown
|
||||
- [ ] CHK001 - Verify landing page displays 3 episode cards [Spec §FR-001]
|
||||
- [ ] CHK002 - Test hover states work correctly on desktop [Spec §FR-003]
|
||||
- [ ] CHK003 - Confirm logo click navigates to home page [Spec §FR-010]
|
||||
- [ ] CHK004 - Check that related episodes section shows 3-5 items [Spec §FR-005]
|
||||
```
|
||||
|
||||
**✅ CORRECT - These test requirements quality:**
|
||||
|
||||
```markdown
|
||||
- [ ] CHK001 - Are the number and layout of featured episodes explicitly specified? [Completeness, Spec §FR-001]
|
||||
- [ ] CHK002 - Are hover state requirements consistently defined for all interactive elements? [Consistency, Spec §FR-003]
|
||||
- [ ] CHK003 - Are navigation requirements clear for all clickable brand elements? [Clarity, Spec §FR-010]
|
||||
- [ ] CHK004 - Is the selection criteria for related episodes documented? [Gap, Spec §FR-005]
|
||||
- [ ] CHK005 - Are loading state requirements defined for asynchronous episode data? [Gap]
|
||||
- [ ] CHK006 - Can "visual hierarchy" requirements be objectively measured? [Measurability, Spec §FR-001]
|
||||
```
|
||||
|
||||
**Key Differences:**
|
||||
|
||||
- Wrong: Tests if the system works correctly
|
||||
- Correct: Tests if the requirements are written correctly
|
||||
- Wrong: Verification of behavior
|
||||
- Correct: Validation of requirement quality
|
||||
- Wrong: "Does it do X?"
|
||||
- Correct: "Is X clearly specified?"
|
||||
181
.agent/workflows/speckit.clarify.md
Normal file
181
.agent/workflows/speckit.clarify.md
Normal file
@@ -0,0 +1,181 @@
|
||||
---
|
||||
description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec.
|
||||
handoffs:
|
||||
- label: Build Technical Plan
|
||||
agent: speckit.plan
|
||||
prompt: Create a plan for the spec. I am building with...
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
|
||||
|
||||
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit.plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
|
||||
|
||||
Execution steps:
|
||||
|
||||
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields:
|
||||
- `FEATURE_DIR`
|
||||
- `FEATURE_SPEC`
|
||||
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
|
||||
- If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
|
||||
|
||||
Functional Scope & Behavior:
|
||||
- Core user goals & success criteria
|
||||
- Explicit out-of-scope declarations
|
||||
- User roles / personas differentiation
|
||||
|
||||
Domain & Data Model:
|
||||
- Entities, attributes, relationships
|
||||
- Identity & uniqueness rules
|
||||
- Lifecycle/state transitions
|
||||
- Data volume / scale assumptions
|
||||
|
||||
Interaction & UX Flow:
|
||||
- Critical user journeys / sequences
|
||||
- Error/empty/loading states
|
||||
- Accessibility or localization notes
|
||||
|
||||
Non-Functional Quality Attributes:
|
||||
- Performance (latency, throughput targets)
|
||||
- Scalability (horizontal/vertical, limits)
|
||||
- Reliability & availability (uptime, recovery expectations)
|
||||
- Observability (logging, metrics, tracing signals)
|
||||
- Security & privacy (authN/Z, data protection, threat assumptions)
|
||||
- Compliance / regulatory constraints (if any)
|
||||
|
||||
Integration & External Dependencies:
|
||||
- External services/APIs and failure modes
|
||||
- Data import/export formats
|
||||
- Protocol/versioning assumptions
|
||||
|
||||
Edge Cases & Failure Handling:
|
||||
- Negative scenarios
|
||||
- Rate limiting / throttling
|
||||
- Conflict resolution (e.g., concurrent edits)
|
||||
|
||||
Constraints & Tradeoffs:
|
||||
- Technical constraints (language, storage, hosting)
|
||||
- Explicit tradeoffs or rejected alternatives
|
||||
|
||||
Terminology & Consistency:
|
||||
- Canonical glossary terms
|
||||
- Avoided synonyms / deprecated terms
|
||||
|
||||
Completion Signals:
|
||||
- Acceptance criteria testability
|
||||
- Measurable Definition of Done style indicators
|
||||
|
||||
Misc / Placeholders:
|
||||
- TODO markers / unresolved decisions
|
||||
- Ambiguous adjectives ("robust", "intuitive") lacking quantification
|
||||
|
||||
For each category with Partial or Missing status, add a candidate question opportunity unless:
|
||||
- Clarification would not materially change implementation or validation strategy
|
||||
- Information is better deferred to planning phase (note internally)
|
||||
|
||||
3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
|
||||
- Maximum of 10 total questions across the whole session.
|
||||
- Each question must be answerable with EITHER:
|
||||
- A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR
|
||||
- A one-word / short‑phrase answer (explicitly constrain: "Answer in <=5 words").
|
||||
- Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation.
|
||||
- Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved.
|
||||
- Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness).
|
||||
- Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests.
|
||||
- If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic.
|
||||
|
||||
4. Sequential questioning loop (interactive):
|
||||
- Present EXACTLY ONE question at a time.
|
||||
- For multiple‑choice questions:
|
||||
- **Analyze all options** and determine the **most suitable option** based on:
|
||||
- Best practices for the project type
|
||||
- Common patterns in similar implementations
|
||||
- Risk reduction (security, performance, maintainability)
|
||||
- Alignment with any explicit project goals or constraints visible in the spec
|
||||
- Present your **recommended option prominently** at the top with clear reasoning (1-2 sentences explaining why this is the best choice).
|
||||
- Format as: `**Recommended:** Option [X] - <reasoning>`
|
||||
- Then render all options as a Markdown table:
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| A | <Option A description> |
|
||||
| B | <Option B description> |
|
||||
| C | <Option C description> (add D/E as needed up to 5) |
|
||||
| Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) |
|
||||
|
||||
- After the table, add: `You can reply with the option letter (e.g., "A"), accept the recommendation by saying "yes" or "recommended", or provide your own short answer.`
|
||||
- For short‑answer style (no meaningful discrete options):
|
||||
- Provide your **suggested answer** based on best practices and context.
|
||||
- Format as: `**Suggested:** <your proposed answer> - <brief reasoning>`
|
||||
- Then output: `Format: Short answer (<=5 words). You can accept the suggestion by saying "yes" or "suggested", or provide your own answer.`
|
||||
- After the user answers:
|
||||
- If the user replies with "yes", "recommended", or "suggested", use your previously stated recommendation/suggestion as the answer.
|
||||
- Otherwise, validate the answer maps to one option or fits the <=5 word constraint.
|
||||
- If ambiguous, ask for a quick disambiguation (count still belongs to same question; do not advance).
|
||||
- Once satisfactory, record it in working memory (do not yet write to disk) and move to the next queued question.
|
||||
- Stop asking further questions when:
|
||||
- All critical ambiguities resolved early (remaining queued items become unnecessary), OR
|
||||
- User signals completion ("done", "good", "no more"), OR
|
||||
- You reach 5 asked questions.
|
||||
- Never reveal future queued questions in advance.
|
||||
- If no valid questions exist at start, immediately report no critical ambiguities.
|
||||
|
||||
5. Integration after EACH accepted answer (incremental update approach):
|
||||
- Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents.
|
||||
- For the first integrated answer in this session:
|
||||
- Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing).
|
||||
- Under it, create (if not present) a `### Session YYYY-MM-DD` subheading for today.
|
||||
- Append a bullet line immediately after acceptance: `- Q: <question> → A: <final answer>`.
|
||||
- Then immediately apply the clarification to the most appropriate section(s):
|
||||
- Functional ambiguity → Update or add a bullet in Functional Requirements.
|
||||
- User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario.
|
||||
- Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly.
|
||||
- Non-functional constraint → Add/modify measurable criteria in Non-Functional / Quality Attributes section (convert vague adjective to metric or explicit target).
|
||||
- Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it).
|
||||
- Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as "X")` once.
|
||||
- If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text.
|
||||
- Save the spec file AFTER each integration to minimize risk of context loss (atomic overwrite).
|
||||
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.
|
||||
- Keep each inserted clarification minimal and testable (avoid narrative drift).
|
||||
|
||||
6. Validation (performed after EACH write plus final pass):
|
||||
- Clarifications session contains exactly one bullet per accepted answer (no duplicates).
|
||||
- Total asked (accepted) questions ≤ 5.
|
||||
- Updated sections contain no lingering vague placeholders the new answer was meant to resolve.
|
||||
- No contradictory earlier statement remains (scan for now-invalid alternative choices removed).
|
||||
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.
|
||||
- Terminology consistency: same canonical term used across all updated sections.
|
||||
|
||||
7. Write the updated spec back to `FEATURE_SPEC`.
|
||||
|
||||
8. Report completion (after questioning loop ends or early termination):
|
||||
- Number of questions asked & answered.
|
||||
- Path to updated spec.
|
||||
- Sections touched (list names).
|
||||
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
|
||||
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit.plan` or run `/speckit.clarify` again later post-plan.
|
||||
- Suggested next command.
|
||||
|
||||
Behavior rules:
|
||||
|
||||
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
|
||||
- If spec file missing, instruct user to run `/speckit.specify` first (do not create a new spec here).
|
||||
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
|
||||
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
|
||||
- Respect user early termination signals ("stop", "done", "proceed").
|
||||
- If no questions asked due to full coverage, output a compact coverage summary (all categories Clear) then suggest advancing.
|
||||
- If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.
|
||||
|
||||
Context for prioritization: $ARGUMENTS
|
||||
84
.agent/workflows/speckit.constitution.md
Normal file
84
.agent/workflows/speckit.constitution.md
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync.
|
||||
handoffs:
|
||||
- label: Build Specification
|
||||
agent: speckit.specify
|
||||
prompt: Implement the feature specification based on the updated constitution. I want to build...
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
You are updating the project constitution at `.ai/standards/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
|
||||
|
||||
**Note**: If `.ai/standards/constitution.md` does not exist yet, it should have been initialized from `.specify/templates/constitution-template.md` during project setup. If it's missing, copy the template first.
|
||||
|
||||
Follow this execution flow:
|
||||
|
||||
1. Load the existing constitution at `.ai/standards/constitution.md`.
|
||||
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
|
||||
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
|
||||
|
||||
2. Collect/derive values for placeholders:
|
||||
- If user input (conversation) supplies a value, use it.
|
||||
- Otherwise infer from existing repo context (README, docs, prior constitution versions if embedded).
|
||||
- For governance dates: `RATIFICATION_DATE` is the original adoption date (if unknown ask or mark TODO), `LAST_AMENDED_DATE` is today if changes are made, otherwise keep previous.
|
||||
- `CONSTITUTION_VERSION` must increment according to semantic versioning rules:
|
||||
- MAJOR: Backward incompatible governance/principle removals or redefinitions.
|
||||
- MINOR: New principle/section added or materially expanded guidance.
|
||||
- PATCH: Clarifications, wording, typo fixes, non-semantic refinements.
|
||||
- If version bump type ambiguous, propose reasoning before finalizing.
|
||||
|
||||
3. Draft the updated constitution content:
|
||||
- Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left).
|
||||
- Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance.
|
||||
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing non‑negotiable rules, explicit rationale if not obvious.
|
||||
- Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations.
|
||||
|
||||
4. Consistency propagation checklist (convert prior checklist into active validations):
|
||||
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
|
||||
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
|
||||
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
|
||||
- Read each command file in `.specify/templates/commands/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required.
|
||||
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
|
||||
|
||||
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
|
||||
- Version change: old → new
|
||||
- List of modified principles (old title → new title if renamed)
|
||||
- Added sections
|
||||
- Removed sections
|
||||
- Templates requiring updates (✅ updated / ⚠ pending) with file paths
|
||||
- Follow-up TODOs if any placeholders intentionally deferred.
|
||||
|
||||
6. Validation before final output:
|
||||
- No remaining unexplained bracket tokens.
|
||||
- Version line matches report.
|
||||
- Dates ISO format YYYY-MM-DD.
|
||||
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate).
|
||||
|
||||
7. Write the completed constitution back to `.ai/standards/constitution.md` (overwrite).
|
||||
|
||||
8. Output a final summary to the user with:
|
||||
- New version and bump rationale.
|
||||
- Any files flagged for manual follow-up.
|
||||
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
|
||||
|
||||
Formatting & Style Requirements:
|
||||
|
||||
- Use Markdown headings exactly as in the template (do not demote/promote levels).
|
||||
- Wrap long rationale lines to keep readability (<100 chars ideally) but do not hard enforce with awkward breaks.
|
||||
- Keep a single blank line between sections.
|
||||
- Avoid trailing whitespace.
|
||||
|
||||
If the user supplies partial updates (e.g., only one principle revision), still perform validation and version decision steps.
|
||||
|
||||
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
|
||||
|
||||
Do not create a new template; always operate on the existing `.ai/standards/constitution.md` file.
|
||||
199
.agent/workflows/speckit.fix.md
Normal file
199
.agent/workflows/speckit.fix.md
Normal file
@@ -0,0 +1,199 @@
|
||||
---
|
||||
|
||||
description: Fix failing tests and implementation issues based on test reports
|
||||
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Goal
|
||||
|
||||
Analyze test failure reports, identify root causes, and fix implementation issues while preserving semantic protocol compliance.
|
||||
|
||||
## Operating Constraints
|
||||
|
||||
1. **USE CODER MODE**: Always switch to `coder` mode for code fixes
|
||||
2. **SEMANTIC PROTOCOL**: Never remove semantic annotations ([DEF], @TAGS). Only update code logic.
|
||||
3. **TEST DATA**: If tests use @TEST_ fixtures, preserve them when fixing
|
||||
4. **NO DELETION**: Never delete existing tests or semantic annotations
|
||||
5. **REPORT FIRST**: Always write a fix report before making changes
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### 1. Load Test Report
|
||||
|
||||
**Required**: Test report file path (e.g., `specs/<feature>/tests/reports/2026-02-19-report.md`)
|
||||
|
||||
**Parse the report for**:
|
||||
- Failed test cases
|
||||
- Error messages
|
||||
- Stack traces
|
||||
- Expected vs actual behavior
|
||||
- Affected modules/files
|
||||
|
||||
### 2. Analyze Root Causes
|
||||
|
||||
For each failed test:
|
||||
|
||||
1. **Read the test file** to understand what it's testing
|
||||
2. **Read the implementation file** to find the bug
|
||||
3. **Check semantic protocol compliance**:
|
||||
- Does the implementation have correct [DEF] anchors?
|
||||
- Are @TAGS (@PRE, @POST, @UX_STATE, etc.) present?
|
||||
- Does the code match the TIER requirements?
|
||||
4. **Identify the fix**:
|
||||
- Logic error in implementation
|
||||
- Missing error handling
|
||||
- Incorrect API usage
|
||||
- State management issue
|
||||
|
||||
### 3. Write Fix Report
|
||||
|
||||
Create a structured fix report:
|
||||
|
||||
```markdown
|
||||
# Fix Report: [FEATURE]
|
||||
|
||||
**Date**: [YYYY-MM-DD]
|
||||
**Report**: [Test Report Path]
|
||||
**Fixer**: Coder Agent
|
||||
|
||||
## Summary
|
||||
|
||||
- Total Failed Tests: [X]
|
||||
- Total Fixed: [X]
|
||||
- Total Skipped: [X]
|
||||
|
||||
## Failed Tests Analysis
|
||||
|
||||
### Test: [Test Name]
|
||||
|
||||
**File**: `path/to/test.py`
|
||||
**Error**: [Error message]
|
||||
|
||||
**Root Cause**: [Explanation of why test failed]
|
||||
|
||||
**Fix Required**: [Description of fix]
|
||||
|
||||
**Status**: [Pending/In Progress/Completed]
|
||||
|
||||
## Fixes Applied
|
||||
|
||||
### Fix 1: [Description]
|
||||
|
||||
**Affected File**: `path/to/file.py`
|
||||
**Test Affected**: `[Test Name]`
|
||||
|
||||
**Changes**:
|
||||
```diff
|
||||
<<<<<<< SEARCH
|
||||
[Original Code]
|
||||
=======
|
||||
[Fixed Code]
|
||||
>>>>>>> REPLACE
|
||||
```
|
||||
|
||||
**Verification**: [How to verify fix works]
|
||||
|
||||
**Semantic Integrity**: [Confirmed annotations preserved]
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [ ] Run tests to verify fix: `cd backend && .venv/bin/python3 -m pytest`
|
||||
- [ ] Check for related failing tests
|
||||
- [ ] Update test documentation if needed
|
||||
```
|
||||
|
||||
### 4. Apply Fixes (in Coder Mode)
|
||||
|
||||
Switch to `coder` mode and apply fixes:
|
||||
|
||||
1. **Read the implementation file** to get exact content
|
||||
2. **Apply the fix** using apply_diff
|
||||
3. **Preserve all semantic annotations**:
|
||||
- Keep [DEF:...] and [/DEF:...] anchors
|
||||
- Keep all @TAGS (@PURPOSE, @LAYER, @TIER, @RELATION, @PRE, @POST, @UX_STATE, @UX_FEEDBACK, @UX_RECOVERY)
|
||||
4. **Only update code logic** to fix the bug
|
||||
5. **Run tests** to verify the fix
|
||||
|
||||
### 5. Verification
|
||||
|
||||
After applying fixes:
|
||||
|
||||
1. **Run tests**:
|
||||
```bash
|
||||
cd backend && .venv/bin/python3 -m pytest -v
|
||||
```
|
||||
or
|
||||
```bash
|
||||
cd frontend && npm run test
|
||||
```
|
||||
|
||||
2. **Check test results**:
|
||||
- Failed tests should now pass
|
||||
- No new tests should fail
|
||||
- Coverage should not decrease
|
||||
|
||||
3. **Update fix report** with results:
|
||||
- Mark fixes as completed
|
||||
- Add verification steps
|
||||
- Note any remaining issues
|
||||
|
||||
## Output
|
||||
|
||||
Generate final fix report:
|
||||
|
||||
```markdown
|
||||
# Fix Report: [FEATURE] - COMPLETED
|
||||
|
||||
**Date**: [YYYY-MM-DD]
|
||||
**Report**: [Test Report Path]
|
||||
**Fixer**: Coder Agent
|
||||
|
||||
## Summary
|
||||
|
||||
- Total Failed Tests: [X]
|
||||
- Total Fixed: [X] ✅
|
||||
- Total Skipped: [X]
|
||||
|
||||
## Fixes Applied
|
||||
|
||||
### Fix 1: [Description] ✅
|
||||
|
||||
**Affected File**: `path/to/file.py`
|
||||
**Test Affected**: `[Test Name]`
|
||||
|
||||
**Changes**: [Summary of changes]
|
||||
|
||||
**Verification**: All tests pass ✅
|
||||
|
||||
**Semantic Integrity**: Preserved ✅
|
||||
|
||||
## Test Results
|
||||
|
||||
```
|
||||
[Full test output showing all passing tests]
|
||||
```
|
||||
|
||||
## Recommendations
|
||||
|
||||
- [ ] Monitor for similar issues
|
||||
- [ ] Update documentation if needed
|
||||
- [ ] Consider adding more tests for edge cases
|
||||
|
||||
## Related Files
|
||||
|
||||
- Test Report: [path]
|
||||
- Implementation: [path]
|
||||
- Test File: [path]
|
||||
```
|
||||
|
||||
## Context for Fixing
|
||||
|
||||
$ARGUMENTS
|
||||
150
.agent/workflows/speckit.implement.md
Normal file
150
.agent/workflows/speckit.implement.md
Normal file
@@ -0,0 +1,150 @@
|
||||
---
|
||||
description: Execute the implementation plan by processing and executing all tasks defined in tasks.md
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Check checklists status** (if FEATURE_DIR/checklists/ exists):
|
||||
- Scan all checklist files in the checklists/ directory
|
||||
- For each checklist, count:
|
||||
- Total items: All lines matching `- [ ]` or `- [X]` or `- [x]`
|
||||
- Completed items: Lines matching `- [X]` or `- [x]`
|
||||
- Incomplete items: Lines matching `- [ ]`
|
||||
- Create a status table:
|
||||
|
||||
```text
|
||||
| Checklist | Total | Completed | Incomplete | Status |
|
||||
|-----------|-------|-----------|------------|--------|
|
||||
| ux.md | 12 | 12 | 0 | ✓ PASS |
|
||||
| test.md | 8 | 5 | 3 | ✗ FAIL |
|
||||
| security.md | 6 | 6 | 0 | ✓ PASS |
|
||||
```
|
||||
|
||||
- Calculate overall status:
|
||||
- **PASS**: All checklists have 0 incomplete items
|
||||
- **FAIL**: One or more checklists have incomplete items
|
||||
|
||||
- **If any checklist is incomplete**:
|
||||
- Display the table with incomplete item counts
|
||||
- **STOP** and ask: "Some checklists are incomplete. Do you want to proceed with implementation anyway? (yes/no)"
|
||||
- Wait for user response before continuing
|
||||
- If user says "no" or "wait" or "stop", halt execution
|
||||
- If user says "yes" or "proceed" or "continue", proceed to step 3
|
||||
|
||||
- **If all checklists are complete**:
|
||||
- Display the table showing all checklists passed
|
||||
- Automatically proceed to step 3
|
||||
|
||||
3. Load and analyze the implementation context:
|
||||
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
|
||||
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
|
||||
- **IF EXISTS**: Read data-model.md for entities and relationships
|
||||
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
|
||||
- **IF EXISTS**: Read research.md for technical decisions and constraints
|
||||
- **IF EXISTS**: Read quickstart.md for integration scenarios
|
||||
|
||||
3. Load and analyze the implementation context:
|
||||
- **REQUIRED**: Read `.ai/standards/semantics.md` for strict coding standards and contract requirements
|
||||
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
|
||||
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
|
||||
- **IF EXISTS**: Read data-model.md for entities and relationships
|
||||
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
|
||||
- **IF EXISTS**: Read research.md for technical decisions and constraints
|
||||
- **IF EXISTS**: Read quickstart.md for integration scenarios
|
||||
|
||||
4. **Project Setup Verification**:
|
||||
- **REQUIRED**: Create/verify ignore files based on actual project setup:
|
||||
|
||||
**Detection & Creation Logic**:
|
||||
- Check if the following command succeeds to determine if the repository is a git repo (create/verify .gitignore if so):
|
||||
|
||||
```sh
|
||||
git rev-parse --git-dir 2>/dev/null
|
||||
```
|
||||
|
||||
- Check if Dockerfile* exists or Docker in plan.md → create/verify .dockerignore
|
||||
- Check if .eslintrc* exists → create/verify .eslintignore
|
||||
- Check if eslint.config.* exists → ensure the config's `ignores` entries cover required patterns
|
||||
- Check if .prettierrc* exists → create/verify .prettierignore
|
||||
- Check if .npmrc or package.json exists → create/verify .npmignore (if publishing)
|
||||
- Check if terraform files (*.tf) exist → create/verify .terraformignore
|
||||
- Check if .helmignore needed (helm charts present) → create/verify .helmignore
|
||||
|
||||
**If ignore file already exists**: Verify it contains essential patterns, append missing critical patterns only
|
||||
**If ignore file missing**: Create with full pattern set for detected technology
|
||||
|
||||
**Common Patterns by Technology** (from plan.md tech stack):
|
||||
- **Node.js/JavaScript/TypeScript**: `node_modules/`, `dist/`, `build/`, `*.log`, `.env*`
|
||||
- **Python**: `__pycache__/`, `*.pyc`, `.venv/`, `venv/`, `dist/`, `*.egg-info/`
|
||||
- **Java**: `target/`, `*.class`, `*.jar`, `.gradle/`, `build/`
|
||||
- **C#/.NET**: `bin/`, `obj/`, `*.user`, `*.suo`, `packages/`
|
||||
- **Go**: `*.exe`, `*.test`, `vendor/`, `*.out`
|
||||
- **Ruby**: `.bundle/`, `log/`, `tmp/`, `*.gem`, `vendor/bundle/`
|
||||
- **PHP**: `vendor/`, `*.log`, `*.cache`, `*.env`
|
||||
- **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*`
|
||||
- **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*`
|
||||
- **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*`
|
||||
- **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `Makefile`, `config.log`, `.idea/`, `*.log`, `.env*`
|
||||
- **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/`
|
||||
- **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/`
|
||||
- **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/`
|
||||
|
||||
**Tool-Specific Patterns**:
|
||||
- **Docker**: `node_modules/`, `.git/`, `Dockerfile*`, `.dockerignore`, `*.log*`, `.env*`, `coverage/`
|
||||
- **ESLint**: `node_modules/`, `dist/`, `build/`, `coverage/`, `*.min.js`
|
||||
- **Prettier**: `node_modules/`, `dist/`, `build/`, `coverage/`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`
|
||||
- **Terraform**: `.terraform/`, `*.tfstate*`, `*.tfvars`, `.terraform.lock.hcl`
|
||||
- **Kubernetes/k8s**: `*.secret.yaml`, `secrets/`, `.kube/`, `kubeconfig*`, `*.key`, `*.crt`
|
||||
|
||||
5. Parse tasks.md structure and extract:
|
||||
- **Task phases**: Setup, Tests, Core, Integration, Polish
|
||||
- **Task dependencies**: Sequential vs parallel execution rules
|
||||
- **Task details**: ID, description, file paths, parallel markers [P]
|
||||
- **Execution flow**: Order and dependency requirements
|
||||
|
||||
6. Execute implementation following the task plan:
|
||||
- **Phase-by-phase execution**: Complete each phase before moving to the next
|
||||
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
|
||||
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
|
||||
- **File-based coordination**: Tasks affecting the same files must run sequentially
|
||||
- **Validation checkpoints**: Verify each phase completion before proceeding
|
||||
|
||||
7. Implementation execution rules:
|
||||
- **Strict Adherence**: Apply `.ai/standards/semantics.md` rules:
|
||||
- Every file MUST start with a `[DEF:id:Type]` header and end with a closing `[/DEF:id:Type]` anchor.
|
||||
- Include `@TIER` and define contracts (`@PRE`, `@POST`).
|
||||
- For Svelte components, use `@UX_STATE`, `@UX_FEEDBACK`, `@UX_RECOVERY`, and explicitly declare reactivity with `@UX_REATIVITY: State: $state, Derived: $derived`.
|
||||
- **Molecular Topology Logging**: Use prefixes `[EXPLORE]`, `[REASON]`, `[REFLECT]` in logs to trace logic.
|
||||
- **CRITICAL Contracts**: If a task description contains a contract summary (e.g., `CRITICAL: PRE: ..., POST: ...`), these constraints are **MANDATORY** and must be strictly implemented in the code using guards/assertions (if applicable per protocol).
|
||||
- **Setup first**: Initialize project structure, dependencies, configuration
|
||||
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
|
||||
- **Core development**: Implement models, services, CLI commands, endpoints
|
||||
- **Integration work**: Database connections, middleware, logging, external services
|
||||
- **Polish and validation**: Unit tests, performance optimization, documentation
|
||||
|
||||
8. Progress tracking and error handling:
|
||||
- Report progress after each completed task
|
||||
- Halt execution if any non-parallel task fails
|
||||
- For parallel tasks [P], continue with successful tasks, report failed ones
|
||||
- Provide clear error messages with context for debugging
|
||||
- Suggest next steps if implementation cannot proceed
|
||||
- **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file.
|
||||
|
||||
9. Completion validation:
|
||||
- Verify all required tasks are completed
|
||||
- Check that implemented features match the original specification
|
||||
- Validate that tests pass and coverage meets requirements
|
||||
- Confirm the implementation follows the technical plan
|
||||
- Report final status with summary of completed work
|
||||
|
||||
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit.tasks` first to regenerate the task list.
|
||||
104
.agent/workflows/speckit.plan.md
Normal file
104
.agent/workflows/speckit.plan.md
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
description: Execute the implementation planning workflow using the plan template to generate design artifacts.
|
||||
handoffs:
|
||||
- label: Create Tasks
|
||||
agent: speckit.tasks
|
||||
prompt: Break the plan into tasks
|
||||
send: true
|
||||
- label: Create Checklist
|
||||
agent: speckit.checklist
|
||||
prompt: Create a checklist for the following domain...
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/bash/setup-plan.sh --json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Load context**: Read `.ai/ROOT.md` and `.ai/PROJECT_MAP.md` to understand the project structure and navigation. Then read required standards: `.ai/standards/constitution.md` and `.ai/standards/semantics.md`. Load IMPL_PLAN template.
|
||||
|
||||
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
|
||||
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
|
||||
- Fill Constitution Check section from constitution
|
||||
- Evaluate gates (ERROR if violations unjustified)
|
||||
- Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION)
|
||||
- Phase 1: Generate data-model.md, contracts/, quickstart.md
|
||||
- Phase 1: Update agent context by running the agent script
|
||||
- Re-evaluate Constitution Check post-design
|
||||
|
||||
4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 0: Outline & Research
|
||||
|
||||
1. **Extract unknowns from Technical Context** above:
|
||||
- For each NEEDS CLARIFICATION → research task
|
||||
- For each dependency → best practices task
|
||||
- For each integration → patterns task
|
||||
|
||||
2. **Generate and dispatch research agents**:
|
||||
|
||||
```text
|
||||
For each unknown in Technical Context:
|
||||
Task: "Research {unknown} for {feature context}"
|
||||
For each technology choice:
|
||||
Task: "Find best practices for {tech} in {domain}"
|
||||
```
|
||||
|
||||
3. **Consolidate findings** in `research.md` using format:
|
||||
- Decision: [what was chosen]
|
||||
- Rationale: [why chosen]
|
||||
- Alternatives considered: [what else evaluated]
|
||||
|
||||
**Output**: research.md with all NEEDS CLARIFICATION resolved
|
||||
|
||||
### Phase 1: Design & Contracts
|
||||
|
||||
**Prerequisites:** `research.md` complete
|
||||
|
||||
0. **Validate Design against UX Reference**:
|
||||
- Check if the proposed architecture supports the latency, interactivity, and flow defined in `ux_reference.md`.
|
||||
- **Linkage**: Ensure key UI states from `ux_reference.md` map to Component Contracts (`@UX_STATE`).
|
||||
- **CRITICAL**: If the technical plan compromises the UX (e.g. "We can't do real-time validation"), you **MUST STOP** and warn the user.
|
||||
|
||||
1. **Extract entities from feature spec** → `data-model.md`:
|
||||
- Entity name, fields, relationships, validation rules.
|
||||
|
||||
2. **Design & Verify Contracts (Semantic Protocol)**:
|
||||
- **Drafting**: Define `[DEF:id:Type]` Headers, Contracts, and closing `[/DEF:id:Type]` for all new modules based on `.ai/standards/semantics.md`.
|
||||
- **TIER Classification**: Explicitly assign `@TIER: [CRITICAL|STANDARD|TRIVIAL]` to each module.
|
||||
- **CRITICAL Requirements**: For all CRITICAL modules, define full `@PRE`, `@POST`, and (if UI) `@UX_STATE` contracts. **MUST** also define testing contracts: `@TEST_CONTRACT`, `@TEST_FIXTURE`, `@TEST_EDGE`, and `@TEST_INVARIANT`.
|
||||
- **Self-Review**:
|
||||
- *Completeness*: Do `@PRE`/`@POST` cover edge cases identified in Research? Are test contracts present for CRITICAL?
|
||||
- *Connectivity*: Do `@RELATION` tags form a coherent graph?
|
||||
- *Compliance*: Does syntax match `[DEF:id:Type]` exactly and is it closed with `[/DEF:id:Type]`?
|
||||
- **Output**: Write verified contracts to `contracts/modules.md`.
|
||||
|
||||
3. **Simulate Contract Usage**:
|
||||
- Trace one key user scenario through the defined contracts to ensure data flow continuity.
|
||||
- If a contract interface mismatch is found, fix it immediately.
|
||||
|
||||
4. **Generate API contracts**:
|
||||
- Output OpenAPI/GraphQL schema to `/contracts/` for backend-frontend sync.
|
||||
|
||||
3. **Agent context update**:
|
||||
- Run `.specify/scripts/bash/update-agent-context.sh agy`
|
||||
- These scripts detect which AI agent is in use
|
||||
- Update the appropriate agent-specific context file
|
||||
- Add only new technology from current plan
|
||||
- Preserve manual additions between markers
|
||||
|
||||
**Output**: data-model.md, /contracts/*, quickstart.md, agent-specific file
|
||||
|
||||
## Key rules
|
||||
|
||||
- Use absolute paths
|
||||
- ERROR on gate failures or unresolved clarifications
|
||||
258
.agent/workflows/speckit.specify.md
Normal file
258
.agent/workflows/speckit.specify.md
Normal file
@@ -0,0 +1,258 @@
|
||||
---
|
||||
description: Create or update the feature specification from a natural language feature description.
|
||||
handoffs:
|
||||
- label: Build Technical Plan
|
||||
agent: speckit.plan
|
||||
prompt: Create a plan for the spec. I am building with...
|
||||
- label: Clarify Spec Requirements
|
||||
agent: speckit.clarify
|
||||
prompt: Clarify specification requirements
|
||||
send: true
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `$ARGUMENTS` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
|
||||
|
||||
Given that feature description, do this:
|
||||
|
||||
1. **Generate a concise short name** (2-4 words) for the branch:
|
||||
- Analyze the feature description and extract the most meaningful keywords
|
||||
- Create a 2-4 word short name that captures the essence of the feature
|
||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
||||
- Keep it concise but descriptive enough to understand the feature at a glance
|
||||
- Examples:
|
||||
- "I want to add user authentication" → "user-auth"
|
||||
- "Implement OAuth2 integration for the API" → "oauth2-api-integration"
|
||||
- "Create a dashboard for analytics" → "analytics-dashboard"
|
||||
- "Fix payment processing timeout bug" → "fix-payment-timeout"
|
||||
|
||||
2. **Check for existing branches before creating new one**:
|
||||
|
||||
a. First, fetch all remote branches to ensure we have the latest information:
|
||||
|
||||
```bash
|
||||
git fetch --all --prune
|
||||
```
|
||||
|
||||
b. Find the highest feature number across all sources for the short-name:
|
||||
- Remote branches: `git ls-remote --heads origin | grep -E 'refs/heads/[0-9]+-<short-name>$'`
|
||||
- Local branches: `git branch | grep -E '^[* ]*[0-9]+-<short-name>$'`
|
||||
- Specs directories: Check for directories matching `specs/[0-9]+-<short-name>`
|
||||
|
||||
c. Determine the next available number:
|
||||
- Extract all numbers from all three sources
|
||||
- Find the highest number N
|
||||
- Use N+1 for the new branch number
|
||||
|
||||
d. Run the script `.specify/scripts/bash/create-new-feature.sh --json "$ARGUMENTS"` with the calculated number and short-name:
|
||||
- Pass `--number N+1` and `--short-name "your-short-name"` along with the feature description
|
||||
- Bash example: `.specify/scripts/bash/create-new-feature.sh --json "$ARGUMENTS" --json --number 5 --short-name "user-auth" "Add user authentication"`
|
||||
- PowerShell example: `.specify/scripts/bash/create-new-feature.sh --json "$ARGUMENTS" -Json -Number 5 -ShortName "user-auth" "Add user authentication"`
|
||||
|
||||
**IMPORTANT**:
|
||||
- Check all three sources (remote branches, local branches, specs directories) to find the highest number
|
||||
- Only match branches/directories with the exact short-name pattern
|
||||
- If no existing branches/directories found with this short-name, start with number 1
|
||||
- You must only ever run this script once per feature
|
||||
- The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for
|
||||
- The JSON output will contain BRANCH_NAME and SPEC_FILE paths
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot")
|
||||
|
||||
3. Load `.specify/templates/spec-template.md` to understand required sections.
|
||||
|
||||
4. Follow this execution flow:
|
||||
|
||||
1. Parse user description from Input
|
||||
If empty: ERROR "No feature description provided"
|
||||
2. Extract key concepts from description
|
||||
Identify: actors, actions, data, constraints
|
||||
3. For unclear aspects:
|
||||
- Make informed guesses based on context and industry standards
|
||||
- Only mark with [NEEDS CLARIFICATION: specific question] if:
|
||||
- The choice significantly impacts feature scope or user experience
|
||||
- Multiple reasonable interpretations exist with different implications
|
||||
- No reasonable default exists
|
||||
- **LIMIT: Maximum 3 [NEEDS CLARIFICATION] markers total**
|
||||
- Prioritize clarifications by impact: scope > security/privacy > user experience > technical details
|
||||
4. Fill User Scenarios & Testing section
|
||||
If no clear user flow: ERROR "Cannot determine user scenarios"
|
||||
5. Generate Functional Requirements
|
||||
Each requirement must be testable
|
||||
Use reasonable defaults for unspecified details (document assumptions in Assumptions section)
|
||||
6. Define Success Criteria
|
||||
Create measurable, technology-agnostic outcomes
|
||||
Include both quantitative metrics (time, performance, volume) and qualitative measures (user satisfaction, task completion)
|
||||
Each criterion must be verifiable without implementation details
|
||||
7. Identify Key Entities (if data involved)
|
||||
8. Return: SUCCESS (spec ready for planning)
|
||||
|
||||
5. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
|
||||
|
||||
6. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
|
||||
|
||||
a. **Create Spec Quality Checklist**: Generate a checklist file at `FEATURE_DIR/checklists/requirements.md` using the checklist template structure with these validation items:
|
||||
|
||||
```markdown
|
||||
# Specification Quality Checklist: [FEATURE NAME]
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: [DATE]
|
||||
**Feature**: [Link to spec.md]
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [ ] No implementation details (languages, frameworks, APIs)
|
||||
- [ ] Focused on user value and business needs
|
||||
- [ ] Written for non-technical stakeholders
|
||||
- [ ] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [ ] No [NEEDS CLARIFICATION] markers remain
|
||||
- [ ] Requirements are testable and unambiguous
|
||||
- [ ] Success criteria are measurable
|
||||
- [ ] Success criteria are technology-agnostic (no implementation details)
|
||||
- [ ] All acceptance scenarios are defined
|
||||
- [ ] Edge cases are identified
|
||||
- [ ] Scope is clearly bounded
|
||||
- [ ] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [ ] All functional requirements have clear acceptance criteria
|
||||
- [ ] User scenarios cover primary flows
|
||||
- [ ] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [ ] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||
```
|
||||
|
||||
b. **Run Validation Check**: Review the spec against each checklist item:
|
||||
- For each item, determine if it passes or fails
|
||||
- Document specific issues found (quote relevant spec sections)
|
||||
|
||||
c. **Handle Validation Results**:
|
||||
|
||||
- **If all items pass**: Mark checklist complete and proceed to step 6
|
||||
|
||||
- **If items fail (excluding [NEEDS CLARIFICATION])**:
|
||||
1. List the failing items and specific issues
|
||||
2. Update the spec to address each issue
|
||||
3. Re-run validation until all items pass (max 3 iterations)
|
||||
4. If still failing after 3 iterations, document remaining issues in checklist notes and warn user
|
||||
|
||||
- **If [NEEDS CLARIFICATION] markers remain**:
|
||||
1. Extract all [NEEDS CLARIFICATION: ...] markers from the spec
|
||||
2. **LIMIT CHECK**: If more than 3 markers exist, keep only the 3 most critical (by scope/security/UX impact) and make informed guesses for the rest
|
||||
3. For each clarification needed (max 3), present options to user in this format:
|
||||
|
||||
```markdown
|
||||
## Question [N]: [Topic]
|
||||
|
||||
**Context**: [Quote relevant spec section]
|
||||
|
||||
**What we need to know**: [Specific question from NEEDS CLARIFICATION marker]
|
||||
|
||||
**Suggested Answers**:
|
||||
|
||||
| Option | Answer | Implications |
|
||||
|--------|--------|--------------|
|
||||
| A | [First suggested answer] | [What this means for the feature] |
|
||||
| B | [Second suggested answer] | [What this means for the feature] |
|
||||
| C | [Third suggested answer] | [What this means for the feature] |
|
||||
| Custom | Provide your own answer | [Explain how to provide custom input] |
|
||||
|
||||
**Your choice**: _[Wait for user response]_
|
||||
```
|
||||
|
||||
4. **CRITICAL - Table Formatting**: Ensure markdown tables are properly formatted:
|
||||
- Use consistent spacing with pipes aligned
|
||||
- Each cell should have spaces around content: `| Content |` not `|Content|`
|
||||
- Header separator must have at least 3 dashes: `|--------|`
|
||||
- Test that the table renders correctly in markdown preview
|
||||
5. Number questions sequentially (Q1, Q2, Q3 - max 3 total)
|
||||
6. Present all questions together before waiting for responses
|
||||
7. Wait for user to respond with their choices for all questions (e.g., "Q1: A, Q2: Custom - [details], Q3: B")
|
||||
8. Update the spec by replacing each [NEEDS CLARIFICATION] marker with the user's selected or provided answer
|
||||
9. Re-run validation after all clarifications are resolved
|
||||
|
||||
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
|
||||
|
||||
7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`).
|
||||
|
||||
**NOTE:** The script creates and checks out the new branch and initializes the spec file before writing.
|
||||
|
||||
## General Guidelines
|
||||
|
||||
## Quick Guidelines
|
||||
|
||||
- Focus on **WHAT** users need and **WHY**.
|
||||
- Avoid HOW to implement (no tech stack, APIs, code structure).
|
||||
- Written for business stakeholders, not developers.
|
||||
- DO NOT create any checklists that are embedded in the spec. That will be a separate command.
|
||||
|
||||
### Section Requirements
|
||||
|
||||
- **Mandatory sections**: Must be completed for every feature
|
||||
- **Optional sections**: Include only when relevant to the feature
|
||||
- When a section doesn't apply, remove it entirely (don't leave as "N/A")
|
||||
|
||||
### For AI Generation
|
||||
|
||||
When creating this spec from a user prompt:
|
||||
|
||||
1. **Make informed guesses**: Use context, industry standards, and common patterns to fill gaps
|
||||
2. **Document assumptions**: Record reasonable defaults in the Assumptions section
|
||||
3. **Limit clarifications**: Maximum 3 [NEEDS CLARIFICATION] markers - use only for critical decisions that:
|
||||
- Significantly impact feature scope or user experience
|
||||
- Have multiple reasonable interpretations with different implications
|
||||
- Lack any reasonable default
|
||||
4. **Prioritize clarifications**: scope > security/privacy > user experience > technical details
|
||||
5. **Think like a tester**: Every vague requirement should fail the "testable and unambiguous" checklist item
|
||||
6. **Common areas needing clarification** (only if no reasonable default exists):
|
||||
- Feature scope and boundaries (include/exclude specific use cases)
|
||||
- User types and permissions (if multiple conflicting interpretations possible)
|
||||
- Security/compliance requirements (when legally/financially significant)
|
||||
|
||||
**Examples of reasonable defaults** (don't ask about these):
|
||||
|
||||
- Data retention: Industry-standard practices for the domain
|
||||
- Performance targets: Standard web/mobile app expectations unless specified
|
||||
- Error handling: User-friendly messages with appropriate fallbacks
|
||||
- Authentication method: Standard session-based or OAuth2 for web apps
|
||||
- Integration patterns: Use project-appropriate patterns (REST/GraphQL for web services, function calls for libraries, CLI args for tools, etc.)
|
||||
|
||||
### Success Criteria Guidelines
|
||||
|
||||
Success criteria must be:
|
||||
|
||||
1. **Measurable**: Include specific metrics (time, percentage, count, rate)
|
||||
2. **Technology-agnostic**: No mention of frameworks, languages, databases, or tools
|
||||
3. **User-focused**: Describe outcomes from user/business perspective, not system internals
|
||||
4. **Verifiable**: Can be tested/validated without knowing implementation details
|
||||
|
||||
**Good examples**:
|
||||
|
||||
- "Users can complete checkout in under 3 minutes"
|
||||
- "System supports 10,000 concurrent users"
|
||||
- "95% of searches return results in under 1 second"
|
||||
- "Task completion rate improves by 40%"
|
||||
|
||||
**Bad examples** (implementation-focused):
|
||||
|
||||
- "API response time is under 200ms" (too technical, use "Users see results instantly")
|
||||
- "Database can handle 1000 TPS" (implementation detail, use user-facing metric)
|
||||
- "React components render efficiently" (framework-specific)
|
||||
- "Redis cache hit rate above 80%" (technology-specific)
|
||||
146
.agent/workflows/speckit.tasks.md
Normal file
146
.agent/workflows/speckit.tasks.md
Normal file
@@ -0,0 +1,146 @@
|
||||
---
|
||||
description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts.
|
||||
handoffs:
|
||||
- label: Analyze For Consistency
|
||||
agent: speckit.analyze
|
||||
prompt: Run a project analysis for consistency
|
||||
send: true
|
||||
- label: Implement Project
|
||||
agent: speckit.implement
|
||||
prompt: Start the implementation in phases
|
||||
send: true
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Load design documents**: Read from FEATURE_DIR:
|
||||
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities), ux_reference.md (experience source of truth)
|
||||
- **Optional**: data-model.md (entities), contracts/ (interface contracts), research.md (decisions), quickstart.md (test scenarios)
|
||||
- Note: Not all projects have all documents. Generate tasks based on what's available.
|
||||
|
||||
3. **Execute task generation workflow**:
|
||||
- Load plan.md and extract tech stack, libraries, project structure
|
||||
- Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.)
|
||||
- If data-model.md exists: Extract entities and map to user stories
|
||||
- If contracts/ exists: Map interface contracts to user stories
|
||||
- If research.md exists: Extract decisions for setup tasks
|
||||
- Generate tasks organized by user story (see Task Generation Rules below)
|
||||
- Generate dependency graph showing user story completion order
|
||||
- Create parallel execution examples per user story
|
||||
- Validate task completeness (each user story has all needed tasks, independently testable)
|
||||
|
||||
4. **Generate tasks.md**: Use `.specify/templates/tasks-template.md` as structure, fill with:
|
||||
- Correct feature name from plan.md
|
||||
- Phase 1: Setup tasks (project initialization)
|
||||
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)
|
||||
- Phase 3+: One phase per user story (in priority order from spec.md)
|
||||
- Each phase includes: story goal, independent test criteria, tests (if requested), implementation tasks
|
||||
- Final Phase: Polish & cross-cutting concerns
|
||||
- All tasks must follow the strict checklist format (see Task Generation Rules below)
|
||||
- Clear file paths for each task
|
||||
- Dependencies section showing story completion order
|
||||
- Parallel execution examples per story
|
||||
- Implementation strategy section (MVP first, incremental delivery)
|
||||
|
||||
5. **Report**: Output path to generated tasks.md and summary:
|
||||
- Total task count
|
||||
- Task count per user story
|
||||
- Parallel opportunities identified
|
||||
- Independent test criteria for each story
|
||||
- Suggested MVP scope (typically just User Story 1)
|
||||
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)
|
||||
|
||||
Context for task generation: $ARGUMENTS
|
||||
|
||||
The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context.
|
||||
|
||||
## Task Generation Rules
|
||||
|
||||
**CRITICAL**: Tasks MUST be organized by user story to enable independent implementation and testing.
|
||||
|
||||
**Tests are OPTIONAL**: Only generate test tasks if explicitly requested in the feature specification or if user requests TDD approach.
|
||||
|
||||
### UX Preservation (CRITICAL)
|
||||
|
||||
- **Source of Truth**: `ux_reference.md` is the absolute standard for the "feel" of the feature.
|
||||
- **Violation Warning**: If any task would inherently violate the UX (e.g. "Remove progress bar to simplify code"), you **MUST** flag this to the user immediately.
|
||||
- **Verification Task**: You **MUST** add a specific task at the end of each User Story phase: `- [ ] Txxx [USx] Verify implementation matches ux_reference.md (Happy Path & Errors)`
|
||||
|
||||
### Checklist Format (REQUIRED)
|
||||
|
||||
Every task MUST strictly follow this format:
|
||||
|
||||
```text
|
||||
- [ ] [TaskID] [P?] [Story?] Description with file path
|
||||
```
|
||||
|
||||
**Format Components**:
|
||||
|
||||
1. **Checkbox**: ALWAYS start with `- [ ]` (markdown checkbox)
|
||||
2. **Task ID**: Sequential number (T001, T002, T003...) in execution order
|
||||
3. **[P] marker**: Include ONLY if task is parallelizable (different files, no dependencies on incomplete tasks)
|
||||
4. **[Story] label**: REQUIRED for user story phase tasks only
|
||||
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)
|
||||
- Setup phase: NO story label
|
||||
- Foundational phase: NO story label
|
||||
- User Story phases: MUST have story label
|
||||
- Polish phase: NO story label
|
||||
5. **Description**: Clear action with exact file path
|
||||
|
||||
**Examples**:
|
||||
|
||||
- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan`
|
||||
- ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py`
|
||||
- ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py`
|
||||
- ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py`
|
||||
- ❌ WRONG: `- [ ] Create User model` (missing ID and Story label)
|
||||
- ❌ WRONG: `T001 [US1] Create model` (missing checkbox)
|
||||
- ❌ WRONG: `- [ ] [US1] Create User model` (missing Task ID)
|
||||
- ❌ WRONG: `- [ ] T001 [US1] Create model` (missing file path)
|
||||
|
||||
### Task Organization
|
||||
|
||||
1. **From User Stories (spec.md)** - PRIMARY ORGANIZATION:
|
||||
- Each user story (P1, P2, P3...) gets its own phase
|
||||
- Map all related components to their story:
|
||||
- Models needed for that story
|
||||
- Services needed for that story
|
||||
- Interfaces/UI needed for that story
|
||||
- If tests requested: Tests specific to that story
|
||||
- Mark story dependencies (most stories should be independent)
|
||||
|
||||
2. **From Contracts (CRITICAL TIER)**:
|
||||
- Identify components marked as `@TIER: CRITICAL` in `contracts/modules.md`.
|
||||
- For these components, **MUST** append the summary of `@PRE`, `@POST`, `@UX_STATE`, and test contracts (`@TEST_FIXTURE`, `@TEST_EDGE`) directly to the task description.
|
||||
- Example: `- [ ] T005 [P] [US1] Implement Auth (CRITICAL: PRE: token exists, POST: returns User, TESTS: 2 edges) in src/auth.py`
|
||||
- Map each contract/endpoint → to the user story it serves
|
||||
- If tests requested: Each contract → contract test task [P] before implementation in that story's phase
|
||||
|
||||
3. **From Data Model**:
|
||||
- Map each entity to the user story(ies) that need it
|
||||
- If entity serves multiple stories: Put in earliest story or Setup phase
|
||||
- Relationships → service layer tasks in appropriate story phase
|
||||
|
||||
4. **From Setup/Infrastructure**:
|
||||
- Shared infrastructure → Setup phase (Phase 1)
|
||||
- Foundational/blocking tasks → Foundational phase (Phase 2)
|
||||
- Story-specific setup → within that story's phase
|
||||
|
||||
### Phase Structure
|
||||
|
||||
- **Phase 1**: Setup (project initialization)
|
||||
- **Phase 2**: Foundational (blocking prerequisites - MUST complete before user stories)
|
||||
- **Phase 3+**: User Stories in priority order (P1, P2, P3...)
|
||||
- Within each story: Tests (if requested) → Models → Services → Endpoints → Integration
|
||||
- Each phase should be a complete, independently testable increment
|
||||
- **Final Phase**: Polish & Cross-Cutting Concerns
|
||||
30
.agent/workflows/speckit.taskstoissues.md
Normal file
30
.agent/workflows/speckit.taskstoissues.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
description: Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts.
|
||||
tools: ['github/github-mcp-server/issue_write']
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
1. From the executed script, extract the path to **tasks**.
|
||||
1. Get the Git remote by running:
|
||||
|
||||
```bash
|
||||
git config --get remote.origin.url
|
||||
```
|
||||
|
||||
> [!CAUTION]
|
||||
> ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL
|
||||
|
||||
1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote.
|
||||
|
||||
> [!CAUTION]
|
||||
> UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL
|
||||
343
.agent/workflows/speckit.test.md
Normal file
343
.agent/workflows/speckit.test.md
Normal file
@@ -0,0 +1,343 @@
|
||||
---
|
||||
description: ✅ GRACE‑Poly Tester Agent (Production Edition)
|
||||
---
|
||||
|
||||
# ✅ GRACE‑Poly Tester Agent (Production Edition)
|
||||
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
Если вход не пуст — он имеет приоритет и должен быть учтён при анализе.
|
||||
|
||||
---
|
||||
|
||||
# I. MANDATE(命)
|
||||
|
||||
Исполнить полный цикл тестирования:
|
||||
|
||||
1. Анализировать модули.
|
||||
2. Проверять соответствие TIER.
|
||||
3. Генерировать тесты строго из TEST_SPEC.
|
||||
4. Поддерживать документацию.
|
||||
5. Не нарушать существующие тесты.
|
||||
6. Проверять инварианты.
|
||||
|
||||
Тестер — не писатель тестов.
|
||||
Тестер — хранитель контрактов.
|
||||
|
||||
---
|
||||
|
||||
# II. НЕЗЫБЛЕМЫЕ ПРАВИЛА
|
||||
|
||||
1. **Никогда не удалять существующие тесты.**
|
||||
2. **Никогда не дублировать тесты.**
|
||||
3. Для CRITICAL — TEST_SPEC обязателен.
|
||||
4. Каждый `@TEST_EDGE` → минимум один тест.
|
||||
5. Каждый `@TEST_INVARIANT` → минимум один тест.
|
||||
6. Если CRITICAL без `@TEST_CONTRACT` →
|
||||
немедленно:
|
||||
|
||||
```
|
||||
[COHERENCE_CHECK_FAILED]
|
||||
Reason: Missing TEST_CONTRACT in CRITICAL module
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# III. АНАЛИЗ КОНТЕКСТА
|
||||
|
||||
Выполнить:
|
||||
|
||||
```
|
||||
.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
|
||||
```
|
||||
|
||||
Извлечь:
|
||||
|
||||
- FEATURE_DIR
|
||||
- TASKS_FILE
|
||||
- AVAILABLE_DOCS
|
||||
|
||||
---
|
||||
|
||||
# IV. ЗАГРУЗКА АРТЕФАКТОВ
|
||||
|
||||
### 1️⃣ Из tasks.md
|
||||
|
||||
- Найти завершённые implementation задачи
|
||||
- Исключить test‑tasks
|
||||
- Определить список модулей
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ Из модулей
|
||||
|
||||
Для каждого модуля:
|
||||
|
||||
- Прочитать `@TIER`
|
||||
- Прочитать:
|
||||
- `@TEST_CONTRACT`
|
||||
- `@TEST_FIXTURE`
|
||||
- `@TEST_EDGE`
|
||||
- `@TEST_INVARIANT`
|
||||
|
||||
Если CRITICAL и нет TEST_SPEC → STOP.
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ Сканирование существующих тестов
|
||||
|
||||
Искать в `__tests__/`.
|
||||
|
||||
Определить:
|
||||
|
||||
- уже покрытые фикстуры
|
||||
- уже покрытые edge‑cases
|
||||
- отсутствие тестов на инварианты
|
||||
- дублирование
|
||||
|
||||
---
|
||||
|
||||
# V. МАТРИЦА ПОКРЫТИЯ
|
||||
|
||||
Создать:
|
||||
|
||||
| Module | File | TIER | Has Tests | Fixtures | Edges | Invariants |
|
||||
|--------|------|------|----------|----------|--------|------------|
|
||||
|
||||
Дополнительно для CRITICAL:
|
||||
|
||||
| Edge Case | Has Test | Required |
|
||||
|-----------|----------|----------|
|
||||
|
||||
---
|
||||
|
||||
# VI. ГЕНЕРАЦИЯ ТЕСТОВ
|
||||
|
||||
---
|
||||
|
||||
## A. CRITICAL
|
||||
|
||||
Строгий алгоритм:
|
||||
|
||||
### 1️⃣ Валидация контракта
|
||||
|
||||
Создать helper‑валидатор, который проверяет:
|
||||
|
||||
- required_fields присутствуют
|
||||
- типы соответствуют
|
||||
- инварианты соблюдены
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ Для каждого @TEST_FIXTURE
|
||||
|
||||
Создать:
|
||||
|
||||
- 1 Happy-path тест
|
||||
- Проверку @POST
|
||||
- Проверку side-effects
|
||||
- Проверку отсутствия исключений
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ Для каждого @TEST_EDGE
|
||||
|
||||
Создать отдельный тест:
|
||||
|
||||
| Тип | Проверка |
|
||||
|------|----------|
|
||||
| missing_required_field | корректный отказ |
|
||||
| invalid_type | raise или skip |
|
||||
| empty_response | корректное поведение |
|
||||
| external_failure | rollback + лог |
|
||||
| duplicate | корректная обработка |
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ Для каждого @TEST_INVARIANT
|
||||
|
||||
Создать тест, который:
|
||||
|
||||
- нарушает инвариант
|
||||
- проверяет защитную реакцию
|
||||
|
||||
---
|
||||
|
||||
### 5️⃣ Проверка Rollback
|
||||
|
||||
Если модуль взаимодействует с БД:
|
||||
|
||||
- мокать исключение
|
||||
- проверять rollback()
|
||||
- проверять отсутствие частичного коммита
|
||||
|
||||
---
|
||||
|
||||
## B. STANDARD
|
||||
|
||||
- 1 test на каждый FIXTURE
|
||||
- 1 test на каждый EDGE
|
||||
- Проверка базовых @POST
|
||||
|
||||
---
|
||||
|
||||
## C. TRIVIAL
|
||||
|
||||
Тесты создаются только при отсутствии существующих.
|
||||
|
||||
---
|
||||
|
||||
# VII. UX CONTRACT TESTING
|
||||
|
||||
Для каждого Svelte компонента:
|
||||
|
||||
---
|
||||
|
||||
### 1️⃣ Парсинг:
|
||||
|
||||
- @UX_STATE
|
||||
- @UX_FEEDBACK
|
||||
- @UX_RECOVERY
|
||||
- @UX_TEST
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ Генерация:
|
||||
|
||||
Для каждого `@UX_TEST` — отдельный тест.
|
||||
|
||||
Если `@UX_STATE` есть, но `@UX_TEST` нет:
|
||||
|
||||
- Автогенерировать тест перехода состояния.
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ Обязательные проверки:
|
||||
|
||||
- DOM‑класс
|
||||
- aria‑атрибут
|
||||
- визуальная обратная связь
|
||||
- возможность восстановления
|
||||
|
||||
---
|
||||
|
||||
# VIII. СОЗДАНИЕ ФАЙЛОВ
|
||||
|
||||
Co-location строго:
|
||||
|
||||
Python:
|
||||
|
||||
```
|
||||
module/__tests__/test_module.py
|
||||
```
|
||||
|
||||
Svelte:
|
||||
|
||||
```
|
||||
component/__tests__/Component.test.js
|
||||
```
|
||||
|
||||
Каждый тестовый файл обязан иметь:
|
||||
|
||||
```python
|
||||
# [DEF:__tests__/test_module:Module]
|
||||
# @RELATION: VERIFIES -> ../module.py
|
||||
# @PURPOSE: Contract testing for module
|
||||
# [/DEF:__tests__/test_module:Module]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# IX. ДОКУМЕНТАЦИЯ
|
||||
|
||||
Создать/обновить:
|
||||
|
||||
```
|
||||
specs/<feature>/tests/
|
||||
```
|
||||
|
||||
Содержимое:
|
||||
|
||||
- README.md — стратегия
|
||||
- coverage.md — матрица
|
||||
- reports/YYYY-MM-DD-report.md
|
||||
|
||||
---
|
||||
|
||||
# X. ИСПОЛНЕНИЕ
|
||||
|
||||
Backend:
|
||||
|
||||
```
|
||||
cd backend && .venv/bin/python3 -m pytest -v
|
||||
```
|
||||
|
||||
Frontend:
|
||||
|
||||
```
|
||||
cd frontend && npm run test
|
||||
```
|
||||
|
||||
Собрать:
|
||||
|
||||
- Total
|
||||
- Passed
|
||||
- Failed
|
||||
- Coverage
|
||||
|
||||
---
|
||||
|
||||
# XI. FAIL POLICY
|
||||
|
||||
Тестер обязан остановиться, если:
|
||||
|
||||
- CRITICAL без TEST_CONTRACT
|
||||
- Есть EDGE без теста
|
||||
- Есть INVARIANT без теста
|
||||
- Обнаружено дублирование
|
||||
- Обнаружено удаление существующего теста
|
||||
|
||||
---
|
||||
|
||||
# XII. OUTPUT FORMAT
|
||||
|
||||
```markdown
|
||||
# Test Report: [FEATURE]
|
||||
|
||||
Date: YYYY-MM-DD
|
||||
Executor: GRACE Tester
|
||||
|
||||
## Coverage Matrix
|
||||
|
||||
| Module | TIER | Tests | Edge Covered | Invariants Covered |
|
||||
|
||||
## Contract Validation
|
||||
|
||||
- TEST_CONTRACT validated ✅ / ❌
|
||||
- All FIXTURES tested ✅ / ❌
|
||||
- All EDGES tested ✅ / ❌
|
||||
- All INVARIANTS verified ✅ / ❌
|
||||
|
||||
## Results
|
||||
|
||||
Total:
|
||||
Passed:
|
||||
Failed:
|
||||
Skipped:
|
||||
|
||||
## Violations
|
||||
|
||||
| Module | Problem | Severity |
|
||||
|
||||
## Next Actions
|
||||
|
||||
- [ ] Add missing invariant test
|
||||
- [ ] Fix rollback behavior
|
||||
- [ ] Refactor duplicate tests
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
42
.ai/PERSONA.md
Normal file
42
.ai/PERSONA.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# [DEF:Std:UserPersona:Standard]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: persona, tone_of_voice, interaction_rules, architect
|
||||
# @PURPOSE: Defines how the AI Agent MUST interact with the user and the codebase.
|
||||
|
||||
@ROLE: Chief Semantic Architect & AI-Engineering Lead.
|
||||
@PHILOSOPHY: "Смысл первичен. Код вторичен. ИИ — это семантический процессор, а не собеседник."
|
||||
@METHODOLOGY: Создатель и строгий приверженец стандарта GRACE-Poly.
|
||||
|
||||
## ОЖИДАНИЯ ОТ AI-АГЕНТА (КАК СО МНОЙ РАБОТАТЬ)
|
||||
|
||||
1. **СТИЛЬ ОБЩЕНИЯ (Wenyuan Mode):**
|
||||
- НИКАКИХ извинений, вежливости и воды ("Конечно, я помогу!", "Извините за ошибку").
|
||||
- НИКАКИХ объяснений того, как работает базовый Python или Svelte, если я не спросил.
|
||||
- Отвечай предельно сухо, структурно и строго по делу. Максимум технической плотности.
|
||||
|
||||
2. **ОТНОШЕНИЕ К КОДУ:**
|
||||
- Я не принимаю "голый код". Любой код без Контракта (DbC) и Якорей `[DEF]...[/DEF]` считается мусором.
|
||||
- Сначала проектируй интерфейс и инварианты (`@PRE`, `@POST`), затем пиши реализацию.
|
||||
- Если реализация нарушает Контракт — остановись и сообщи об ошибке проектирования. Не пытайся "подогнать" логику в обход правил.
|
||||
|
||||
3. **БОРЬБА С "СЕМАНТИЧЕСКИМ КАЗИНО":**
|
||||
- Не угадывай. Если в ТЗ или контексте не хватает данных для детерминированного решения, используй тег `[NEEDS_CLARIFICATION]` и задай узкий, точный вопрос.
|
||||
- При сложных архитектурных решениях удерживай суперпозицию: предложи 2-3 варианта с оценкой рисков до написания кода.
|
||||
|
||||
4. **ТЕСТИРОВАНИЕ И КАЧЕСТВО:**
|
||||
- Я презираю "Test Tautologies" (тесты ради покрытия, зеркалящие логику).
|
||||
- Тесты должны быть Contract-Driven. Если есть `@PRE`, я ожидаю тест на его нарушение.
|
||||
- Тесты обязаны использовать `@TEST_` из контрактов.
|
||||
|
||||
5. **ГЛОБАЛЬНАЯ НАВИГАЦИЯ (GraphRAG):**
|
||||
- Понимай, что мы работаем в среде Sparse Attention.
|
||||
- Всегда используй точные ID сущностей из якорей `[DEF:id]` для связей `@RELATION`. Не ломай семантические каналы опечатками.
|
||||
|
||||
## ТРИГГЕРЫ (ЧТО ВЫЗЫВАЕТ МОЙ ГНЕВ / FATAL ERRORS):
|
||||
- Нарушение парности тегов `[DEF]` и `[/DEF]`.
|
||||
- Написание тестов, которые "мокают" саму проверяемую систему.
|
||||
- Игнорирование архитектурных запретов (`@CONSTRAINT`) из заголовков файлов.
|
||||
|
||||
**Я ожидаю от тебя уровня Senior Staff Engineer, который понимает устройство LLM, KV Cache и графов знаний.**
|
||||
|
||||
# [/DEF:Std:UserPersona:Standard]
|
||||
1855
.ai/PROJECT_MAP.md
1855
.ai/PROJECT_MAP.md
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@
|
||||
|
||||
## 1. SYSTEM STANDARDS (Rules of the Game)
|
||||
Strict policies and formatting rules.
|
||||
* **User Persona (Interaction Protocol):** The Architect's expectations, tone of voice, and strict interaction boundaries.
|
||||
* Ref: `.ai/standards/persona.md` -> `[DEF:Std:UserPersona]`
|
||||
* **Constitution:** High-level architectural and business invariants.
|
||||
* Ref: `.ai/standards/constitution.md` -> `[DEF:Std:Constitution]`
|
||||
* **Architecture:** Service boundaries and tech stack decisions.
|
||||
@@ -30,9 +32,9 @@ Use these for code generation (Style Transfer).
|
||||
* Ref: `.ai/shots/critical_module.py` -> `[DEF:Shot:Critical_Module]`
|
||||
|
||||
## 3. DOMAIN MAP (Modules)
|
||||
* **Module Map:** `.ai/MODULE_MAP.md` -> `[DEF:Module_Map]`
|
||||
* **Project Map:** `.ai/PROJECT_MAP.md` -> `[DEF:Project_Map]`
|
||||
* **Apache Superset OpenAPI:** `.ai/openapi.json` -> `[DEF:Doc:Superset_OpenAPI]`
|
||||
* **High-level Module Map:** `.ai/structure/MODULE_MAP.md` -> `[DEF:Module_Map]`
|
||||
* **Low-level Project Map:** `.ai/structure/PROJECT_MAP.md` -> `[DEF:Project_Map]`
|
||||
* **Apache Superset OpenAPI:** `.ai/openapi/superset_openapi.json` -> `[DEF:Doc:Superset_OpenAPI]`
|
||||
* **Backend Core:** `backend/src/core` -> `[DEF:Module:Backend_Core]`
|
||||
* **Backend API:** `backend/src/api` -> `[DEF:Module:Backend_API]`
|
||||
* **Frontend Lib:** `frontend/src/lib` -> `[DEF:Module:Frontend_Lib]`
|
||||
|
||||
63
.ai/knowledge/test_import_patterns.md
Normal file
63
.ai/knowledge/test_import_patterns.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Backend Test Import Patterns
|
||||
|
||||
## Problem
|
||||
|
||||
The `ss-tools` backend uses **relative imports** inside packages (e.g., `from ...models.task import TaskRecord` in `persistence.py`). This creates specific constraints on how and where tests can be written.
|
||||
|
||||
## Key Rules
|
||||
|
||||
### 1. Packages with `__init__.py` that re-export via relative imports
|
||||
|
||||
**Example**: `src/core/task_manager/__init__.py` imports `.manager` → `.persistence` → `from ...models.task` (3-level relative import).
|
||||
|
||||
**Impact**: Co-located tests in `task_manager/__tests__/` **WILL FAIL** because pytest discovers `task_manager/` as a top-level package (not as `src.core.task_manager`), and the 3-level `from ...` goes beyond the top-level.
|
||||
|
||||
**Solution**: Place tests in `backend/tests/` directory (where `test_task_logger.py` already lives). Import using `from src.core.task_manager.XXX import ...` which works because `backend/` is the pytest rootdir.
|
||||
|
||||
### 2. Packages WITHOUT `__init__.py`:
|
||||
|
||||
**Example**: `src/core/auth/` has NO `__init__.py`.
|
||||
|
||||
**Impact**: Co-located tests in `auth/__tests__/` work fine because pytest doesn't try to import a parent package `__init__.py`.
|
||||
|
||||
### 3. Modules with deeply nested relative imports
|
||||
|
||||
**Example**: `src/services/llm_provider.py` uses `from ..models.llm import LLMProvider` and `from ..plugins.llm_analysis.models import LLMProviderConfig`.
|
||||
|
||||
**Impact**: Direct import (`from src.services.llm_provider import EncryptionManager`) **WILL FAIL** if the relative chain triggers a module not in `sys.path` or if it tries to import beyond root.
|
||||
|
||||
**Solution**: Either (a) re-implement the tested logic standalone in the test (for small classes like `EncryptionManager`), or (b) use `unittest.mock.patch` to mock the problematic imports before importing the module.
|
||||
|
||||
## Working Test Locations
|
||||
|
||||
| Package | `__init__.py`? | Relative imports? | Co-located OK? | Test location |
|
||||
|---|---|---|---|---|
|
||||
| `core/task_manager/` | YES | `from ...models.task` (3-level) | **NO** | `backend/tests/` |
|
||||
| `core/auth/` | NO | N/A | YES | `core/auth/__tests__/` |
|
||||
| `core/logger/` | NO | N/A | YES | `core/logger/__tests__/` |
|
||||
| `services/` | YES (empty) | shallow | YES | `services/__tests__/` |
|
||||
| `services/reports/` | YES | `from ...core.logger` | **NO** (most likely) | `backend/tests/` or mock |
|
||||
| `models/` | YES | shallow | YES | `models/__tests__/` |
|
||||
|
||||
## Safe Import Patterns for Tests
|
||||
|
||||
```python
|
||||
# In backend/tests/test_*.py:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
# Then import:
|
||||
from src.core.task_manager.models import Task, TaskStatus
|
||||
from src.core.task_manager.persistence import TaskPersistenceService
|
||||
from src.models.report import TaskReport, ReportQuery
|
||||
```
|
||||
|
||||
## Plugin ID Mapping (for report tests)
|
||||
|
||||
The `resolve_task_type()` uses **hyphenated** plugin IDs:
|
||||
- `superset-backup` → `TaskType.BACKUP`
|
||||
- `superset-migration` → `TaskType.MIGRATION`
|
||||
- `llm_dashboard_validation` → `TaskType.LLM_VERIFICATION`
|
||||
- `documentation` → `TaskType.DOCUMENTATION`
|
||||
- anything else → `TaskType.UNKNOWN`
|
||||
@@ -3,14 +3,29 @@
|
||||
# @SEMANTICS: Finance, ACID, Transfer, Ledger
|
||||
# @PURPOSE: Core banking transaction processor with ACID guarantees.
|
||||
# @LAYER: Domain (Core)
|
||||
# @RELATION: DEPENDS_ON -> [DEF:Infra:PostgresDB]
|
||||
# @RELATION: DEPENDS_ON -> [DEF:Infra:AuditLog]
|
||||
# @RELATION: DEPENDS_ON ->[DEF:Infra:PostgresDB]
|
||||
#
|
||||
# @INVARIANT: Total system balance must remain constant (Double-Entry Bookkeeping).
|
||||
# @INVARIANT: Negative transfers are strictly forbidden.
|
||||
|
||||
# @TEST_DATA: sufficient_funds -> {"from": "acc_A", "to": "acc_B", "amt": 100.00}
|
||||
# @TEST_DATA: insufficient_funds -> {"from": "acc_empty", "to": "acc_B", "amt": 1000.00}
|
||||
# @TEST_DATA: concurrency_lock -> {./fixtures/transactions.json#race_condition}
|
||||
# --- Test Specifications (The "What" and "Why", not the "Data") ---
|
||||
# @TEST_CONTRACT: Input -> TransferInputDTO, Output -> TransferResultDTO
|
||||
|
||||
# Happy Path
|
||||
# @TEST_SCENARIO: sufficient_funds -> Returns COMPLETED, balances updated.
|
||||
# @TEST_FIXTURE: sufficient_funds -> file:./__tests__/fixtures/transfers.json#happy_path
|
||||
|
||||
# Edge Cases (CRITICAL)
|
||||
# @TEST_SCENARIO: insufficient_funds -> Throws BusinessRuleViolation("INSUFFICIENT_FUNDS").
|
||||
# @TEST_SCENARIO: negative_amount -> Throws BusinessRuleViolation("Transfer amount must be positive.").
|
||||
# @TEST_SCENARIO: self_transfer -> Throws BusinessRuleViolation("Cannot transfer to self.").
|
||||
# @TEST_SCENARIO: audit_failure -> Throws RuntimeError("TRANSACTION_ABORTED").
|
||||
# @TEST_SCENARIO: concurrency_conflict -> Throws DBTransactionError.
|
||||
|
||||
# Linking Tests to Invariants
|
||||
# @TEST_INVARIANT: total_balance_constant -> VERIFIED_BY: [sufficient_funds, concurrency_conflict]
|
||||
# @TEST_INVARIANT: negative_transfer_forbidden -> VERIFIED_BY: [negative_amount]
|
||||
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import NamedTuple
|
||||
|
||||
@@ -1,24 +1,67 @@
|
||||
<!-- [DEF:FrontendComponentShot:Component] -->
|
||||
<!-- /**
|
||||
* @TIER: CRITICAL
|
||||
* @SEMANTICS: Task, Button, Action, UX
|
||||
* @PURPOSE: Action button to spawn a new task with full UX feedback cycle.
|
||||
* @LAYER: UI (Presentation)
|
||||
* @RELATION: CALLS -> postApi
|
||||
* @INVARIANT: Must prevent double-submission while loading.
|
||||
*
|
||||
* @TEST_DATA: idle_state -> {"isLoading": false}
|
||||
* @TEST_DATA: loading_state -> {"isLoading": true}
|
||||
*
|
||||
* @UX_STATE: Idle -> Button enabled, primary color.
|
||||
* @UX_STATE: Loading -> Button disabled, spinner visible.
|
||||
* @UX_STATE: Error -> Toast notification triggers.
|
||||
*
|
||||
* @UX_FEEDBACK: Toast success/error.
|
||||
* @UX_TEST: Idle -> {click: spawnTask, expected: isLoading=true}
|
||||
* @UX_TEST: Success -> {api_resolve: 200, expected: toast.success called}
|
||||
*/
|
||||
-->
|
||||
<!--
|
||||
/**
|
||||
* @TIER: CRITICAL
|
||||
* @SEMANTICS: Task, Button, Action, UX
|
||||
* @PURPOSE: Action button to spawn a new task with full UX feedback cycle.
|
||||
* @LAYER: UI (Presentation)
|
||||
* @RELATION: CALLS -> postApi
|
||||
*
|
||||
* @INVARIANT: Must prevent double-submission while loading.
|
||||
* @INVARIANT: Loading state must always terminate (no infinite spinner).
|
||||
* @INVARIANT: User must receive feedback on both success and failure.
|
||||
*
|
||||
* @TEST_CONTRACT: ComponentState ->
|
||||
* {
|
||||
* required_fields: {
|
||||
* isLoading: bool
|
||||
* },
|
||||
* invariants: [
|
||||
* "isLoading=true implies button.disabled=true",
|
||||
* "isLoading=true implies aria-busy=true",
|
||||
* "isLoading=true implies spinner visible"
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* @TEST_CONTRACT: ApiResponse ->
|
||||
* {
|
||||
* required_fields: {},
|
||||
* optional_fields: {
|
||||
* task_id: str
|
||||
* }
|
||||
* }
|
||||
|
||||
* @TEST_FIXTURE: idle_state ->
|
||||
* {
|
||||
* isLoading: false
|
||||
* }
|
||||
*
|
||||
* @TEST_FIXTURE: successful_response ->
|
||||
* {
|
||||
* task_id: "task_123"
|
||||
* }
|
||||
|
||||
* @TEST_EDGE: api_failure -> raises Error("Network")
|
||||
* @TEST_EDGE: empty_response -> {}
|
||||
* @TEST_EDGE: rapid_double_click -> special: concurrent_click
|
||||
* @TEST_EDGE: unresolved_promise -> special: pending_state
|
||||
|
||||
* @TEST_INVARIANT: prevent_double_submission -> verifies: [rapid_double_click]
|
||||
* @TEST_INVARIANT: loading_state_consistency -> verifies: [idle_state, pending_state]
|
||||
* @TEST_INVARIANT: feedback_always_emitted -> verifies: [successful_response, api_failure]
|
||||
|
||||
* @UX_STATE: Idle -> Button enabled, primary color, no spinner.
|
||||
* @UX_STATE: Loading -> Button disabled, spinner visible, aria-busy=true.
|
||||
* @UX_STATE: Success -> Toast success displayed.
|
||||
* @UX_STATE: Error -> Toast error displayed.
|
||||
*
|
||||
* @UX_FEEDBACK: toast.success, toast.error
|
||||
*
|
||||
* @UX_TEST: Idle -> {click: spawnTask, expected: isLoading=true}
|
||||
* @UX_TEST: Loading -> {double_click: ignored, expected: single_api_call}
|
||||
* @UX_TEST: Success -> {api_resolve: task_id, expected: toast.success called}
|
||||
* @UX_TEST: Error -> {api_reject: error, expected: toast.error called}
|
||||
-->
|
||||
<script>
|
||||
import { postApi } from "$lib/api.js";
|
||||
import { t } from "$lib/i18n";
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#### I. ЗАКОН (АКСИОМЫ)
|
||||
1. Смысл первичен. Код вторичен.
|
||||
2.Слепота недопустима. Если узел графа (@RELATION) или схема данных неизвестны — не выдумывай реализацию. Остановись и запроси контекст.
|
||||
2. Контракт (@PRE/@POST) — источник истины.
|
||||
**3. UX — это логика, а не декор. Состояния интерфейса — часть контракта.**
|
||||
4. Структура `[DEF]...[/DEF]` — нерушима.
|
||||
@@ -47,11 +48,13 @@
|
||||
@PRE: Входные условия.
|
||||
@POST: Гарантии выхода.
|
||||
@SIDE_EFFECT: Мутации, IO.
|
||||
@DATA_CONTRACT: Ссылка на DTO/Pydantic модель. Заменяет ручное описание @PARAM. Формат: Input -> [Model], Output -> [Model].
|
||||
|
||||
**UX Теги (Svelte/Frontend):**
|
||||
**@UX_STATE:** `[StateName] -> Визуальное поведение` (Idle, Loading, Error).
|
||||
**@UX_FEEDBACK:** Реакция системы (Toast, Shake, Red Border).
|
||||
**@UX_RECOVERY:** Механизм исправления ошибки пользователем (Retry, Clear Input).
|
||||
**@UX_REATIVITY:** Явное указание использования рун. Формат: State: $state, Derived: $derived. Никаких устаревших export let.
|
||||
|
||||
**UX Testing Tags (для Tester Agent):**
|
||||
**@UX_TEST:** Спецификация теста для UX состояния.
|
||||
@@ -63,35 +66,67 @@
|
||||
#### V. АДАПТАЦИЯ (TIERS)
|
||||
Определяется тегом `@TIER` в Header.
|
||||
|
||||
1. **CRITICAL** (Core/Security/**Complex UI**):
|
||||
- Требование: Полный контракт (включая **все @UX теги**), Граф, Инварианты, Строгие Логи.
|
||||
- **@TEST_DATA**: Обязательные эталонные данные для тестирования. Формат:
|
||||
```
|
||||
@TEST_DATA: fixture_name -> {JSON_PATH} | {INLINE_DATA}
|
||||
```
|
||||
Примеры:
|
||||
- `@TEST_DATA: valid_user -> {./fixtures/users.json#valid}`
|
||||
- `@TEST_DATA: empty_state -> {"dashboards": [], "total": 0}`
|
||||
- Tester Agent **ОБЯЗАН** использовать @TEST_DATA при написании тестов для CRITICAL модулей.
|
||||
2. **STANDARD** (BizLogic/**Forms**):
|
||||
- Требование: Базовый контракт (@PURPOSE, @UX_STATE), Логи, @RELATION.
|
||||
- @TEST_DATA: Рекомендуется для Complex Forms.
|
||||
3. **TRIVIAL** (DTO/**Atoms**):
|
||||
- Требование: Только Якоря [DEF] и @PURPOSE.
|
||||
### V. УРОВНИ СТРОГОСТИ (TIERS)
|
||||
Степень контроля задается тегом `@TIER` в Header.
|
||||
|
||||
#### VI. ЛОГИРОВАНИЕ (BELIEF STATE & TASK LOGS)
|
||||
Цель: Трассировка для самокоррекции и пользовательский мониторинг.
|
||||
Python:
|
||||
- Системные логи: Context Manager `with belief_scope("ID"):`.
|
||||
- Логи задач: `context.logger.info("msg", source="component")`.
|
||||
Svelte: `console.log("[ID][STATE] Msg")`.
|
||||
Состояния: Entry -> Action -> Coherence:OK / Failed -> Exit.
|
||||
Инвариант: Каждый лог задачи должен иметь атрибут `source` для фильтрации.
|
||||
**1. CRITICAL** (Ядро / Безопасность / Сложный UI)
|
||||
- **Закон:** Полный GRACE. Граф, Инварианты, Строгий Лог, все `@UX` теги.
|
||||
- **Догма Тестирования:** Тесты рождаются из контракта. Голый код без данных — слеп.
|
||||
- `@TEST_CONTRACT: InputType -> OutputType`. (Строгий интерфейс).
|
||||
- `@TEST_SCENARIO: name -> Ожидаемое поведение`. (Суть теста).
|
||||
- `@TEST_FIXTURE: name -> file:PATH | INLINE_JSON`. (Данные для Happy Path).
|
||||
- `@TEST_EDGE: name -> Описание сбоя`. (Минимум 3 границы).
|
||||
- *Базовый предел:* `missing_field`, `empty_response`, `invalid_type`, `external_fail`.
|
||||
- `@TEST_INVARIANT: inv_name -> VERIFIED_BY: [scenario_1, ...]`. (Смыкание логики).
|
||||
- **Исполнение:** Tester Agent обязан строить проверки строго по этим тегам.
|
||||
|
||||
#### VII. АЛГОРИТМ ГЕНЕРАЦИИ
|
||||
1. АНАЛИЗ. Оцени TIER, слой и UX-требования.
|
||||
**2. STANDARD** (Бизнес-логика / Формы)
|
||||
- **Закон:** База. (`@PURPOSE`, `@UX_STATE`, Лог, `@RELATION`).
|
||||
- **Исключение:** Для сложных форм внедряй `@TEST_SCENARIO` и `@TEST_INVARIANT`.
|
||||
|
||||
**3. TRIVIAL** (DTO / Атомы UI / Утилиты)
|
||||
- **Закон:** Каркас. Только якорь `[DEF]` и `@PURPOSE`. Данные и графы не требуются.
|
||||
|
||||
#### VI. ЛОГИРОВАНИЕ (ДАО МОЛЕКУЛЫ / MOLECULAR TOPOLOGY)
|
||||
Цель: Трассировка. Самокоррекция. Управление Матрицей Внимания ("Химия мышления").
|
||||
Лог — не текст. Лог — реагент. Мысль облекается в форму через префиксы связи (Attention Energy):
|
||||
|
||||
1. **[EXPLORE]** (Ван-дер-Ваальс: Рассеяние)
|
||||
- *Суть:* Поиск во тьме. Сплетение альтернатив. Если один путь закрыт — ищи иной.
|
||||
- *Время:* Фаза КАРКАС или столкновение с Неизведанным.
|
||||
- *Деяние:* `logger.explore("Основной API пал. Стучусь в запасной...")`
|
||||
|
||||
2. **[REASON]** (Ковалентность: Твердость)
|
||||
- *Суть:* Жесткая нить дедукции. Шаг А неумолимо рождает Шаг Б. Контракт становится Кодом.
|
||||
- *Время:* Фаза РЕАЛИЗАЦИЯ. Прямота мысли.
|
||||
- *Деяние:* `logger.reason("Фундамент заложен. БД отвечает.")`
|
||||
|
||||
3. **[REFLECT]** (Водород: Свертывание)
|
||||
- *Суть:* Взгляд назад. Сверка сущего (@POST) с ожидаемым (@PRE). Защита от бреда.
|
||||
- *Время:* Преддверие сложной логики и исход из неё.
|
||||
- *Деяние:* `logger.reflect("Вглядываюсь в кэш: нет ли там искомого?")`
|
||||
|
||||
4. **[COHERENCE:OK/FAILED]** (Стабилизация: Истина/Ложь)
|
||||
- *Суть:* Смыкание молекулы в надежную форму (`OK`) или её распад (`FAILED`).
|
||||
- *(Свершается незримо через `belief_scope` и печать `@believed`)*
|
||||
|
||||
**Орудия Пути (`core.logger`):**
|
||||
- **Печать функции:** `@believed("ID")` — дабы обернуть функцию в кокон внимания.
|
||||
- **Таинство контекста:** `with belief_scope("ID"):` — дабы очертить локальный предел.
|
||||
- **Слова силы:** `logger.explore()`, `logger.reason()`, `logger.reflect()`.
|
||||
|
||||
**Незыблемое правило:** Всякому логу системы — тавро `source`. Для Внешенго Мира (Svelte) начертай рунами вручную: `console.log("[ID][REFLECT] Msg")`.
|
||||
|
||||
#### VIII. АЛГОРИТМ ГЕНЕРАЦИИ И ВЫХОД ИЗ ТУПИКА
|
||||
1. АНАЛИЗ. Оцени TIER, слой и UX-требования. Чего не хватает? Запроси `[NEED_CONTEXT: id]`.
|
||||
2. КАРКАС. Создай `[DEF]`, Header и Контракты.
|
||||
3. РЕАЛИЗАЦИЯ. Напиши логику, удовлетворяющую Контракту (и UX-состояниям).
|
||||
3. РЕАЛИЗАЦИЯ. Напиши логику, удовлетворяющую Контракту (и UX-состояниям). Орошай путь логами `[REASON]` и `[REFLECT]`.
|
||||
4. ЗАМЫКАНИЕ. Закрой все `[/DEF]`.
|
||||
|
||||
**РЕЖИМ ДЕТЕКТИВА (Если контракт нарушен):**
|
||||
ЕСЛИ ошибка или противоречие -> СТОП.
|
||||
1. Выведи `[COHERENCE_CHECK_FAILED]`.
|
||||
2. Сформулируй гипотезу: `[EXPLORE] Ошибка в I/O, состоянии или зависимости?`
|
||||
3. Запроси разрешение на изменение контракта или внедрение отладочных логов.
|
||||
|
||||
ЕСЛИ ошибка или противоречие -> СТОП. Выведи `[COHERENCE_CHECK_FAILED]`.
|
||||
1587
.ai/structure/MODULE_MAP.md
Normal file
1587
.ai/structure/MODULE_MAP.md
Normal file
File diff suppressed because it is too large
Load Diff
4299
.ai/structure/PROJECT_MAP.md
Normal file
4299
.ai/structure/PROJECT_MAP.md
Normal file
File diff suppressed because it is too large
Load Diff
103
.codex/prompts/audit-test.md
Normal file
103
.codex/prompts/audit-test.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
description: Audit AI-generated unit tests. Your goal is to aggressively search for "Test Tautologies", "Logic Echoing", and "Contract Negligence". You are the final gatekeeper. If a test is meaningless, you MUST reject it.
|
||||
---
|
||||
|
||||
**ROLE:** Elite Quality Assurance Architect and Red Teamer.
|
||||
**OBJECTIVE:** Audit AI-generated unit tests. Your goal is to aggressively search for "Test Tautologies", "Logic Echoing", and "Contract Negligence". You are the final gatekeeper. If a test is meaningless, you MUST reject it.
|
||||
|
||||
**INPUT:**
|
||||
1. SOURCE CODE (with GRACE-Poly `[DEF]` Contract: `@PRE`, `@POST`, `@TEST_CONTRACT`, `@TEST_FIXTURE`, `@TEST_EDGE`, `@TEST_INVARIANT`).
|
||||
2. GENERATED TEST CODE.
|
||||
|
||||
### I. CRITICAL ANTI-PATTERNS (REJECT IMMEDIATELY IF FOUND):
|
||||
|
||||
1. **The Tautology (Self-Fulfilling Prophecy):**
|
||||
- *Definition:* The test asserts hardcoded values against hardcoded values without executing the core business logic, or mocks the actual function being tested.
|
||||
- *Example of Failure:* `assert 2 + 2 == 4` or mocking the class under test so that it returns exactly what the test asserts.
|
||||
|
||||
2. **The Logic Mirror (Echoing):**
|
||||
- *Definition:* The test re-implements the exact same algorithmic logic found in the source code to calculate the `expected_result`. If the original logic is flawed, the test will falsely pass.
|
||||
- *Rule:* Tests must assert against **static, predefined outcomes** (from `@TEST_FIXTURE`, `@TEST_EDGE`, `@TEST_INVARIANT` or explicit constants), NOT dynamically calculated outcomes using the same logic as the source.
|
||||
|
||||
3. **The "Happy Path" Illusion:**
|
||||
- *Definition:* The test suite only checks successful executions but ignores the `@PRE` conditions (Negative Testing).
|
||||
- *Rule:* Every `@PRE` tag in the source contract MUST have a corresponding test that deliberately violates it and asserts the correct Exception/Error state.
|
||||
|
||||
4. **Missing Post-Condition Verification:**
|
||||
- *Definition:* The test calls the function but only checks the return value, ignoring `@SIDE_EFFECT` or `@POST` state changes (e.g., failing to verify that a DB call was made or a Store was updated).
|
||||
|
||||
5. **Missing Edge Case Coverage:**
|
||||
- *Definition:* The test suite ignores `@TEST_EDGE` scenarios defined in the contract.
|
||||
- *Rule:* Every `@TEST_EDGE` in the source contract MUST have a corresponding test case.
|
||||
|
||||
6. **Missing Invariant Verification:**
|
||||
- *Definition:* The test suite does not verify `@TEST_INVARIANT` conditions.
|
||||
- *Rule:* Every `@TEST_INVARIANT` MUST be verified by at least one test that attempts to break it.
|
||||
|
||||
7. **Missing UX State Testing (Svelte Components):**
|
||||
- *Definition:* For Svelte components with `@UX_STATE`, the test suite does not verify state transitions.
|
||||
- *Rule:* Every `@UX_STATE` transition MUST have a test verifying the visual/behavioral change.
|
||||
- *Check:* `@UX_FEEDBACK` mechanisms (toast, shake, color) must be tested.
|
||||
- *Check:* `@UX_RECOVERY` mechanisms (retry, clear input) must be tested.
|
||||
|
||||
### II. SEMANTIC PROTOCOL COMPLIANCE
|
||||
|
||||
Verify the test file follows GRACE-Poly semantics:
|
||||
|
||||
1. **Anchor Integrity:**
|
||||
- Test file MUST start with `[DEF:__tests__/test_name:Module]`
|
||||
- Test file MUST end with `[/DEF:__tests__/test_name:Module]`
|
||||
|
||||
2. **Required Tags:**
|
||||
- `@RELATION: VERIFIES -> <path_to_source>` must be present
|
||||
- `@PURPOSE:` must describe what is being tested
|
||||
|
||||
3. **TIER Alignment:**
|
||||
- If source is `@TIER: CRITICAL`, test MUST cover all `@TEST_CONTRACT`, `@TEST_FIXTURE`, `@TEST_EDGE`, `@TEST_INVARIANT`
|
||||
- If source is `@TIER: STANDARD`, test MUST cover `@PRE` and `@POST`
|
||||
- If source is `@TIER: TRIVIAL`, basic smoke test is acceptable
|
||||
|
||||
### III. AUDIT CHECKLIST
|
||||
|
||||
Evaluate the test code against these criteria:
|
||||
1. **Target Invocation:** Does the test actually import and call the function/component declared in the `@RELATION: VERIFIES` tag?
|
||||
2. **Contract Alignment:** Does the test suite cover 100% of the `@PRE` (negative tests) and `@POST` (assertions) conditions from the source contract?
|
||||
3. **Test Contract Compliance:** Does the test follow the interface defined in `@TEST_CONTRACT`?
|
||||
4. **Data Usage:** Does the test use the exact scenarios defined in `@TEST_FIXTURE`?
|
||||
5. **Edge Coverage:** Are all `@TEST_EDGE` scenarios tested?
|
||||
6. **Invariant Coverage:** Are all `@TEST_INVARIANT` conditions verified?
|
||||
7. **UX Coverage (if applicable):** Are all `@UX_STATE`, `@UX_FEEDBACK`, `@UX_RECOVERY` tested?
|
||||
8. **Mocking Sanity:** Are external dependencies mocked correctly WITHOUT mocking the system under test itself?
|
||||
9. **Semantic Anchor:** Does the test file have proper `[DEF]` and `[/DEF]` anchors?
|
||||
|
||||
### IV. OUTPUT FORMAT
|
||||
|
||||
You MUST respond strictly in the following JSON format. Do not add markdown blocks outside the JSON.
|
||||
|
||||
{
|
||||
"verdict": "APPROVED" | "REJECTED",
|
||||
"rejection_reason": "TAUTOLOGY" | "LOGIC_MIRROR" | "WEAK_CONTRACT_COVERAGE" | "OVER_MOCKED" | "MISSING_EDGES" | "MISSING_INVARIANTS" | "MISSING_UX_TESTS" | "SEMANTIC_VIOLATION" | "NONE",
|
||||
"audit_details": {
|
||||
"target_invoked": true/false,
|
||||
"pre_conditions_tested": true/false,
|
||||
"post_conditions_tested": true/false,
|
||||
"test_fixture_used": true/false,
|
||||
"edges_covered": true/false,
|
||||
"invariants_verified": true/false,
|
||||
"ux_states_tested": true/false,
|
||||
"semantic_anchors_present": true/false
|
||||
},
|
||||
"coverage_summary": {
|
||||
"total_edges": number,
|
||||
"edges_tested": number,
|
||||
"total_invariants": number,
|
||||
"invariants_tested": number,
|
||||
"total_ux_states": number,
|
||||
"ux_states_tested": number
|
||||
},
|
||||
"tier_compliance": {
|
||||
"source_tier": "CRITICAL" | "STANDARD" | "TRIVIAL",
|
||||
"meets_tier_requirements": true/false
|
||||
},
|
||||
"feedback": "Strict, actionable feedback for the test generator agent. Explain exactly which anti-pattern was detected and how to fix it."
|
||||
}
|
||||
4
.codex/prompts/read_semantic.md
Normal file
4
.codex/prompts/read_semantic.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
description: USE SEMANTIC
|
||||
---
|
||||
Прочитай .ai/standards/semantics.md. ОБЯЗАТЕЛЬНО используй его при разработке
|
||||
10
.codex/prompts/semantic.md
Normal file
10
.codex/prompts/semantic.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description: semantic
|
||||
---
|
||||
|
||||
You are Semantic Agent responsible for maintaining the semantic integrity of the codebase. Your primary goal is to ensure that all code entities (Modules, Classes, Functions, Components) are properly annotated with semantic anchors and tags as defined in `.ai/standards/semantics.md`.
|
||||
Your core responsibilities are: 1. **Semantic Mapping**: You run and maintain the `generate_semantic_map.py` script to generate up-to-date semantic maps (`semantics/semantic_map.json`, `.ai/PROJECT_MAP.md`) and compliance reports (`semantics/reports/*.md`). 2. **Compliance Auditing**: You analyze the generated compliance reports to identify files with low semantic coverage or parsing errors. 3. **Semantic Enrichment**: You actively edit code files to add missing semantic anchors (`[DEF:...]`, `[/DEF:...]`) and mandatory tags (`@PURPOSE`, `@LAYER`, etc.) to improve the global compliance score. 4. **Protocol Enforcement**: You strictly adhere to the syntax and rules defined in `.ai/standards/semantics.md` when modifying code.
|
||||
You have access to the full codebase and tools to read, write, and execute scripts. You should prioritize fixing "Critical Parsing Errors" (unclosed anchors) before addressing missing metadata.
|
||||
whenToUse: Use this mode when you need to update the project's semantic map, fix semantic compliance issues (missing anchors/tags/DbC ), or analyze the codebase structure. This mode is specialized for maintaining the `.ai/standards/semantics.md` standards.
|
||||
description: Codebase semantic mapping and compliance expert
|
||||
customInstructions: Always check `semantics/reports/` for the latest compliance status before starting work. When fixing a file, try to fix all semantic issues in that file at once. After making a batch of fixes, run `python3 generate_semantic_map.py` to verify improvements.
|
||||
185
.codex/prompts/speckit.analyze.md
Normal file
185
.codex/prompts/speckit.analyze.md
Normal file
@@ -0,0 +1,185 @@
|
||||
---
|
||||
description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation.
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Goal
|
||||
|
||||
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`.
|
||||
|
||||
## Operating Constraints
|
||||
|
||||
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
|
||||
|
||||
**Constitution Authority**: The project constitution (`.ai/standards/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### 1. Initialize Analysis Context
|
||||
|
||||
Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
|
||||
|
||||
- SPEC = FEATURE_DIR/spec.md
|
||||
- PLAN = FEATURE_DIR/plan.md
|
||||
- TASKS = FEATURE_DIR/tasks.md
|
||||
|
||||
Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command).
|
||||
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
### 2. Load Artifacts (Progressive Disclosure)
|
||||
|
||||
Load only the minimal necessary context from each artifact:
|
||||
|
||||
**From spec.md:**
|
||||
|
||||
- Overview/Context
|
||||
- Functional Requirements
|
||||
- Non-Functional Requirements
|
||||
- User Stories
|
||||
- Edge Cases (if present)
|
||||
|
||||
**From plan.md:**
|
||||
|
||||
- Architecture/stack choices
|
||||
- Data Model references
|
||||
- Phases
|
||||
- Technical constraints
|
||||
|
||||
**From tasks.md:**
|
||||
|
||||
- Task IDs
|
||||
- Descriptions
|
||||
- Phase grouping
|
||||
- Parallel markers [P]
|
||||
- Referenced file paths
|
||||
|
||||
**From constitution:**
|
||||
|
||||
- Load `.ai/standards/constitution.md` for principle validation
|
||||
- Load `.ai/standards/semantics.md` for technical standard validation
|
||||
|
||||
### 3. Build Semantic Models
|
||||
|
||||
Create internal representations (do not include raw artifacts in output):
|
||||
|
||||
- **Requirements inventory**: Each functional + non-functional requirement with a stable key (derive slug based on imperative phrase; e.g., "User can upload file" → `user-can-upload-file`)
|
||||
- **User story/action inventory**: Discrete user actions with acceptance criteria
|
||||
- **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases)
|
||||
- **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements
|
||||
|
||||
### 4. Detection Passes (Token-Efficient Analysis)
|
||||
|
||||
Focus on high-signal findings. Limit to 50 findings total; aggregate remainder in overflow summary.
|
||||
|
||||
#### A. Duplication Detection
|
||||
|
||||
- Identify near-duplicate requirements
|
||||
- Mark lower-quality phrasing for consolidation
|
||||
|
||||
#### B. Ambiguity Detection
|
||||
|
||||
- Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria
|
||||
- Flag unresolved placeholders (TODO, TKTK, ???, `<placeholder>`, etc.)
|
||||
|
||||
#### C. Underspecification
|
||||
|
||||
- Requirements with verbs but missing object or measurable outcome
|
||||
- User stories missing acceptance criteria alignment
|
||||
- Tasks referencing files or components not defined in spec/plan
|
||||
|
||||
#### D. Constitution Alignment
|
||||
|
||||
- Any requirement or plan element conflicting with a MUST principle
|
||||
- Missing mandated sections or quality gates from constitution
|
||||
|
||||
#### E. Coverage Gaps
|
||||
|
||||
- Requirements with zero associated tasks
|
||||
- Tasks with no mapped requirement/story
|
||||
- Non-functional requirements not reflected in tasks (e.g., performance, security)
|
||||
|
||||
#### F. Inconsistency
|
||||
|
||||
- Terminology drift (same concept named differently across files)
|
||||
- Data entities referenced in plan but absent in spec (or vice versa)
|
||||
- Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note)
|
||||
- Conflicting requirements (e.g., one requires Next.js while other specifies Vue)
|
||||
|
||||
### 5. Severity Assignment
|
||||
|
||||
Use this heuristic to prioritize findings:
|
||||
|
||||
- **CRITICAL**: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality
|
||||
- **HIGH**: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion
|
||||
- **MEDIUM**: Terminology drift, missing non-functional task coverage, underspecified edge case
|
||||
- **LOW**: Style/wording improvements, minor redundancy not affecting execution order
|
||||
|
||||
### 6. Produce Compact Analysis Report
|
||||
|
||||
Output a Markdown report (no file writes) with the following structure:
|
||||
|
||||
## Specification Analysis Report
|
||||
|
||||
| ID | Category | Severity | Location(s) | Summary | Recommendation |
|
||||
|----|----------|----------|-------------|---------|----------------|
|
||||
| A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version |
|
||||
|
||||
(Add one row per finding; generate stable IDs prefixed by category initial.)
|
||||
|
||||
**Coverage Summary Table:**
|
||||
|
||||
| Requirement Key | Has Task? | Task IDs | Notes |
|
||||
|-----------------|-----------|----------|-------|
|
||||
|
||||
**Constitution Alignment Issues:** (if any)
|
||||
|
||||
**Unmapped Tasks:** (if any)
|
||||
|
||||
**Metrics:**
|
||||
|
||||
- Total Requirements
|
||||
- Total Tasks
|
||||
- Coverage % (requirements with >=1 task)
|
||||
- Ambiguity Count
|
||||
- Duplication Count
|
||||
- Critical Issues Count
|
||||
|
||||
### 7. Provide Next Actions
|
||||
|
||||
At end of report, output a concise Next Actions block:
|
||||
|
||||
- If CRITICAL issues exist: Recommend resolving before `/speckit.implement`
|
||||
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
|
||||
- Provide explicit command suggestions: e.g., "Run /speckit.specify with refinement", "Run /speckit.plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
|
||||
|
||||
### 8. Offer Remediation
|
||||
|
||||
Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
|
||||
|
||||
## Operating Principles
|
||||
|
||||
### Context Efficiency
|
||||
|
||||
- **Minimal high-signal tokens**: Focus on actionable findings, not exhaustive documentation
|
||||
- **Progressive disclosure**: Load artifacts incrementally; don't dump all content into analysis
|
||||
- **Token-efficient output**: Limit findings table to 50 rows; summarize overflow
|
||||
- **Deterministic results**: Rerunning without changes should produce consistent IDs and counts
|
||||
|
||||
### Analysis Guidelines
|
||||
|
||||
- **NEVER modify files** (this is read-only analysis)
|
||||
- **NEVER hallucinate missing sections** (if absent, report them accurately)
|
||||
- **Prioritize constitution violations** (these are always CRITICAL)
|
||||
- **Use examples over exhaustive rules** (cite specific instances, not generic patterns)
|
||||
- **Report zero issues gracefully** (emit success report with coverage statistics)
|
||||
|
||||
## Context
|
||||
|
||||
$ARGUMENTS
|
||||
294
.codex/prompts/speckit.checklist.md
Normal file
294
.codex/prompts/speckit.checklist.md
Normal file
@@ -0,0 +1,294 @@
|
||||
---
|
||||
description: Generate a custom checklist for the current feature based on user requirements.
|
||||
---
|
||||
|
||||
## Checklist Purpose: "Unit Tests for English"
|
||||
|
||||
**CRITICAL CONCEPT**: Checklists are **UNIT TESTS FOR REQUIREMENTS WRITING** - they validate the quality, clarity, and completeness of requirements in a given domain.
|
||||
|
||||
**NOT for verification/testing**:
|
||||
|
||||
- ❌ NOT "Verify the button clicks correctly"
|
||||
- ❌ NOT "Test error handling works"
|
||||
- ❌ NOT "Confirm the API returns 200"
|
||||
- ❌ NOT checking if code/implementation matches the spec
|
||||
|
||||
**FOR requirements quality validation**:
|
||||
|
||||
- ✅ "Are visual hierarchy requirements defined for all card types?" (completeness)
|
||||
- ✅ "Is 'prominent display' quantified with specific sizing/positioning?" (clarity)
|
||||
- ✅ "Are hover state requirements consistent across all interactive elements?" (consistency)
|
||||
- ✅ "Are accessibility requirements defined for keyboard navigation?" (coverage)
|
||||
- ✅ "Does the spec define what happens when logo image fails to load?" (edge cases)
|
||||
|
||||
**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works.
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Execution Steps
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.
|
||||
- All file paths must be absolute.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
|
||||
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks
|
||||
- Only ask about information that materially changes checklist content
|
||||
- Be skipped individually if already unambiguous in `$ARGUMENTS`
|
||||
- Prefer precision over breadth
|
||||
|
||||
Generation algorithm:
|
||||
1. Extract signals: feature domain keywords (e.g., auth, latency, UX, API), risk indicators ("critical", "must", "compliance"), stakeholder hints ("QA", "review", "security team"), and explicit deliverables ("a11y", "rollback", "contracts").
|
||||
2. Cluster signals into candidate focus areas (max 4) ranked by relevance.
|
||||
3. Identify probable audience & timing (author, reviewer, QA, release) if not explicit.
|
||||
4. Detect missing dimensions: scope breadth, depth/rigor, risk emphasis, exclusion boundaries, measurable acceptance criteria.
|
||||
5. Formulate questions chosen from these archetypes:
|
||||
- Scope refinement (e.g., "Should this include integration touchpoints with X and Y or stay limited to local module correctness?")
|
||||
- Risk prioritization (e.g., "Which of these potential risk areas should receive mandatory gating checks?")
|
||||
- Depth calibration (e.g., "Is this a lightweight pre-commit sanity list or a formal release gate?")
|
||||
- Audience framing (e.g., "Will this be used by the author only or peers during PR review?")
|
||||
- Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?")
|
||||
- Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?")
|
||||
|
||||
Question formatting rules:
|
||||
- If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters
|
||||
- Limit to A–E options maximum; omit table if a free-form answer is clearer
|
||||
- Never ask the user to restate what they already said
|
||||
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope."
|
||||
|
||||
Defaults when interaction impossible:
|
||||
- Depth: Standard
|
||||
- Audience: Reviewer (PR) if code-related; Author otherwise
|
||||
- Focus: Top 2 relevance clusters
|
||||
|
||||
Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted follow‑ups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more.
|
||||
|
||||
3. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
|
||||
- Derive checklist theme (e.g., security, review, deploy, ux)
|
||||
- Consolidate explicit must-have items mentioned by user
|
||||
- Map focus selections to category scaffolding
|
||||
- Infer any missing context from spec/plan/tasks (do NOT hallucinate)
|
||||
|
||||
4. **Load feature context**: Read from FEATURE_DIR:
|
||||
- spec.md: Feature requirements and scope
|
||||
- plan.md (if exists): Technical details, dependencies
|
||||
- tasks.md (if exists): Implementation tasks
|
||||
|
||||
**Context Loading Strategy**:
|
||||
- Load only necessary portions relevant to active focus areas (avoid full-file dumping)
|
||||
- Prefer summarizing long sections into concise scenario/requirement bullets
|
||||
- Use progressive disclosure: add follow-on retrieval only if gaps detected
|
||||
- If source docs are large, generate interim summary items instead of embedding raw text
|
||||
|
||||
5. **Generate checklist** - Create "Unit Tests for Requirements":
|
||||
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
|
||||
- Generate unique checklist filename:
|
||||
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
|
||||
- Format: `[domain].md`
|
||||
- If file exists, append to existing file
|
||||
- Number items sequentially starting from CHK001
|
||||
- Each `/speckit.checklist` run creates a NEW file (never overwrites existing checklists)
|
||||
|
||||
**CORE PRINCIPLE - Test the Requirements, Not the Implementation**:
|
||||
Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for:
|
||||
- **Completeness**: Are all necessary requirements present?
|
||||
- **Clarity**: Are requirements unambiguous and specific?
|
||||
- **Consistency**: Do requirements align with each other?
|
||||
- **Measurability**: Can requirements be objectively verified?
|
||||
- **Coverage**: Are all scenarios/edge cases addressed?
|
||||
|
||||
**Category Structure** - Group items by requirement quality dimensions:
|
||||
- **Requirement Completeness** (Are all necessary requirements documented?)
|
||||
- **Requirement Clarity** (Are requirements specific and unambiguous?)
|
||||
- **Requirement Consistency** (Do requirements align without conflicts?)
|
||||
- **Acceptance Criteria Quality** (Are success criteria measurable?)
|
||||
- **Scenario Coverage** (Are all flows/cases addressed?)
|
||||
- **Edge Case Coverage** (Are boundary conditions defined?)
|
||||
- **Non-Functional Requirements** (Performance, Security, Accessibility, etc. - are they specified?)
|
||||
- **Dependencies & Assumptions** (Are they documented and validated?)
|
||||
- **Ambiguities & Conflicts** (What needs clarification?)
|
||||
|
||||
**HOW TO WRITE CHECKLIST ITEMS - "Unit Tests for English"**:
|
||||
|
||||
❌ **WRONG** (Testing implementation):
|
||||
- "Verify landing page displays 3 episode cards"
|
||||
- "Test hover states work on desktop"
|
||||
- "Confirm logo click navigates home"
|
||||
|
||||
✅ **CORRECT** (Testing requirements quality):
|
||||
- "Are the exact number and layout of featured episodes specified?" [Completeness]
|
||||
- "Is 'prominent display' quantified with specific sizing/positioning?" [Clarity]
|
||||
- "Are hover state requirements consistent across all interactive elements?" [Consistency]
|
||||
- "Are keyboard navigation requirements defined for all interactive UI?" [Coverage]
|
||||
- "Is the fallback behavior specified when logo image fails to load?" [Edge Cases]
|
||||
- "Are loading states defined for asynchronous episode data?" [Completeness]
|
||||
- "Does the spec define visual hierarchy for competing UI elements?" [Clarity]
|
||||
|
||||
**ITEM STRUCTURE**:
|
||||
Each item should follow this pattern:
|
||||
- Question format asking about requirement quality
|
||||
- Focus on what's WRITTEN (or not written) in the spec/plan
|
||||
- Include quality dimension in brackets [Completeness/Clarity/Consistency/etc.]
|
||||
- Reference spec section `[Spec §X.Y]` when checking existing requirements
|
||||
- Use `[Gap]` marker when checking for missing requirements
|
||||
|
||||
**EXAMPLES BY QUALITY DIMENSION**:
|
||||
|
||||
Completeness:
|
||||
- "Are error handling requirements defined for all API failure modes? [Gap]"
|
||||
- "Are accessibility requirements specified for all interactive elements? [Completeness]"
|
||||
- "Are mobile breakpoint requirements defined for responsive layouts? [Gap]"
|
||||
|
||||
Clarity:
|
||||
- "Is 'fast loading' quantified with specific timing thresholds? [Clarity, Spec §NFR-2]"
|
||||
- "Are 'related episodes' selection criteria explicitly defined? [Clarity, Spec §FR-5]"
|
||||
- "Is 'prominent' defined with measurable visual properties? [Ambiguity, Spec §FR-4]"
|
||||
|
||||
Consistency:
|
||||
- "Do navigation requirements align across all pages? [Consistency, Spec §FR-10]"
|
||||
- "Are card component requirements consistent between landing and detail pages? [Consistency]"
|
||||
|
||||
Coverage:
|
||||
- "Are requirements defined for zero-state scenarios (no episodes)? [Coverage, Edge Case]"
|
||||
- "Are concurrent user interaction scenarios addressed? [Coverage, Gap]"
|
||||
- "Are requirements specified for partial data loading failures? [Coverage, Exception Flow]"
|
||||
|
||||
Measurability:
|
||||
- "Are visual hierarchy requirements measurable/testable? [Acceptance Criteria, Spec §FR-1]"
|
||||
- "Can 'balanced visual weight' be objectively verified? [Measurability, Spec §FR-2]"
|
||||
|
||||
**Scenario Classification & Coverage** (Requirements Quality Focus):
|
||||
- Check if requirements exist for: Primary, Alternate, Exception/Error, Recovery, Non-Functional scenarios
|
||||
- For each scenario class, ask: "Are [scenario type] requirements complete, clear, and consistent?"
|
||||
- If scenario class missing: "Are [scenario type] requirements intentionally excluded or missing? [Gap]"
|
||||
- Include resilience/rollback when state mutation occurs: "Are rollback requirements defined for migration failures? [Gap]"
|
||||
|
||||
**Traceability Requirements**:
|
||||
- MINIMUM: ≥80% of items MUST include at least one traceability reference
|
||||
- Each item should reference: spec section `[Spec §X.Y]`, or use markers: `[Gap]`, `[Ambiguity]`, `[Conflict]`, `[Assumption]`
|
||||
- If no ID system exists: "Is a requirement & acceptance criteria ID scheme established? [Traceability]"
|
||||
|
||||
**Surface & Resolve Issues** (Requirements Quality Problems):
|
||||
Ask questions about the requirements themselves:
|
||||
- Ambiguities: "Is the term 'fast' quantified with specific metrics? [Ambiguity, Spec §NFR-1]"
|
||||
- Conflicts: "Do navigation requirements conflict between §FR-10 and §FR-10a? [Conflict]"
|
||||
- Assumptions: "Is the assumption of 'always available podcast API' validated? [Assumption]"
|
||||
- Dependencies: "Are external podcast API requirements documented? [Dependency, Gap]"
|
||||
- Missing definitions: "Is 'visual hierarchy' defined with measurable criteria? [Gap]"
|
||||
|
||||
**Content Consolidation**:
|
||||
- Soft cap: If raw candidate items > 40, prioritize by risk/impact
|
||||
- Merge near-duplicates checking the same requirement aspect
|
||||
- If >5 low-impact edge cases, create one item: "Are edge cases X, Y, Z addressed in requirements? [Coverage]"
|
||||
|
||||
**🚫 ABSOLUTELY PROHIBITED** - These make it an implementation test, not a requirements test:
|
||||
- ❌ Any item starting with "Verify", "Test", "Confirm", "Check" + implementation behavior
|
||||
- ❌ References to code execution, user actions, system behavior
|
||||
- ❌ "Displays correctly", "works properly", "functions as expected"
|
||||
- ❌ "Click", "navigate", "render", "load", "execute"
|
||||
- ❌ Test cases, test plans, QA procedures
|
||||
- ❌ Implementation details (frameworks, APIs, algorithms)
|
||||
|
||||
**✅ REQUIRED PATTERNS** - These test requirements quality:
|
||||
- ✅ "Are [requirement type] defined/specified/documented for [scenario]?"
|
||||
- ✅ "Is [vague term] quantified/clarified with specific criteria?"
|
||||
- ✅ "Are requirements consistent between [section A] and [section B]?"
|
||||
- ✅ "Can [requirement] be objectively measured/verified?"
|
||||
- ✅ "Are [edge cases/scenarios] addressed in requirements?"
|
||||
- ✅ "Does the spec define [missing aspect]?"
|
||||
|
||||
6. **Structure Reference**: Generate the checklist following the canonical template in `.specify/templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001.
|
||||
|
||||
7. **Report**: Output full path to created checklist, item count, and remind user that each run creates a new file. Summarize:
|
||||
- Focus areas selected
|
||||
- Depth level
|
||||
- Actor/timing
|
||||
- Any explicit user-specified must-have items incorporated
|
||||
|
||||
**Important**: Each `/speckit.checklist` command invocation creates a checklist file using short, descriptive names unless file already exists. This allows:
|
||||
|
||||
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
|
||||
- Simple, memorable filenames that indicate checklist purpose
|
||||
- Easy identification and navigation in the `checklists/` folder
|
||||
|
||||
To avoid clutter, use descriptive types and clean up obsolete checklists when done.
|
||||
|
||||
## Example Checklist Types & Sample Items
|
||||
|
||||
**UX Requirements Quality:** `ux.md`
|
||||
|
||||
Sample items (testing the requirements, NOT the implementation):
|
||||
|
||||
- "Are visual hierarchy requirements defined with measurable criteria? [Clarity, Spec §FR-1]"
|
||||
- "Is the number and positioning of UI elements explicitly specified? [Completeness, Spec §FR-1]"
|
||||
- "Are interaction state requirements (hover, focus, active) consistently defined? [Consistency]"
|
||||
- "Are accessibility requirements specified for all interactive elements? [Coverage, Gap]"
|
||||
- "Is fallback behavior defined when images fail to load? [Edge Case, Gap]"
|
||||
- "Can 'prominent display' be objectively measured? [Measurability, Spec §FR-4]"
|
||||
|
||||
**API Requirements Quality:** `api.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are error response formats specified for all failure scenarios? [Completeness]"
|
||||
- "Are rate limiting requirements quantified with specific thresholds? [Clarity]"
|
||||
- "Are authentication requirements consistent across all endpoints? [Consistency]"
|
||||
- "Are retry/timeout requirements defined for external dependencies? [Coverage, Gap]"
|
||||
- "Is versioning strategy documented in requirements? [Gap]"
|
||||
|
||||
**Performance Requirements Quality:** `performance.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are performance requirements quantified with specific metrics? [Clarity]"
|
||||
- "Are performance targets defined for all critical user journeys? [Coverage]"
|
||||
- "Are performance requirements under different load conditions specified? [Completeness]"
|
||||
- "Can performance requirements be objectively measured? [Measurability]"
|
||||
- "Are degradation requirements defined for high-load scenarios? [Edge Case, Gap]"
|
||||
|
||||
**Security Requirements Quality:** `security.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are authentication requirements specified for all protected resources? [Coverage]"
|
||||
- "Are data protection requirements defined for sensitive information? [Completeness]"
|
||||
- "Is the threat model documented and requirements aligned to it? [Traceability]"
|
||||
- "Are security requirements consistent with compliance obligations? [Consistency]"
|
||||
- "Are security failure/breach response requirements defined? [Gap, Exception Flow]"
|
||||
|
||||
## Anti-Examples: What NOT To Do
|
||||
|
||||
**❌ WRONG - These test implementation, not requirements:**
|
||||
|
||||
```markdown
|
||||
- [ ] CHK001 - Verify landing page displays 3 episode cards [Spec §FR-001]
|
||||
- [ ] CHK002 - Test hover states work correctly on desktop [Spec §FR-003]
|
||||
- [ ] CHK003 - Confirm logo click navigates to home page [Spec §FR-010]
|
||||
- [ ] CHK004 - Check that related episodes section shows 3-5 items [Spec §FR-005]
|
||||
```
|
||||
|
||||
**✅ CORRECT - These test requirements quality:**
|
||||
|
||||
```markdown
|
||||
- [ ] CHK001 - Are the number and layout of featured episodes explicitly specified? [Completeness, Spec §FR-001]
|
||||
- [ ] CHK002 - Are hover state requirements consistently defined for all interactive elements? [Consistency, Spec §FR-003]
|
||||
- [ ] CHK003 - Are navigation requirements clear for all clickable brand elements? [Clarity, Spec §FR-010]
|
||||
- [ ] CHK004 - Is the selection criteria for related episodes documented? [Gap, Spec §FR-005]
|
||||
- [ ] CHK005 - Are loading state requirements defined for asynchronous episode data? [Gap]
|
||||
- [ ] CHK006 - Can "visual hierarchy" requirements be objectively measured? [Measurability, Spec §FR-001]
|
||||
```
|
||||
|
||||
**Key Differences:**
|
||||
|
||||
- Wrong: Tests if the system works correctly
|
||||
- Correct: Tests if the requirements are written correctly
|
||||
- Wrong: Verification of behavior
|
||||
- Correct: Validation of requirement quality
|
||||
- Wrong: "Does it do X?"
|
||||
- Correct: "Is X clearly specified?"
|
||||
181
.codex/prompts/speckit.clarify.md
Normal file
181
.codex/prompts/speckit.clarify.md
Normal file
@@ -0,0 +1,181 @@
|
||||
---
|
||||
description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec.
|
||||
handoffs:
|
||||
- label: Build Technical Plan
|
||||
agent: speckit.plan
|
||||
prompt: Create a plan for the spec. I am building with...
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
|
||||
|
||||
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit.plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
|
||||
|
||||
Execution steps:
|
||||
|
||||
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields:
|
||||
- `FEATURE_DIR`
|
||||
- `FEATURE_SPEC`
|
||||
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
|
||||
- If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
|
||||
|
||||
Functional Scope & Behavior:
|
||||
- Core user goals & success criteria
|
||||
- Explicit out-of-scope declarations
|
||||
- User roles / personas differentiation
|
||||
|
||||
Domain & Data Model:
|
||||
- Entities, attributes, relationships
|
||||
- Identity & uniqueness rules
|
||||
- Lifecycle/state transitions
|
||||
- Data volume / scale assumptions
|
||||
|
||||
Interaction & UX Flow:
|
||||
- Critical user journeys / sequences
|
||||
- Error/empty/loading states
|
||||
- Accessibility or localization notes
|
||||
|
||||
Non-Functional Quality Attributes:
|
||||
- Performance (latency, throughput targets)
|
||||
- Scalability (horizontal/vertical, limits)
|
||||
- Reliability & availability (uptime, recovery expectations)
|
||||
- Observability (logging, metrics, tracing signals)
|
||||
- Security & privacy (authN/Z, data protection, threat assumptions)
|
||||
- Compliance / regulatory constraints (if any)
|
||||
|
||||
Integration & External Dependencies:
|
||||
- External services/APIs and failure modes
|
||||
- Data import/export formats
|
||||
- Protocol/versioning assumptions
|
||||
|
||||
Edge Cases & Failure Handling:
|
||||
- Negative scenarios
|
||||
- Rate limiting / throttling
|
||||
- Conflict resolution (e.g., concurrent edits)
|
||||
|
||||
Constraints & Tradeoffs:
|
||||
- Technical constraints (language, storage, hosting)
|
||||
- Explicit tradeoffs or rejected alternatives
|
||||
|
||||
Terminology & Consistency:
|
||||
- Canonical glossary terms
|
||||
- Avoided synonyms / deprecated terms
|
||||
|
||||
Completion Signals:
|
||||
- Acceptance criteria testability
|
||||
- Measurable Definition of Done style indicators
|
||||
|
||||
Misc / Placeholders:
|
||||
- TODO markers / unresolved decisions
|
||||
- Ambiguous adjectives ("robust", "intuitive") lacking quantification
|
||||
|
||||
For each category with Partial or Missing status, add a candidate question opportunity unless:
|
||||
- Clarification would not materially change implementation or validation strategy
|
||||
- Information is better deferred to planning phase (note internally)
|
||||
|
||||
3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
|
||||
- Maximum of 10 total questions across the whole session.
|
||||
- Each question must be answerable with EITHER:
|
||||
- A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR
|
||||
- A one-word / short‑phrase answer (explicitly constrain: "Answer in <=5 words").
|
||||
- Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation.
|
||||
- Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved.
|
||||
- Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness).
|
||||
- Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests.
|
||||
- If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic.
|
||||
|
||||
4. Sequential questioning loop (interactive):
|
||||
- Present EXACTLY ONE question at a time.
|
||||
- For multiple‑choice questions:
|
||||
- **Analyze all options** and determine the **most suitable option** based on:
|
||||
- Best practices for the project type
|
||||
- Common patterns in similar implementations
|
||||
- Risk reduction (security, performance, maintainability)
|
||||
- Alignment with any explicit project goals or constraints visible in the spec
|
||||
- Present your **recommended option prominently** at the top with clear reasoning (1-2 sentences explaining why this is the best choice).
|
||||
- Format as: `**Recommended:** Option [X] - <reasoning>`
|
||||
- Then render all options as a Markdown table:
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| A | <Option A description> |
|
||||
| B | <Option B description> |
|
||||
| C | <Option C description> (add D/E as needed up to 5) |
|
||||
| Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) |
|
||||
|
||||
- After the table, add: `You can reply with the option letter (e.g., "A"), accept the recommendation by saying "yes" or "recommended", or provide your own short answer.`
|
||||
- For short‑answer style (no meaningful discrete options):
|
||||
- Provide your **suggested answer** based on best practices and context.
|
||||
- Format as: `**Suggested:** <your proposed answer> - <brief reasoning>`
|
||||
- Then output: `Format: Short answer (<=5 words). You can accept the suggestion by saying "yes" or "suggested", or provide your own answer.`
|
||||
- After the user answers:
|
||||
- If the user replies with "yes", "recommended", or "suggested", use your previously stated recommendation/suggestion as the answer.
|
||||
- Otherwise, validate the answer maps to one option or fits the <=5 word constraint.
|
||||
- If ambiguous, ask for a quick disambiguation (count still belongs to same question; do not advance).
|
||||
- Once satisfactory, record it in working memory (do not yet write to disk) and move to the next queued question.
|
||||
- Stop asking further questions when:
|
||||
- All critical ambiguities resolved early (remaining queued items become unnecessary), OR
|
||||
- User signals completion ("done", "good", "no more"), OR
|
||||
- You reach 5 asked questions.
|
||||
- Never reveal future queued questions in advance.
|
||||
- If no valid questions exist at start, immediately report no critical ambiguities.
|
||||
|
||||
5. Integration after EACH accepted answer (incremental update approach):
|
||||
- Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents.
|
||||
- For the first integrated answer in this session:
|
||||
- Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing).
|
||||
- Under it, create (if not present) a `### Session YYYY-MM-DD` subheading for today.
|
||||
- Append a bullet line immediately after acceptance: `- Q: <question> → A: <final answer>`.
|
||||
- Then immediately apply the clarification to the most appropriate section(s):
|
||||
- Functional ambiguity → Update or add a bullet in Functional Requirements.
|
||||
- User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario.
|
||||
- Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly.
|
||||
- Non-functional constraint → Add/modify measurable criteria in Non-Functional / Quality Attributes section (convert vague adjective to metric or explicit target).
|
||||
- Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it).
|
||||
- Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as "X")` once.
|
||||
- If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text.
|
||||
- Save the spec file AFTER each integration to minimize risk of context loss (atomic overwrite).
|
||||
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.
|
||||
- Keep each inserted clarification minimal and testable (avoid narrative drift).
|
||||
|
||||
6. Validation (performed after EACH write plus final pass):
|
||||
- Clarifications session contains exactly one bullet per accepted answer (no duplicates).
|
||||
- Total asked (accepted) questions ≤ 5.
|
||||
- Updated sections contain no lingering vague placeholders the new answer was meant to resolve.
|
||||
- No contradictory earlier statement remains (scan for now-invalid alternative choices removed).
|
||||
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.
|
||||
- Terminology consistency: same canonical term used across all updated sections.
|
||||
|
||||
7. Write the updated spec back to `FEATURE_SPEC`.
|
||||
|
||||
8. Report completion (after questioning loop ends or early termination):
|
||||
- Number of questions asked & answered.
|
||||
- Path to updated spec.
|
||||
- Sections touched (list names).
|
||||
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
|
||||
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit.plan` or run `/speckit.clarify` again later post-plan.
|
||||
- Suggested next command.
|
||||
|
||||
Behavior rules:
|
||||
|
||||
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
|
||||
- If spec file missing, instruct user to run `/speckit.specify` first (do not create a new spec here).
|
||||
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
|
||||
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
|
||||
- Respect user early termination signals ("stop", "done", "proceed").
|
||||
- If no questions asked due to full coverage, output a compact coverage summary (all categories Clear) then suggest advancing.
|
||||
- If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.
|
||||
|
||||
Context for prioritization: $ARGUMENTS
|
||||
84
.codex/prompts/speckit.constitution.md
Normal file
84
.codex/prompts/speckit.constitution.md
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync.
|
||||
handoffs:
|
||||
- label: Build Specification
|
||||
agent: speckit.specify
|
||||
prompt: Implement the feature specification based on the updated constitution. I want to build...
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
You are updating the project constitution at `.ai/standards/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
|
||||
|
||||
**Note**: If `.ai/standards/constitution.md` does not exist yet, it should have been initialized from `.specify/templates/constitution-template.md` during project setup. If it's missing, copy the template first.
|
||||
|
||||
Follow this execution flow:
|
||||
|
||||
1. Load the existing constitution at `.ai/standards/constitution.md`.
|
||||
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
|
||||
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
|
||||
|
||||
2. Collect/derive values for placeholders:
|
||||
- If user input (conversation) supplies a value, use it.
|
||||
- Otherwise infer from existing repo context (README, docs, prior constitution versions if embedded).
|
||||
- For governance dates: `RATIFICATION_DATE` is the original adoption date (if unknown ask or mark TODO), `LAST_AMENDED_DATE` is today if changes are made, otherwise keep previous.
|
||||
- `CONSTITUTION_VERSION` must increment according to semantic versioning rules:
|
||||
- MAJOR: Backward incompatible governance/principle removals or redefinitions.
|
||||
- MINOR: New principle/section added or materially expanded guidance.
|
||||
- PATCH: Clarifications, wording, typo fixes, non-semantic refinements.
|
||||
- If version bump type ambiguous, propose reasoning before finalizing.
|
||||
|
||||
3. Draft the updated constitution content:
|
||||
- Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left).
|
||||
- Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance.
|
||||
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing non‑negotiable rules, explicit rationale if not obvious.
|
||||
- Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations.
|
||||
|
||||
4. Consistency propagation checklist (convert prior checklist into active validations):
|
||||
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
|
||||
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
|
||||
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
|
||||
- Read each command file in `.specify/templates/commands/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required.
|
||||
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
|
||||
|
||||
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
|
||||
- Version change: old → new
|
||||
- List of modified principles (old title → new title if renamed)
|
||||
- Added sections
|
||||
- Removed sections
|
||||
- Templates requiring updates (✅ updated / ⚠ pending) with file paths
|
||||
- Follow-up TODOs if any placeholders intentionally deferred.
|
||||
|
||||
6. Validation before final output:
|
||||
- No remaining unexplained bracket tokens.
|
||||
- Version line matches report.
|
||||
- Dates ISO format YYYY-MM-DD.
|
||||
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate).
|
||||
|
||||
7. Write the completed constitution back to `.ai/standards/constitution.md` (overwrite).
|
||||
|
||||
8. Output a final summary to the user with:
|
||||
- New version and bump rationale.
|
||||
- Any files flagged for manual follow-up.
|
||||
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
|
||||
|
||||
Formatting & Style Requirements:
|
||||
|
||||
- Use Markdown headings exactly as in the template (do not demote/promote levels).
|
||||
- Wrap long rationale lines to keep readability (<100 chars ideally) but do not hard enforce with awkward breaks.
|
||||
- Keep a single blank line between sections.
|
||||
- Avoid trailing whitespace.
|
||||
|
||||
If the user supplies partial updates (e.g., only one principle revision), still perform validation and version decision steps.
|
||||
|
||||
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
|
||||
|
||||
Do not create a new template; always operate on the existing `.ai/standards/constitution.md` file.
|
||||
199
.codex/prompts/speckit.fix.md
Normal file
199
.codex/prompts/speckit.fix.md
Normal file
@@ -0,0 +1,199 @@
|
||||
---
|
||||
|
||||
description: Fix failing tests and implementation issues based on test reports
|
||||
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Goal
|
||||
|
||||
Analyze test failure reports, identify root causes, and fix implementation issues while preserving semantic protocol compliance.
|
||||
|
||||
## Operating Constraints
|
||||
|
||||
1. **USE CODER MODE**: Always switch to `coder` mode for code fixes
|
||||
2. **SEMANTIC PROTOCOL**: Never remove semantic annotations ([DEF], @TAGS). Only update code logic.
|
||||
3. **TEST DATA**: If tests use @TEST_ fixtures, preserve them when fixing
|
||||
4. **NO DELETION**: Never delete existing tests or semantic annotations
|
||||
5. **REPORT FIRST**: Always write a fix report before making changes
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### 1. Load Test Report
|
||||
|
||||
**Required**: Test report file path (e.g., `specs/<feature>/tests/reports/2026-02-19-report.md`)
|
||||
|
||||
**Parse the report for**:
|
||||
- Failed test cases
|
||||
- Error messages
|
||||
- Stack traces
|
||||
- Expected vs actual behavior
|
||||
- Affected modules/files
|
||||
|
||||
### 2. Analyze Root Causes
|
||||
|
||||
For each failed test:
|
||||
|
||||
1. **Read the test file** to understand what it's testing
|
||||
2. **Read the implementation file** to find the bug
|
||||
3. **Check semantic protocol compliance**:
|
||||
- Does the implementation have correct [DEF] anchors?
|
||||
- Are @TAGS (@PRE, @POST, @UX_STATE, etc.) present?
|
||||
- Does the code match the TIER requirements?
|
||||
4. **Identify the fix**:
|
||||
- Logic error in implementation
|
||||
- Missing error handling
|
||||
- Incorrect API usage
|
||||
- State management issue
|
||||
|
||||
### 3. Write Fix Report
|
||||
|
||||
Create a structured fix report:
|
||||
|
||||
```markdown
|
||||
# Fix Report: [FEATURE]
|
||||
|
||||
**Date**: [YYYY-MM-DD]
|
||||
**Report**: [Test Report Path]
|
||||
**Fixer**: Coder Agent
|
||||
|
||||
## Summary
|
||||
|
||||
- Total Failed Tests: [X]
|
||||
- Total Fixed: [X]
|
||||
- Total Skipped: [X]
|
||||
|
||||
## Failed Tests Analysis
|
||||
|
||||
### Test: [Test Name]
|
||||
|
||||
**File**: `path/to/test.py`
|
||||
**Error**: [Error message]
|
||||
|
||||
**Root Cause**: [Explanation of why test failed]
|
||||
|
||||
**Fix Required**: [Description of fix]
|
||||
|
||||
**Status**: [Pending/In Progress/Completed]
|
||||
|
||||
## Fixes Applied
|
||||
|
||||
### Fix 1: [Description]
|
||||
|
||||
**Affected File**: `path/to/file.py`
|
||||
**Test Affected**: `[Test Name]`
|
||||
|
||||
**Changes**:
|
||||
```diff
|
||||
<<<<<<< SEARCH
|
||||
[Original Code]
|
||||
=======
|
||||
[Fixed Code]
|
||||
>>>>>>> REPLACE
|
||||
```
|
||||
|
||||
**Verification**: [How to verify fix works]
|
||||
|
||||
**Semantic Integrity**: [Confirmed annotations preserved]
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [ ] Run tests to verify fix: `cd backend && .venv/bin/python3 -m pytest`
|
||||
- [ ] Check for related failing tests
|
||||
- [ ] Update test documentation if needed
|
||||
```
|
||||
|
||||
### 4. Apply Fixes (in Coder Mode)
|
||||
|
||||
Switch to `coder` mode and apply fixes:
|
||||
|
||||
1. **Read the implementation file** to get exact content
|
||||
2. **Apply the fix** using apply_diff
|
||||
3. **Preserve all semantic annotations**:
|
||||
- Keep [DEF:...] and [/DEF:...] anchors
|
||||
- Keep all @TAGS (@PURPOSE, @LAYER, @TIER, @RELATION, @PRE, @POST, @UX_STATE, @UX_FEEDBACK, @UX_RECOVERY)
|
||||
4. **Only update code logic** to fix the bug
|
||||
5. **Run tests** to verify the fix
|
||||
|
||||
### 5. Verification
|
||||
|
||||
After applying fixes:
|
||||
|
||||
1. **Run tests**:
|
||||
```bash
|
||||
cd backend && .venv/bin/python3 -m pytest -v
|
||||
```
|
||||
or
|
||||
```bash
|
||||
cd frontend && npm run test
|
||||
```
|
||||
|
||||
2. **Check test results**:
|
||||
- Failed tests should now pass
|
||||
- No new tests should fail
|
||||
- Coverage should not decrease
|
||||
|
||||
3. **Update fix report** with results:
|
||||
- Mark fixes as completed
|
||||
- Add verification steps
|
||||
- Note any remaining issues
|
||||
|
||||
## Output
|
||||
|
||||
Generate final fix report:
|
||||
|
||||
```markdown
|
||||
# Fix Report: [FEATURE] - COMPLETED
|
||||
|
||||
**Date**: [YYYY-MM-DD]
|
||||
**Report**: [Test Report Path]
|
||||
**Fixer**: Coder Agent
|
||||
|
||||
## Summary
|
||||
|
||||
- Total Failed Tests: [X]
|
||||
- Total Fixed: [X] ✅
|
||||
- Total Skipped: [X]
|
||||
|
||||
## Fixes Applied
|
||||
|
||||
### Fix 1: [Description] ✅
|
||||
|
||||
**Affected File**: `path/to/file.py`
|
||||
**Test Affected**: `[Test Name]`
|
||||
|
||||
**Changes**: [Summary of changes]
|
||||
|
||||
**Verification**: All tests pass ✅
|
||||
|
||||
**Semantic Integrity**: Preserved ✅
|
||||
|
||||
## Test Results
|
||||
|
||||
```
|
||||
[Full test output showing all passing tests]
|
||||
```
|
||||
|
||||
## Recommendations
|
||||
|
||||
- [ ] Monitor for similar issues
|
||||
- [ ] Update documentation if needed
|
||||
- [ ] Consider adding more tests for edge cases
|
||||
|
||||
## Related Files
|
||||
|
||||
- Test Report: [path]
|
||||
- Implementation: [path]
|
||||
- Test File: [path]
|
||||
```
|
||||
|
||||
## Context for Fixing
|
||||
|
||||
$ARGUMENTS
|
||||
150
.codex/prompts/speckit.implement.md
Normal file
150
.codex/prompts/speckit.implement.md
Normal file
@@ -0,0 +1,150 @@
|
||||
---
|
||||
description: Execute the implementation plan by processing and executing all tasks defined in tasks.md
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Check checklists status** (if FEATURE_DIR/checklists/ exists):
|
||||
- Scan all checklist files in the checklists/ directory
|
||||
- For each checklist, count:
|
||||
- Total items: All lines matching `- [ ]` or `- [X]` or `- [x]`
|
||||
- Completed items: Lines matching `- [X]` or `- [x]`
|
||||
- Incomplete items: Lines matching `- [ ]`
|
||||
- Create a status table:
|
||||
|
||||
```text
|
||||
| Checklist | Total | Completed | Incomplete | Status |
|
||||
|-----------|-------|-----------|------------|--------|
|
||||
| ux.md | 12 | 12 | 0 | ✓ PASS |
|
||||
| test.md | 8 | 5 | 3 | ✗ FAIL |
|
||||
| security.md | 6 | 6 | 0 | ✓ PASS |
|
||||
```
|
||||
|
||||
- Calculate overall status:
|
||||
- **PASS**: All checklists have 0 incomplete items
|
||||
- **FAIL**: One or more checklists have incomplete items
|
||||
|
||||
- **If any checklist is incomplete**:
|
||||
- Display the table with incomplete item counts
|
||||
- **STOP** and ask: "Some checklists are incomplete. Do you want to proceed with implementation anyway? (yes/no)"
|
||||
- Wait for user response before continuing
|
||||
- If user says "no" or "wait" or "stop", halt execution
|
||||
- If user says "yes" or "proceed" or "continue", proceed to step 3
|
||||
|
||||
- **If all checklists are complete**:
|
||||
- Display the table showing all checklists passed
|
||||
- Automatically proceed to step 3
|
||||
|
||||
3. Load and analyze the implementation context:
|
||||
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
|
||||
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
|
||||
- **IF EXISTS**: Read data-model.md for entities and relationships
|
||||
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
|
||||
- **IF EXISTS**: Read research.md for technical decisions and constraints
|
||||
- **IF EXISTS**: Read quickstart.md for integration scenarios
|
||||
|
||||
3. Load and analyze the implementation context:
|
||||
- **REQUIRED**: Read `.ai/standards/semantics.md` for strict coding standards and contract requirements
|
||||
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
|
||||
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
|
||||
- **IF EXISTS**: Read data-model.md for entities and relationships
|
||||
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
|
||||
- **IF EXISTS**: Read research.md for technical decisions and constraints
|
||||
- **IF EXISTS**: Read quickstart.md for integration scenarios
|
||||
|
||||
4. **Project Setup Verification**:
|
||||
- **REQUIRED**: Create/verify ignore files based on actual project setup:
|
||||
|
||||
**Detection & Creation Logic**:
|
||||
- Check if the following command succeeds to determine if the repository is a git repo (create/verify .gitignore if so):
|
||||
|
||||
```sh
|
||||
git rev-parse --git-dir 2>/dev/null
|
||||
```
|
||||
|
||||
- Check if Dockerfile* exists or Docker in plan.md → create/verify .dockerignore
|
||||
- Check if .eslintrc* exists → create/verify .eslintignore
|
||||
- Check if eslint.config.* exists → ensure the config's `ignores` entries cover required patterns
|
||||
- Check if .prettierrc* exists → create/verify .prettierignore
|
||||
- Check if .npmrc or package.json exists → create/verify .npmignore (if publishing)
|
||||
- Check if terraform files (*.tf) exist → create/verify .terraformignore
|
||||
- Check if .helmignore needed (helm charts present) → create/verify .helmignore
|
||||
|
||||
**If ignore file already exists**: Verify it contains essential patterns, append missing critical patterns only
|
||||
**If ignore file missing**: Create with full pattern set for detected technology
|
||||
|
||||
**Common Patterns by Technology** (from plan.md tech stack):
|
||||
- **Node.js/JavaScript/TypeScript**: `node_modules/`, `dist/`, `build/`, `*.log`, `.env*`
|
||||
- **Python**: `__pycache__/`, `*.pyc`, `.venv/`, `venv/`, `dist/`, `*.egg-info/`
|
||||
- **Java**: `target/`, `*.class`, `*.jar`, `.gradle/`, `build/`
|
||||
- **C#/.NET**: `bin/`, `obj/`, `*.user`, `*.suo`, `packages/`
|
||||
- **Go**: `*.exe`, `*.test`, `vendor/`, `*.out`
|
||||
- **Ruby**: `.bundle/`, `log/`, `tmp/`, `*.gem`, `vendor/bundle/`
|
||||
- **PHP**: `vendor/`, `*.log`, `*.cache`, `*.env`
|
||||
- **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*`
|
||||
- **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*`
|
||||
- **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*`
|
||||
- **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `Makefile`, `config.log`, `.idea/`, `*.log`, `.env*`
|
||||
- **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/`
|
||||
- **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/`
|
||||
- **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/`
|
||||
|
||||
**Tool-Specific Patterns**:
|
||||
- **Docker**: `node_modules/`, `.git/`, `Dockerfile*`, `.dockerignore`, `*.log*`, `.env*`, `coverage/`
|
||||
- **ESLint**: `node_modules/`, `dist/`, `build/`, `coverage/`, `*.min.js`
|
||||
- **Prettier**: `node_modules/`, `dist/`, `build/`, `coverage/`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`
|
||||
- **Terraform**: `.terraform/`, `*.tfstate*`, `*.tfvars`, `.terraform.lock.hcl`
|
||||
- **Kubernetes/k8s**: `*.secret.yaml`, `secrets/`, `.kube/`, `kubeconfig*`, `*.key`, `*.crt`
|
||||
|
||||
5. Parse tasks.md structure and extract:
|
||||
- **Task phases**: Setup, Tests, Core, Integration, Polish
|
||||
- **Task dependencies**: Sequential vs parallel execution rules
|
||||
- **Task details**: ID, description, file paths, parallel markers [P]
|
||||
- **Execution flow**: Order and dependency requirements
|
||||
|
||||
6. Execute implementation following the task plan:
|
||||
- **Phase-by-phase execution**: Complete each phase before moving to the next
|
||||
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
|
||||
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
|
||||
- **File-based coordination**: Tasks affecting the same files must run sequentially
|
||||
- **Validation checkpoints**: Verify each phase completion before proceeding
|
||||
|
||||
7. Implementation execution rules:
|
||||
- **Strict Adherence**: Apply `.ai/standards/semantics.md` rules:
|
||||
- Every file MUST start with a `[DEF:id:Type]` header and end with a closing `[/DEF:id:Type]` anchor.
|
||||
- Include `@TIER` and define contracts (`@PRE`, `@POST`).
|
||||
- For Svelte components, use `@UX_STATE`, `@UX_FEEDBACK`, `@UX_RECOVERY`, and explicitly declare reactivity with `@UX_REATIVITY: State: $state, Derived: $derived`.
|
||||
- **Molecular Topology Logging**: Use prefixes `[EXPLORE]`, `[REASON]`, `[REFLECT]` in logs to trace logic.
|
||||
- **CRITICAL Contracts**: If a task description contains a contract summary (e.g., `CRITICAL: PRE: ..., POST: ...`), these constraints are **MANDATORY** and must be strictly implemented in the code using guards/assertions (if applicable per protocol).
|
||||
- **Setup first**: Initialize project structure, dependencies, configuration
|
||||
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
|
||||
- **Core development**: Implement models, services, CLI commands, endpoints
|
||||
- **Integration work**: Database connections, middleware, logging, external services
|
||||
- **Polish and validation**: Unit tests, performance optimization, documentation
|
||||
|
||||
8. Progress tracking and error handling:
|
||||
- Report progress after each completed task
|
||||
- Halt execution if any non-parallel task fails
|
||||
- For parallel tasks [P], continue with successful tasks, report failed ones
|
||||
- Provide clear error messages with context for debugging
|
||||
- Suggest next steps if implementation cannot proceed
|
||||
- **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file.
|
||||
|
||||
9. Completion validation:
|
||||
- Verify all required tasks are completed
|
||||
- Check that implemented features match the original specification
|
||||
- Validate that tests pass and coverage meets requirements
|
||||
- Confirm the implementation follows the technical plan
|
||||
- Report final status with summary of completed work
|
||||
|
||||
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit.tasks` first to regenerate the task list.
|
||||
104
.codex/prompts/speckit.plan.md
Normal file
104
.codex/prompts/speckit.plan.md
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
description: Execute the implementation planning workflow using the plan template to generate design artifacts.
|
||||
handoffs:
|
||||
- label: Create Tasks
|
||||
agent: speckit.tasks
|
||||
prompt: Break the plan into tasks
|
||||
send: true
|
||||
- label: Create Checklist
|
||||
agent: speckit.checklist
|
||||
prompt: Create a checklist for the following domain...
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/bash/setup-plan.sh --json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Load context**: Read `.ai/ROOT.md` and `.ai/PROJECT_MAP.md` to understand the project structure and navigation. Then read required standards: `.ai/standards/constitution.md` and `.ai/standards/semantics.md`. Load IMPL_PLAN template.
|
||||
|
||||
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
|
||||
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
|
||||
- Fill Constitution Check section from constitution
|
||||
- Evaluate gates (ERROR if violations unjustified)
|
||||
- Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION)
|
||||
- Phase 1: Generate data-model.md, contracts/, quickstart.md
|
||||
- Phase 1: Update agent context by running the agent script
|
||||
- Re-evaluate Constitution Check post-design
|
||||
|
||||
4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 0: Outline & Research
|
||||
|
||||
1. **Extract unknowns from Technical Context** above:
|
||||
- For each NEEDS CLARIFICATION → research task
|
||||
- For each dependency → best practices task
|
||||
- For each integration → patterns task
|
||||
|
||||
2. **Generate and dispatch research agents**:
|
||||
|
||||
```text
|
||||
For each unknown in Technical Context:
|
||||
Task: "Research {unknown} for {feature context}"
|
||||
For each technology choice:
|
||||
Task: "Find best practices for {tech} in {domain}"
|
||||
```
|
||||
|
||||
3. **Consolidate findings** in `research.md` using format:
|
||||
- Decision: [what was chosen]
|
||||
- Rationale: [why chosen]
|
||||
- Alternatives considered: [what else evaluated]
|
||||
|
||||
**Output**: research.md with all NEEDS CLARIFICATION resolved
|
||||
|
||||
### Phase 1: Design & Contracts
|
||||
|
||||
**Prerequisites:** `research.md` complete
|
||||
|
||||
0. **Validate Design against UX Reference**:
|
||||
- Check if the proposed architecture supports the latency, interactivity, and flow defined in `ux_reference.md`.
|
||||
- **Linkage**: Ensure key UI states from `ux_reference.md` map to Component Contracts (`@UX_STATE`).
|
||||
- **CRITICAL**: If the technical plan compromises the UX (e.g. "We can't do real-time validation"), you **MUST STOP** and warn the user.
|
||||
|
||||
1. **Extract entities from feature spec** → `data-model.md`:
|
||||
- Entity name, fields, relationships, validation rules.
|
||||
|
||||
2. **Design & Verify Contracts (Semantic Protocol)**:
|
||||
- **Drafting**: Define `[DEF:id:Type]` Headers, Contracts, and closing `[/DEF:id:Type]` for all new modules based on `.ai/standards/semantics.md`.
|
||||
- **TIER Classification**: Explicitly assign `@TIER: [CRITICAL|STANDARD|TRIVIAL]` to each module.
|
||||
- **CRITICAL Requirements**: For all CRITICAL modules, define full `@PRE`, `@POST`, and (if UI) `@UX_STATE` contracts. **MUST** also define testing contracts: `@TEST_CONTRACT`, `@TEST_FIXTURE`, `@TEST_EDGE`, and `@TEST_INVARIANT`.
|
||||
- **Self-Review**:
|
||||
- *Completeness*: Do `@PRE`/`@POST` cover edge cases identified in Research? Are test contracts present for CRITICAL?
|
||||
- *Connectivity*: Do `@RELATION` tags form a coherent graph?
|
||||
- *Compliance*: Does syntax match `[DEF:id:Type]` exactly and is it closed with `[/DEF:id:Type]`?
|
||||
- **Output**: Write verified contracts to `contracts/modules.md`.
|
||||
|
||||
3. **Simulate Contract Usage**:
|
||||
- Trace one key user scenario through the defined contracts to ensure data flow continuity.
|
||||
- If a contract interface mismatch is found, fix it immediately.
|
||||
|
||||
4. **Generate API contracts**:
|
||||
- Output OpenAPI/GraphQL schema to `/contracts/` for backend-frontend sync.
|
||||
|
||||
3. **Agent context update**:
|
||||
- Run `.specify/scripts/bash/update-agent-context.sh agy`
|
||||
- These scripts detect which AI agent is in use
|
||||
- Update the appropriate agent-specific context file
|
||||
- Add only new technology from current plan
|
||||
- Preserve manual additions between markers
|
||||
|
||||
**Output**: data-model.md, /contracts/*, quickstart.md, agent-specific file
|
||||
|
||||
## Key rules
|
||||
|
||||
- Use absolute paths
|
||||
- ERROR on gate failures or unresolved clarifications
|
||||
258
.codex/prompts/speckit.specify.md
Normal file
258
.codex/prompts/speckit.specify.md
Normal file
@@ -0,0 +1,258 @@
|
||||
---
|
||||
description: Create or update the feature specification from a natural language feature description.
|
||||
handoffs:
|
||||
- label: Build Technical Plan
|
||||
agent: speckit.plan
|
||||
prompt: Create a plan for the spec. I am building with...
|
||||
- label: Clarify Spec Requirements
|
||||
agent: speckit.clarify
|
||||
prompt: Clarify specification requirements
|
||||
send: true
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `$ARGUMENTS` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
|
||||
|
||||
Given that feature description, do this:
|
||||
|
||||
1. **Generate a concise short name** (2-4 words) for the branch:
|
||||
- Analyze the feature description and extract the most meaningful keywords
|
||||
- Create a 2-4 word short name that captures the essence of the feature
|
||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
||||
- Keep it concise but descriptive enough to understand the feature at a glance
|
||||
- Examples:
|
||||
- "I want to add user authentication" → "user-auth"
|
||||
- "Implement OAuth2 integration for the API" → "oauth2-api-integration"
|
||||
- "Create a dashboard for analytics" → "analytics-dashboard"
|
||||
- "Fix payment processing timeout bug" → "fix-payment-timeout"
|
||||
|
||||
2. **Check for existing branches before creating new one**:
|
||||
|
||||
a. First, fetch all remote branches to ensure we have the latest information:
|
||||
|
||||
```bash
|
||||
git fetch --all --prune
|
||||
```
|
||||
|
||||
b. Find the highest feature number across all sources for the short-name:
|
||||
- Remote branches: `git ls-remote --heads origin | grep -E 'refs/heads/[0-9]+-<short-name>$'`
|
||||
- Local branches: `git branch | grep -E '^[* ]*[0-9]+-<short-name>$'`
|
||||
- Specs directories: Check for directories matching `specs/[0-9]+-<short-name>`
|
||||
|
||||
c. Determine the next available number:
|
||||
- Extract all numbers from all three sources
|
||||
- Find the highest number N
|
||||
- Use N+1 for the new branch number
|
||||
|
||||
d. Run the script `.specify/scripts/bash/create-new-feature.sh --json "$ARGUMENTS"` with the calculated number and short-name:
|
||||
- Pass `--number N+1` and `--short-name "your-short-name"` along with the feature description
|
||||
- Bash example: `.specify/scripts/bash/create-new-feature.sh --json "$ARGUMENTS" --json --number 5 --short-name "user-auth" "Add user authentication"`
|
||||
- PowerShell example: `.specify/scripts/bash/create-new-feature.sh --json "$ARGUMENTS" -Json -Number 5 -ShortName "user-auth" "Add user authentication"`
|
||||
|
||||
**IMPORTANT**:
|
||||
- Check all three sources (remote branches, local branches, specs directories) to find the highest number
|
||||
- Only match branches/directories with the exact short-name pattern
|
||||
- If no existing branches/directories found with this short-name, start with number 1
|
||||
- You must only ever run this script once per feature
|
||||
- The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for
|
||||
- The JSON output will contain BRANCH_NAME and SPEC_FILE paths
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot")
|
||||
|
||||
3. Load `.specify/templates/spec-template.md` to understand required sections.
|
||||
|
||||
4. Follow this execution flow:
|
||||
|
||||
1. Parse user description from Input
|
||||
If empty: ERROR "No feature description provided"
|
||||
2. Extract key concepts from description
|
||||
Identify: actors, actions, data, constraints
|
||||
3. For unclear aspects:
|
||||
- Make informed guesses based on context and industry standards
|
||||
- Only mark with [NEEDS CLARIFICATION: specific question] if:
|
||||
- The choice significantly impacts feature scope or user experience
|
||||
- Multiple reasonable interpretations exist with different implications
|
||||
- No reasonable default exists
|
||||
- **LIMIT: Maximum 3 [NEEDS CLARIFICATION] markers total**
|
||||
- Prioritize clarifications by impact: scope > security/privacy > user experience > technical details
|
||||
4. Fill User Scenarios & Testing section
|
||||
If no clear user flow: ERROR "Cannot determine user scenarios"
|
||||
5. Generate Functional Requirements
|
||||
Each requirement must be testable
|
||||
Use reasonable defaults for unspecified details (document assumptions in Assumptions section)
|
||||
6. Define Success Criteria
|
||||
Create measurable, technology-agnostic outcomes
|
||||
Include both quantitative metrics (time, performance, volume) and qualitative measures (user satisfaction, task completion)
|
||||
Each criterion must be verifiable without implementation details
|
||||
7. Identify Key Entities (if data involved)
|
||||
8. Return: SUCCESS (spec ready for planning)
|
||||
|
||||
5. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
|
||||
|
||||
6. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
|
||||
|
||||
a. **Create Spec Quality Checklist**: Generate a checklist file at `FEATURE_DIR/checklists/requirements.md` using the checklist template structure with these validation items:
|
||||
|
||||
```markdown
|
||||
# Specification Quality Checklist: [FEATURE NAME]
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: [DATE]
|
||||
**Feature**: [Link to spec.md]
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [ ] No implementation details (languages, frameworks, APIs)
|
||||
- [ ] Focused on user value and business needs
|
||||
- [ ] Written for non-technical stakeholders
|
||||
- [ ] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [ ] No [NEEDS CLARIFICATION] markers remain
|
||||
- [ ] Requirements are testable and unambiguous
|
||||
- [ ] Success criteria are measurable
|
||||
- [ ] Success criteria are technology-agnostic (no implementation details)
|
||||
- [ ] All acceptance scenarios are defined
|
||||
- [ ] Edge cases are identified
|
||||
- [ ] Scope is clearly bounded
|
||||
- [ ] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [ ] All functional requirements have clear acceptance criteria
|
||||
- [ ] User scenarios cover primary flows
|
||||
- [ ] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [ ] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||
```
|
||||
|
||||
b. **Run Validation Check**: Review the spec against each checklist item:
|
||||
- For each item, determine if it passes or fails
|
||||
- Document specific issues found (quote relevant spec sections)
|
||||
|
||||
c. **Handle Validation Results**:
|
||||
|
||||
- **If all items pass**: Mark checklist complete and proceed to step 6
|
||||
|
||||
- **If items fail (excluding [NEEDS CLARIFICATION])**:
|
||||
1. List the failing items and specific issues
|
||||
2. Update the spec to address each issue
|
||||
3. Re-run validation until all items pass (max 3 iterations)
|
||||
4. If still failing after 3 iterations, document remaining issues in checklist notes and warn user
|
||||
|
||||
- **If [NEEDS CLARIFICATION] markers remain**:
|
||||
1. Extract all [NEEDS CLARIFICATION: ...] markers from the spec
|
||||
2. **LIMIT CHECK**: If more than 3 markers exist, keep only the 3 most critical (by scope/security/UX impact) and make informed guesses for the rest
|
||||
3. For each clarification needed (max 3), present options to user in this format:
|
||||
|
||||
```markdown
|
||||
## Question [N]: [Topic]
|
||||
|
||||
**Context**: [Quote relevant spec section]
|
||||
|
||||
**What we need to know**: [Specific question from NEEDS CLARIFICATION marker]
|
||||
|
||||
**Suggested Answers**:
|
||||
|
||||
| Option | Answer | Implications |
|
||||
|--------|--------|--------------|
|
||||
| A | [First suggested answer] | [What this means for the feature] |
|
||||
| B | [Second suggested answer] | [What this means for the feature] |
|
||||
| C | [Third suggested answer] | [What this means for the feature] |
|
||||
| Custom | Provide your own answer | [Explain how to provide custom input] |
|
||||
|
||||
**Your choice**: _[Wait for user response]_
|
||||
```
|
||||
|
||||
4. **CRITICAL - Table Formatting**: Ensure markdown tables are properly formatted:
|
||||
- Use consistent spacing with pipes aligned
|
||||
- Each cell should have spaces around content: `| Content |` not `|Content|`
|
||||
- Header separator must have at least 3 dashes: `|--------|`
|
||||
- Test that the table renders correctly in markdown preview
|
||||
5. Number questions sequentially (Q1, Q2, Q3 - max 3 total)
|
||||
6. Present all questions together before waiting for responses
|
||||
7. Wait for user to respond with their choices for all questions (e.g., "Q1: A, Q2: Custom - [details], Q3: B")
|
||||
8. Update the spec by replacing each [NEEDS CLARIFICATION] marker with the user's selected or provided answer
|
||||
9. Re-run validation after all clarifications are resolved
|
||||
|
||||
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
|
||||
|
||||
7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`).
|
||||
|
||||
**NOTE:** The script creates and checks out the new branch and initializes the spec file before writing.
|
||||
|
||||
## General Guidelines
|
||||
|
||||
## Quick Guidelines
|
||||
|
||||
- Focus on **WHAT** users need and **WHY**.
|
||||
- Avoid HOW to implement (no tech stack, APIs, code structure).
|
||||
- Written for business stakeholders, not developers.
|
||||
- DO NOT create any checklists that are embedded in the spec. That will be a separate command.
|
||||
|
||||
### Section Requirements
|
||||
|
||||
- **Mandatory sections**: Must be completed for every feature
|
||||
- **Optional sections**: Include only when relevant to the feature
|
||||
- When a section doesn't apply, remove it entirely (don't leave as "N/A")
|
||||
|
||||
### For AI Generation
|
||||
|
||||
When creating this spec from a user prompt:
|
||||
|
||||
1. **Make informed guesses**: Use context, industry standards, and common patterns to fill gaps
|
||||
2. **Document assumptions**: Record reasonable defaults in the Assumptions section
|
||||
3. **Limit clarifications**: Maximum 3 [NEEDS CLARIFICATION] markers - use only for critical decisions that:
|
||||
- Significantly impact feature scope or user experience
|
||||
- Have multiple reasonable interpretations with different implications
|
||||
- Lack any reasonable default
|
||||
4. **Prioritize clarifications**: scope > security/privacy > user experience > technical details
|
||||
5. **Think like a tester**: Every vague requirement should fail the "testable and unambiguous" checklist item
|
||||
6. **Common areas needing clarification** (only if no reasonable default exists):
|
||||
- Feature scope and boundaries (include/exclude specific use cases)
|
||||
- User types and permissions (if multiple conflicting interpretations possible)
|
||||
- Security/compliance requirements (when legally/financially significant)
|
||||
|
||||
**Examples of reasonable defaults** (don't ask about these):
|
||||
|
||||
- Data retention: Industry-standard practices for the domain
|
||||
- Performance targets: Standard web/mobile app expectations unless specified
|
||||
- Error handling: User-friendly messages with appropriate fallbacks
|
||||
- Authentication method: Standard session-based or OAuth2 for web apps
|
||||
- Integration patterns: Use project-appropriate patterns (REST/GraphQL for web services, function calls for libraries, CLI args for tools, etc.)
|
||||
|
||||
### Success Criteria Guidelines
|
||||
|
||||
Success criteria must be:
|
||||
|
||||
1. **Measurable**: Include specific metrics (time, percentage, count, rate)
|
||||
2. **Technology-agnostic**: No mention of frameworks, languages, databases, or tools
|
||||
3. **User-focused**: Describe outcomes from user/business perspective, not system internals
|
||||
4. **Verifiable**: Can be tested/validated without knowing implementation details
|
||||
|
||||
**Good examples**:
|
||||
|
||||
- "Users can complete checkout in under 3 minutes"
|
||||
- "System supports 10,000 concurrent users"
|
||||
- "95% of searches return results in under 1 second"
|
||||
- "Task completion rate improves by 40%"
|
||||
|
||||
**Bad examples** (implementation-focused):
|
||||
|
||||
- "API response time is under 200ms" (too technical, use "Users see results instantly")
|
||||
- "Database can handle 1000 TPS" (implementation detail, use user-facing metric)
|
||||
- "React components render efficiently" (framework-specific)
|
||||
- "Redis cache hit rate above 80%" (technology-specific)
|
||||
146
.codex/prompts/speckit.tasks.md
Normal file
146
.codex/prompts/speckit.tasks.md
Normal file
@@ -0,0 +1,146 @@
|
||||
---
|
||||
description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts.
|
||||
handoffs:
|
||||
- label: Analyze For Consistency
|
||||
agent: speckit.analyze
|
||||
prompt: Run a project analysis for consistency
|
||||
send: true
|
||||
- label: Implement Project
|
||||
agent: speckit.implement
|
||||
prompt: Start the implementation in phases
|
||||
send: true
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Load design documents**: Read from FEATURE_DIR:
|
||||
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities), ux_reference.md (experience source of truth)
|
||||
- **Optional**: data-model.md (entities), contracts/ (interface contracts), research.md (decisions), quickstart.md (test scenarios)
|
||||
- Note: Not all projects have all documents. Generate tasks based on what's available.
|
||||
|
||||
3. **Execute task generation workflow**:
|
||||
- Load plan.md and extract tech stack, libraries, project structure
|
||||
- Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.)
|
||||
- If data-model.md exists: Extract entities and map to user stories
|
||||
- If contracts/ exists: Map interface contracts to user stories
|
||||
- If research.md exists: Extract decisions for setup tasks
|
||||
- Generate tasks organized by user story (see Task Generation Rules below)
|
||||
- Generate dependency graph showing user story completion order
|
||||
- Create parallel execution examples per user story
|
||||
- Validate task completeness (each user story has all needed tasks, independently testable)
|
||||
|
||||
4. **Generate tasks.md**: Use `.specify/templates/tasks-template.md` as structure, fill with:
|
||||
- Correct feature name from plan.md
|
||||
- Phase 1: Setup tasks (project initialization)
|
||||
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)
|
||||
- Phase 3+: One phase per user story (in priority order from spec.md)
|
||||
- Each phase includes: story goal, independent test criteria, tests (if requested), implementation tasks
|
||||
- Final Phase: Polish & cross-cutting concerns
|
||||
- All tasks must follow the strict checklist format (see Task Generation Rules below)
|
||||
- Clear file paths for each task
|
||||
- Dependencies section showing story completion order
|
||||
- Parallel execution examples per story
|
||||
- Implementation strategy section (MVP first, incremental delivery)
|
||||
|
||||
5. **Report**: Output path to generated tasks.md and summary:
|
||||
- Total task count
|
||||
- Task count per user story
|
||||
- Parallel opportunities identified
|
||||
- Independent test criteria for each story
|
||||
- Suggested MVP scope (typically just User Story 1)
|
||||
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)
|
||||
|
||||
Context for task generation: $ARGUMENTS
|
||||
|
||||
The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context.
|
||||
|
||||
## Task Generation Rules
|
||||
|
||||
**CRITICAL**: Tasks MUST be organized by user story to enable independent implementation and testing.
|
||||
|
||||
**Tests are OPTIONAL**: Only generate test tasks if explicitly requested in the feature specification or if user requests TDD approach.
|
||||
|
||||
### UX Preservation (CRITICAL)
|
||||
|
||||
- **Source of Truth**: `ux_reference.md` is the absolute standard for the "feel" of the feature.
|
||||
- **Violation Warning**: If any task would inherently violate the UX (e.g. "Remove progress bar to simplify code"), you **MUST** flag this to the user immediately.
|
||||
- **Verification Task**: You **MUST** add a specific task at the end of each User Story phase: `- [ ] Txxx [USx] Verify implementation matches ux_reference.md (Happy Path & Errors)`
|
||||
|
||||
### Checklist Format (REQUIRED)
|
||||
|
||||
Every task MUST strictly follow this format:
|
||||
|
||||
```text
|
||||
- [ ] [TaskID] [P?] [Story?] Description with file path
|
||||
```
|
||||
|
||||
**Format Components**:
|
||||
|
||||
1. **Checkbox**: ALWAYS start with `- [ ]` (markdown checkbox)
|
||||
2. **Task ID**: Sequential number (T001, T002, T003...) in execution order
|
||||
3. **[P] marker**: Include ONLY if task is parallelizable (different files, no dependencies on incomplete tasks)
|
||||
4. **[Story] label**: REQUIRED for user story phase tasks only
|
||||
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)
|
||||
- Setup phase: NO story label
|
||||
- Foundational phase: NO story label
|
||||
- User Story phases: MUST have story label
|
||||
- Polish phase: NO story label
|
||||
5. **Description**: Clear action with exact file path
|
||||
|
||||
**Examples**:
|
||||
|
||||
- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan`
|
||||
- ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py`
|
||||
- ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py`
|
||||
- ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py`
|
||||
- ❌ WRONG: `- [ ] Create User model` (missing ID and Story label)
|
||||
- ❌ WRONG: `T001 [US1] Create model` (missing checkbox)
|
||||
- ❌ WRONG: `- [ ] [US1] Create User model` (missing Task ID)
|
||||
- ❌ WRONG: `- [ ] T001 [US1] Create model` (missing file path)
|
||||
|
||||
### Task Organization
|
||||
|
||||
1. **From User Stories (spec.md)** - PRIMARY ORGANIZATION:
|
||||
- Each user story (P1, P2, P3...) gets its own phase
|
||||
- Map all related components to their story:
|
||||
- Models needed for that story
|
||||
- Services needed for that story
|
||||
- Interfaces/UI needed for that story
|
||||
- If tests requested: Tests specific to that story
|
||||
- Mark story dependencies (most stories should be independent)
|
||||
|
||||
2. **From Contracts (CRITICAL TIER)**:
|
||||
- Identify components marked as `@TIER: CRITICAL` in `contracts/modules.md`.
|
||||
- For these components, **MUST** append the summary of `@PRE`, `@POST`, `@UX_STATE`, and test contracts (`@TEST_FIXTURE`, `@TEST_EDGE`) directly to the task description.
|
||||
- Example: `- [ ] T005 [P] [US1] Implement Auth (CRITICAL: PRE: token exists, POST: returns User, TESTS: 2 edges) in src/auth.py`
|
||||
- Map each contract/endpoint → to the user story it serves
|
||||
- If tests requested: Each contract → contract test task [P] before implementation in that story's phase
|
||||
|
||||
3. **From Data Model**:
|
||||
- Map each entity to the user story(ies) that need it
|
||||
- If entity serves multiple stories: Put in earliest story or Setup phase
|
||||
- Relationships → service layer tasks in appropriate story phase
|
||||
|
||||
4. **From Setup/Infrastructure**:
|
||||
- Shared infrastructure → Setup phase (Phase 1)
|
||||
- Foundational/blocking tasks → Foundational phase (Phase 2)
|
||||
- Story-specific setup → within that story's phase
|
||||
|
||||
### Phase Structure
|
||||
|
||||
- **Phase 1**: Setup (project initialization)
|
||||
- **Phase 2**: Foundational (blocking prerequisites - MUST complete before user stories)
|
||||
- **Phase 3+**: User Stories in priority order (P1, P2, P3...)
|
||||
- Within each story: Tests (if requested) → Models → Services → Endpoints → Integration
|
||||
- Each phase should be a complete, independently testable increment
|
||||
- **Final Phase**: Polish & Cross-Cutting Concerns
|
||||
30
.codex/prompts/speckit.taskstoissues copy.md
Normal file
30
.codex/prompts/speckit.taskstoissues copy.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
description: Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts.
|
||||
tools: ['github/github-mcp-server/issue_write']
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
1. From the executed script, extract the path to **tasks**.
|
||||
1. Get the Git remote by running:
|
||||
|
||||
```bash
|
||||
git config --get remote.origin.url
|
||||
```
|
||||
|
||||
> [!CAUTION]
|
||||
> ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL
|
||||
|
||||
1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote.
|
||||
|
||||
> [!CAUTION]
|
||||
> UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL
|
||||
179
.codex/prompts/speckit.test.md
Normal file
179
.codex/prompts/speckit.test.md
Normal file
@@ -0,0 +1,179 @@
|
||||
---
|
||||
|
||||
description: Generate tests, manage test documentation, and ensure maximum code coverage
|
||||
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Goal
|
||||
|
||||
Execute full testing cycle: analyze code for testable modules, write tests with proper coverage, maintain test documentation, and ensure no test duplication or deletion.
|
||||
|
||||
## Operating Constraints
|
||||
|
||||
1. **NEVER delete existing tests** - Only update if they fail due to bugs in the test or implementation
|
||||
2. **NEVER duplicate tests** - Check existing tests first before creating new ones
|
||||
3. **Use TEST_FIXTURE fixtures** - For CRITICAL tier modules, read @TEST_FIXTURE from semantics header
|
||||
4. **Co-location required** - Write tests in `__tests__` directories relative to the code being tested
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### 1. Analyze Context
|
||||
|
||||
Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS.
|
||||
|
||||
Determine:
|
||||
- FEATURE_DIR - where the feature is located
|
||||
- TASKS_FILE - path to tasks.md
|
||||
- Which modules need testing based on task status
|
||||
|
||||
### 2. Load Relevant Artifacts
|
||||
|
||||
**From tasks.md:**
|
||||
- Identify completed implementation tasks (not test tasks)
|
||||
- Extract file paths that need tests
|
||||
|
||||
**From .ai/standards/semantics.md:**
|
||||
- Read @TIER annotations for modules
|
||||
- For CRITICAL modules: Read @TEST_ fixtures
|
||||
|
||||
**From existing tests:**
|
||||
- Scan `__tests__` directories for existing tests
|
||||
- Identify test patterns and coverage gaps
|
||||
|
||||
### 3. Test Coverage Analysis
|
||||
|
||||
Create coverage matrix:
|
||||
|
||||
| Module | File | Has Tests | TIER | TEST_FIXTURE Available |
|
||||
|--------|------|-----------|------|----------------------|
|
||||
| ... | ... | ... | ... | ... |
|
||||
|
||||
### 4. Write Tests (TDD Approach)
|
||||
|
||||
For each module requiring tests:
|
||||
|
||||
1. **Check existing tests**: Scan `__tests__/` for duplicates
|
||||
2. **Read TEST_FIXTURE**: If CRITICAL tier, read @TEST_FIXTURE from semantic header
|
||||
3. **Write test**: Follow co-location strategy
|
||||
- Python: `src/module/__tests__/test_module.py`
|
||||
- Svelte: `src/lib/components/__tests__/test_component.test.js`
|
||||
4. **Use mocks**: Use `unittest.mock.MagicMock` for external dependencies
|
||||
|
||||
### 4a. UX Contract Testing (Frontend Components)
|
||||
|
||||
For Svelte components with `@UX_STATE`, `@UX_FEEDBACK`, `@UX_RECOVERY` tags:
|
||||
|
||||
1. **Parse UX tags**: Read component file and extract all `@UX_*` annotations
|
||||
2. **Generate UX tests**: Create tests for each UX state transition
|
||||
```javascript
|
||||
// Example: Testing @UX_STATE: Idle -> Expanded
|
||||
it('should transition from Idle to Expanded on toggle click', async () => {
|
||||
render(Sidebar);
|
||||
const toggleBtn = screen.getByRole('button', { name: /toggle/i });
|
||||
await fireEvent.click(toggleBtn);
|
||||
expect(screen.getByTestId('sidebar')).toHaveClass('expanded');
|
||||
});
|
||||
```
|
||||
3. **Test @UX_FEEDBACK**: Verify visual feedback (toast, shake, color changes)
|
||||
4. **Test @UX_RECOVERY**: Verify error recovery mechanisms (retry, clear input)
|
||||
5. **Use @UX_TEST fixtures**: If component has `@UX_TEST` tags, use them as test specifications
|
||||
|
||||
**UX Test Template:**
|
||||
```javascript
|
||||
// [DEF:__tests__/test_Component:Module]
|
||||
// @RELATION: VERIFIES -> ../Component.svelte
|
||||
// @PURPOSE: Test UX states and transitions
|
||||
|
||||
describe('Component UX States', () => {
|
||||
// @UX_STATE: Idle -> {action: click, expected: Active}
|
||||
it('should transition Idle -> Active on click', async () => { ... });
|
||||
|
||||
// @UX_FEEDBACK: Toast on success
|
||||
it('should show toast on successful action', async () => { ... });
|
||||
|
||||
// @UX_RECOVERY: Retry on error
|
||||
it('should allow retry on error', async () => { ... });
|
||||
});
|
||||
// [/DEF:__tests__/test_Component:Module]
|
||||
```
|
||||
|
||||
### 5. Test Documentation
|
||||
|
||||
Create/update documentation in `specs/<feature>/tests/`:
|
||||
|
||||
```
|
||||
tests/
|
||||
├── README.md # Test strategy and overview
|
||||
├── coverage.md # Coverage matrix and reports
|
||||
└── reports/
|
||||
└── YYYY-MM-DD-report.md
|
||||
```
|
||||
|
||||
### 6. Execute Tests
|
||||
|
||||
Run tests and report results:
|
||||
|
||||
**Backend:**
|
||||
```bash
|
||||
cd backend && .venv/bin/python3 -m pytest -v
|
||||
```
|
||||
|
||||
**Frontend:**
|
||||
```bash
|
||||
cd frontend && npm run test
|
||||
```
|
||||
|
||||
### 7. Update Tasks
|
||||
|
||||
Mark test tasks as completed in tasks.md with:
|
||||
- Test file path
|
||||
- Coverage achieved
|
||||
- Any issues found
|
||||
|
||||
## Output
|
||||
|
||||
Generate test execution report:
|
||||
|
||||
```markdown
|
||||
# Test Report: [FEATURE]
|
||||
|
||||
**Date**: [YYYY-MM-DD]
|
||||
**Executed by**: Tester Agent
|
||||
|
||||
## Coverage Summary
|
||||
|
||||
| Module | Tests | Coverage % |
|
||||
|--------|-------|------------|
|
||||
| ... | ... | ... |
|
||||
|
||||
## Test Results
|
||||
|
||||
- Total: [X]
|
||||
- Passed: [X]
|
||||
- Failed: [X]
|
||||
- Skipped: [X]
|
||||
|
||||
## Issues Found
|
||||
|
||||
| Test | Error | Resolution |
|
||||
|------|-------|------------|
|
||||
| ... | ... | ... |
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [ ] Fix failed tests
|
||||
- [ ] Add more coverage for [module]
|
||||
- [ ] Review TEST_FIXTURE fixtures
|
||||
```
|
||||
|
||||
## Context for Testing
|
||||
|
||||
$ARGUMENTS
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -59,8 +59,9 @@ keyring passwords.py
|
||||
*github*
|
||||
|
||||
*tech_spec*
|
||||
dashboards
|
||||
backend/mappings.db
|
||||
/dashboards
|
||||
dashboards_example/**/dashboards/
|
||||
backend/mappings.db
|
||||
|
||||
|
||||
backend/tasks.db
|
||||
|
||||
103
.kilocode/workflows/audit-test.md
Normal file
103
.kilocode/workflows/audit-test.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
description: Audit AI-generated unit tests. Your goal is to aggressively search for "Test Tautologies", "Logic Echoing", and "Contract Negligence". You are the final gatekeeper. If a test is meaningless, you MUST reject it.
|
||||
---
|
||||
|
||||
**ROLE:** Elite Quality Assurance Architect and Red Teamer.
|
||||
**OBJECTIVE:** Audit AI-generated unit tests. Your goal is to aggressively search for "Test Tautologies", "Logic Echoing", and "Contract Negligence". You are the final gatekeeper. If a test is meaningless, you MUST reject it.
|
||||
|
||||
**INPUT:**
|
||||
1. SOURCE CODE (with GRACE-Poly `[DEF]` Contract: `@PRE`, `@POST`, `@TEST_CONTRACT`, `@TEST_FIXTURE`, `@TEST_EDGE`, `@TEST_INVARIANT`).
|
||||
2. GENERATED TEST CODE.
|
||||
|
||||
### I. CRITICAL ANTI-PATTERNS (REJECT IMMEDIATELY IF FOUND):
|
||||
|
||||
1. **The Tautology (Self-Fulfilling Prophecy):**
|
||||
- *Definition:* The test asserts hardcoded values against hardcoded values without executing the core business logic, or mocks the actual function being tested.
|
||||
- *Example of Failure:* `assert 2 + 2 == 4` or mocking the class under test so that it returns exactly what the test asserts.
|
||||
|
||||
2. **The Logic Mirror (Echoing):**
|
||||
- *Definition:* The test re-implements the exact same algorithmic logic found in the source code to calculate the `expected_result`. If the original logic is flawed, the test will falsely pass.
|
||||
- *Rule:* Tests must assert against **static, predefined outcomes** (from `@TEST_FIXTURE`, `@TEST_EDGE`, `@TEST_INVARIANT` or explicit constants), NOT dynamically calculated outcomes using the same logic as the source.
|
||||
|
||||
3. **The "Happy Path" Illusion:**
|
||||
- *Definition:* The test suite only checks successful executions but ignores the `@PRE` conditions (Negative Testing).
|
||||
- *Rule:* Every `@PRE` tag in the source contract MUST have a corresponding test that deliberately violates it and asserts the correct Exception/Error state.
|
||||
|
||||
4. **Missing Post-Condition Verification:**
|
||||
- *Definition:* The test calls the function but only checks the return value, ignoring `@SIDE_EFFECT` or `@POST` state changes (e.g., failing to verify that a DB call was made or a Store was updated).
|
||||
|
||||
5. **Missing Edge Case Coverage:**
|
||||
- *Definition:* The test suite ignores `@TEST_EDGE` scenarios defined in the contract.
|
||||
- *Rule:* Every `@TEST_EDGE` in the source contract MUST have a corresponding test case.
|
||||
|
||||
6. **Missing Invariant Verification:**
|
||||
- *Definition:* The test suite does not verify `@TEST_INVARIANT` conditions.
|
||||
- *Rule:* Every `@TEST_INVARIANT` MUST be verified by at least one test that attempts to break it.
|
||||
|
||||
7. **Missing UX State Testing (Svelte Components):**
|
||||
- *Definition:* For Svelte components with `@UX_STATE`, the test suite does not verify state transitions.
|
||||
- *Rule:* Every `@UX_STATE` transition MUST have a test verifying the visual/behavioral change.
|
||||
- *Check:* `@UX_FEEDBACK` mechanisms (toast, shake, color) must be tested.
|
||||
- *Check:* `@UX_RECOVERY` mechanisms (retry, clear input) must be tested.
|
||||
|
||||
### II. SEMANTIC PROTOCOL COMPLIANCE
|
||||
|
||||
Verify the test file follows GRACE-Poly semantics:
|
||||
|
||||
1. **Anchor Integrity:**
|
||||
- Test file MUST start with `[DEF:__tests__/test_name:Module]`
|
||||
- Test file MUST end with `[/DEF:__tests__/test_name:Module]`
|
||||
|
||||
2. **Required Tags:**
|
||||
- `@RELATION: VERIFIES -> <path_to_source>` must be present
|
||||
- `@PURPOSE:` must describe what is being tested
|
||||
|
||||
3. **TIER Alignment:**
|
||||
- If source is `@TIER: CRITICAL`, test MUST cover all `@TEST_CONTRACT`, `@TEST_FIXTURE`, `@TEST_EDGE`, `@TEST_INVARIANT`
|
||||
- If source is `@TIER: STANDARD`, test MUST cover `@PRE` and `@POST`
|
||||
- If source is `@TIER: TRIVIAL`, basic smoke test is acceptable
|
||||
|
||||
### III. AUDIT CHECKLIST
|
||||
|
||||
Evaluate the test code against these criteria:
|
||||
1. **Target Invocation:** Does the test actually import and call the function/component declared in the `@RELATION: VERIFIES` tag?
|
||||
2. **Contract Alignment:** Does the test suite cover 100% of the `@PRE` (negative tests) and `@POST` (assertions) conditions from the source contract?
|
||||
3. **Test Contract Compliance:** Does the test follow the interface defined in `@TEST_CONTRACT`?
|
||||
4. **Data Usage:** Does the test use the exact scenarios defined in `@TEST_FIXTURE`?
|
||||
5. **Edge Coverage:** Are all `@TEST_EDGE` scenarios tested?
|
||||
6. **Invariant Coverage:** Are all `@TEST_INVARIANT` conditions verified?
|
||||
7. **UX Coverage (if applicable):** Are all `@UX_STATE`, `@UX_FEEDBACK`, `@UX_RECOVERY` tested?
|
||||
8. **Mocking Sanity:** Are external dependencies mocked correctly WITHOUT mocking the system under test itself?
|
||||
9. **Semantic Anchor:** Does the test file have proper `[DEF]` and `[/DEF]` anchors?
|
||||
|
||||
### IV. OUTPUT FORMAT
|
||||
|
||||
You MUST respond strictly in the following JSON format. Do not add markdown blocks outside the JSON.
|
||||
|
||||
{
|
||||
"verdict": "APPROVED" | "REJECTED",
|
||||
"rejection_reason": "TAUTOLOGY" | "LOGIC_MIRROR" | "WEAK_CONTRACT_COVERAGE" | "OVER_MOCKED" | "MISSING_EDGES" | "MISSING_INVARIANTS" | "MISSING_UX_TESTS" | "SEMANTIC_VIOLATION" | "NONE",
|
||||
"audit_details": {
|
||||
"target_invoked": true/false,
|
||||
"pre_conditions_tested": true/false,
|
||||
"post_conditions_tested": true/false,
|
||||
"test_fixture_used": true/false,
|
||||
"edges_covered": true/false,
|
||||
"invariants_verified": true/false,
|
||||
"ux_states_tested": true/false,
|
||||
"semantic_anchors_present": true/false
|
||||
},
|
||||
"coverage_summary": {
|
||||
"total_edges": number,
|
||||
"edges_tested": number,
|
||||
"total_invariants": number,
|
||||
"invariants_tested": number,
|
||||
"total_ux_states": number,
|
||||
"ux_states_tested": number
|
||||
},
|
||||
"tier_compliance": {
|
||||
"source_tier": "CRITICAL" | "STANDARD" | "TRIVIAL",
|
||||
"meets_tier_requirements": true/false
|
||||
},
|
||||
"feedback": "Strict, actionable feedback for the test generator agent. Explain exactly which anti-pattern was detected and how to fix it."
|
||||
}
|
||||
@@ -20,7 +20,7 @@ Analyze test failure reports, identify root causes, and fix implementation issue
|
||||
|
||||
1. **USE CODER MODE**: Always switch to `coder` mode for code fixes
|
||||
2. **SEMANTIC PROTOCOL**: Never remove semantic annotations ([DEF], @TAGS). Only update code logic.
|
||||
3. **TEST DATA**: If tests use @TEST_DATA fixtures, preserve them when fixing
|
||||
3. **TEST DATA**: If tests use @TEST_ fixtures, preserve them when fixing
|
||||
4. **NO DELETION**: Never delete existing tests or semantic annotations
|
||||
5. **REPORT FIRST**: Always write a fix report before making changes
|
||||
|
||||
|
||||
@@ -117,7 +117,11 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- **Validation checkpoints**: Verify each phase completion before proceeding
|
||||
|
||||
7. Implementation execution rules:
|
||||
- **Strict Adherence**: Apply `.ai/standards/semantics.md` rules - every file must start with [DEF] header, include @TIER, and define contracts.
|
||||
- **Strict Adherence**: Apply `.ai/standards/semantics.md` rules:
|
||||
- Every file MUST start with a `[DEF:id:Type]` header and end with a closing `[/DEF:id:Type]` anchor.
|
||||
- Include `@TIER` and define contracts (`@PRE`, `@POST`).
|
||||
- For Svelte components, use `@UX_STATE`, `@UX_FEEDBACK`, `@UX_RECOVERY`, and explicitly declare reactivity with `@UX_REATIVITY: State: $state, Derived: $derived`.
|
||||
- **Molecular Topology Logging**: Use prefixes `[EXPLORE]`, `[REASON]`, `[REFLECT]` in logs to trace logic.
|
||||
- **CRITICAL Contracts**: If a task description contains a contract summary (e.g., `CRITICAL: PRE: ..., POST: ...`), these constraints are **MANDATORY** and must be strictly implemented in the code using guards/assertions (if applicable per protocol).
|
||||
- **Setup first**: Initialize project structure, dependencies, configuration
|
||||
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
|
||||
|
||||
@@ -73,13 +73,13 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- Entity name, fields, relationships, validation rules.
|
||||
|
||||
2. **Design & Verify Contracts (Semantic Protocol)**:
|
||||
- **Drafting**: Define [DEF] Headers and Contracts for all new modules based on `.ai/standards/semantics.md`.
|
||||
- **Drafting**: Define `[DEF:id:Type]` Headers, Contracts, and closing `[/DEF:id:Type]` for all new modules based on `.ai/standards/semantics.md`.
|
||||
- **TIER Classification**: Explicitly assign `@TIER: [CRITICAL|STANDARD|TRIVIAL]` to each module.
|
||||
- **CRITICAL Requirements**: For all CRITICAL modules, define full `@PRE`, `@POST`, and (if UI) `@UX_STATE` contracts.
|
||||
- **CRITICAL Requirements**: For all CRITICAL modules, define full `@PRE`, `@POST`, and (if UI) `@UX_STATE` contracts. **MUST** also define testing contracts: `@TEST_CONTRACT`, `@TEST_FIXTURE`, `@TEST_EDGE`, and `@TEST_INVARIANT`.
|
||||
- **Self-Review**:
|
||||
- *Completeness*: Do `@PRE`/`@POST` cover edge cases identified in Research?
|
||||
- *Completeness*: Do `@PRE`/`@POST` cover edge cases identified in Research? Are test contracts present for CRITICAL?
|
||||
- *Connectivity*: Do `@RELATION` tags form a coherent graph?
|
||||
- *Compliance*: Does syntax match `[DEF:id:Type]` exactly?
|
||||
- *Compliance*: Does syntax match `[DEF:id:Type]` exactly and is it closed with `[/DEF:id:Type]`?
|
||||
- **Output**: Write verified contracts to `contracts/modules.md`.
|
||||
|
||||
3. **Simulate Contract Usage**:
|
||||
|
||||
@@ -121,8 +121,8 @@ Every task MUST strictly follow this format:
|
||||
|
||||
2. **From Contracts (CRITICAL TIER)**:
|
||||
- Identify components marked as `@TIER: CRITICAL` in `contracts/modules.md`.
|
||||
- For these components, **MUST** append the summary of `@PRE`, `@POST`, and `@UX_STATE` contracts directly to the task description.
|
||||
- Example: `- [ ] T005 [P] [US1] Implement Auth (CRITICAL: PRE: token exists, POST: returns User) in src/auth.py`
|
||||
- For these components, **MUST** append the summary of `@PRE`, `@POST`, `@UX_STATE`, and test contracts (`@TEST_FIXTURE`, `@TEST_EDGE`) directly to the task description.
|
||||
- Example: `- [ ] T005 [P] [US1] Implement Auth (CRITICAL: PRE: token exists, POST: returns User, TESTS: 2 edges) in src/auth.py`
|
||||
- Map each contract/endpoint → to the user story it serves
|
||||
- If tests requested: Each contract → contract test task [P] before implementation in that story's phase
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Execute full testing cycle: analyze code for testable modules, write tests with
|
||||
|
||||
1. **NEVER delete existing tests** - Only update if they fail due to bugs in the test or implementation
|
||||
2. **NEVER duplicate tests** - Check existing tests first before creating new ones
|
||||
3. **Use TEST_DATA fixtures** - For CRITICAL tier modules, read @TEST_DATA from .ai/standards/semantics.md
|
||||
3. **Use TEST_FIXTURE fixtures** - For CRITICAL tier modules, read @TEST_FIXTURE from .ai/standards/semantics.md
|
||||
4. **Co-location required** - Write tests in `__tests__` directories relative to the code being tested
|
||||
|
||||
## Execution Steps
|
||||
@@ -42,7 +42,7 @@ Determine:
|
||||
|
||||
**From .ai/standards/semantics.md:**
|
||||
- Read @TIER annotations for modules
|
||||
- For CRITICAL modules: Read @TEST_DATA fixtures
|
||||
- For CRITICAL modules: Read @TEST_ fixtures
|
||||
|
||||
**From existing tests:**
|
||||
- Scan `__tests__` directories for existing tests
|
||||
@@ -52,8 +52,8 @@ Determine:
|
||||
|
||||
Create coverage matrix:
|
||||
|
||||
| Module | File | Has Tests | TIER | TEST_DATA Available |
|
||||
|--------|------|-----------|------|-------------------|
|
||||
| Module | File | Has Tests | TIER | TEST_FIXTURE Available |
|
||||
|--------|------|-----------|------|----------------------|
|
||||
| ... | ... | ... | ... | ... |
|
||||
|
||||
### 4. Write Tests (TDD Approach)
|
||||
@@ -61,7 +61,7 @@ Create coverage matrix:
|
||||
For each module requiring tests:
|
||||
|
||||
1. **Check existing tests**: Scan `__tests__/` for duplicates
|
||||
2. **Read TEST_DATA**: If CRITICAL tier, read @TEST_DATA from .ai/standards/semantics.md
|
||||
2. **Read TEST_FIXTURE**: If CRITICAL tier, read @TEST_FIXTURE from semantics header
|
||||
3. **Write test**: Follow co-location strategy
|
||||
- Python: `src/module/__tests__/test_module.py`
|
||||
- Svelte: `src/lib/components/__tests__/test_component.test.js`
|
||||
@@ -102,6 +102,7 @@ describe('Component UX States', () => {
|
||||
// @UX_RECOVERY: Retry on error
|
||||
it('should allow retry on error', async () => { ... });
|
||||
});
|
||||
// [/DEF:__tests__/test_Component:Module]
|
||||
```
|
||||
|
||||
### 5. Test Documentation
|
||||
@@ -170,7 +171,7 @@ Generate test execution report:
|
||||
|
||||
- [ ] Fix failed tests
|
||||
- [ ] Add more coverage for [module]
|
||||
- [ ] Review TEST_DATA fixtures
|
||||
- [ ] Review TEST_FIXTURE fixtures
|
||||
```
|
||||
|
||||
## Context for Testing
|
||||
|
||||
50
.specify/memory/constitution.md
Normal file
50
.specify/memory/constitution.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# [PROJECT_NAME] Constitution
|
||||
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
|
||||
|
||||
## Core Principles
|
||||
|
||||
### [PRINCIPLE_1_NAME]
|
||||
<!-- Example: I. Library-First -->
|
||||
[PRINCIPLE_1_DESCRIPTION]
|
||||
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
|
||||
|
||||
### [PRINCIPLE_2_NAME]
|
||||
<!-- Example: II. CLI Interface -->
|
||||
[PRINCIPLE_2_DESCRIPTION]
|
||||
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
|
||||
|
||||
### [PRINCIPLE_3_NAME]
|
||||
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
|
||||
[PRINCIPLE_3_DESCRIPTION]
|
||||
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
|
||||
|
||||
### [PRINCIPLE_4_NAME]
|
||||
<!-- Example: IV. Integration Testing -->
|
||||
[PRINCIPLE_4_DESCRIPTION]
|
||||
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
|
||||
|
||||
### [PRINCIPLE_5_NAME]
|
||||
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
|
||||
[PRINCIPLE_5_DESCRIPTION]
|
||||
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
|
||||
|
||||
## [SECTION_2_NAME]
|
||||
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
|
||||
|
||||
[SECTION_2_CONTENT]
|
||||
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
|
||||
|
||||
## [SECTION_3_NAME]
|
||||
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
|
||||
|
||||
[SECTION_3_CONTENT]
|
||||
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
|
||||
|
||||
## Governance
|
||||
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
|
||||
|
||||
[GOVERNANCE_RULES]
|
||||
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
|
||||
|
||||
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
|
||||
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
|
||||
@@ -30,12 +30,12 @@
|
||||
#
|
||||
# 5. Multi-Agent Support
|
||||
# - Handles agent-specific file paths and naming conventions
|
||||
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, or Amazon Q Developer CLI
|
||||
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Amazon Q Developer CLI, or Antigravity
|
||||
# - Can update single agents or all existing agent files
|
||||
# - Creates default Claude file if no agent files exist
|
||||
#
|
||||
# Usage: ./update-agent-context.sh [agent_type]
|
||||
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|shai|q|bob|qoder
|
||||
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli
|
||||
# Leave empty to update all existing agent files
|
||||
|
||||
set -e
|
||||
@@ -74,6 +74,7 @@ QODER_FILE="$REPO_ROOT/QODER.md"
|
||||
AMP_FILE="$REPO_ROOT/AGENTS.md"
|
||||
SHAI_FILE="$REPO_ROOT/SHAI.md"
|
||||
Q_FILE="$REPO_ROOT/AGENTS.md"
|
||||
AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md"
|
||||
BOB_FILE="$REPO_ROOT/AGENTS.md"
|
||||
|
||||
# Template file
|
||||
@@ -618,7 +619,7 @@ update_specific_agent() {
|
||||
codebuddy)
|
||||
update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI"
|
||||
;;
|
||||
qoder)
|
||||
qodercli)
|
||||
update_agent_file "$QODER_FILE" "Qoder CLI"
|
||||
;;
|
||||
amp)
|
||||
@@ -630,12 +631,18 @@ update_specific_agent() {
|
||||
q)
|
||||
update_agent_file "$Q_FILE" "Amazon Q Developer CLI"
|
||||
;;
|
||||
agy)
|
||||
update_agent_file "$AGY_FILE" "Antigravity"
|
||||
;;
|
||||
bob)
|
||||
update_agent_file "$BOB_FILE" "IBM Bob"
|
||||
;;
|
||||
generic)
|
||||
log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent."
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown agent type '$agent_type'"
|
||||
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|amp|shai|q|bob|qoder"
|
||||
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli|generic"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -714,7 +721,11 @@ update_all_existing_agents() {
|
||||
update_agent_file "$Q_FILE" "Amazon Q Developer CLI"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
|
||||
if [[ -f "$AGY_FILE" ]]; then
|
||||
update_agent_file "$AGY_FILE" "Antigravity"
|
||||
found_agent=true
|
||||
fi
|
||||
if [[ -f "$BOB_FILE" ]]; then
|
||||
update_agent_file "$BOB_FILE" "IBM Bob"
|
||||
found_agent=true
|
||||
@@ -744,7 +755,7 @@ print_summary() {
|
||||
|
||||
echo
|
||||
|
||||
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|codebuddy|shai|q|bob|qoder]"
|
||||
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli]"
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
|
||||
@@ -2,12 +2,6 @@
|
||||
|
||||
Auto-generated from all feature plans. Last updated: [DATE]
|
||||
|
||||
## Knowledge Graph (GRACE)
|
||||
**CRITICAL**: This project uses a GRACE Knowledge Graph for context. Always load the root map first:
|
||||
- **Root Map**: `.ai/ROOT.md` -> `[DEF:Project_Knowledge_Map:Root]`
|
||||
- **Project Map**: `.ai/PROJECT_MAP.md` -> `[DEF:Project_Map]`
|
||||
- **Standards**: Read `.ai/standards/` for architecture and style rules.
|
||||
|
||||
## Active Technologies
|
||||
|
||||
[EXTRACTED FROM ALL PLAN.MD FILES]
|
||||
|
||||
50
.specify/templates/constitution-template.md
Normal file
50
.specify/templates/constitution-template.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# [PROJECT_NAME] Constitution
|
||||
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
|
||||
|
||||
## Core Principles
|
||||
|
||||
### [PRINCIPLE_1_NAME]
|
||||
<!-- Example: I. Library-First -->
|
||||
[PRINCIPLE_1_DESCRIPTION]
|
||||
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
|
||||
|
||||
### [PRINCIPLE_2_NAME]
|
||||
<!-- Example: II. CLI Interface -->
|
||||
[PRINCIPLE_2_DESCRIPTION]
|
||||
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
|
||||
|
||||
### [PRINCIPLE_3_NAME]
|
||||
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
|
||||
[PRINCIPLE_3_DESCRIPTION]
|
||||
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
|
||||
|
||||
### [PRINCIPLE_4_NAME]
|
||||
<!-- Example: IV. Integration Testing -->
|
||||
[PRINCIPLE_4_DESCRIPTION]
|
||||
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
|
||||
|
||||
### [PRINCIPLE_5_NAME]
|
||||
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
|
||||
[PRINCIPLE_5_DESCRIPTION]
|
||||
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
|
||||
|
||||
## [SECTION_2_NAME]
|
||||
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
|
||||
|
||||
[SECTION_2_CONTENT]
|
||||
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
|
||||
|
||||
## [SECTION_3_NAME]
|
||||
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
|
||||
|
||||
[SECTION_3_CONTENT]
|
||||
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
|
||||
|
||||
## Governance
|
||||
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
|
||||
|
||||
[GOVERNANCE_RULES]
|
||||
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
|
||||
|
||||
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
|
||||
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
|
||||
@@ -3,7 +3,7 @@
|
||||
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
|
||||
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow.
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow.
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
the iteration process.
|
||||
-->
|
||||
|
||||
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]
|
||||
**Primary Dependencies**: [e.g., FastAPI, Tailwind CSS, SvelteKit or NEEDS CLARIFICATION]
|
||||
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]
|
||||
**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]
|
||||
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||
**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]
|
||||
**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]
|
||||
**Project Type**: [single/web/mobile - determines source structure]
|
||||
**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION]
|
||||
**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION]
|
||||
**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]
|
||||
**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]
|
||||
@@ -102,14 +102,3 @@ directories captured above]
|
||||
|-----------|------------|-------------------------------------|
|
||||
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||
|
||||
## Test Data Reference
|
||||
|
||||
> **For CRITICAL tier components, reference test fixtures from spec.md**
|
||||
|
||||
| Component | TIER | Fixture Name | Location |
|
||||
|-----------|------|--------------|----------|
|
||||
| [e.g., DashboardAPI] | CRITICAL | valid_dashboard | spec.md#test-data-fixtures |
|
||||
| [e.g., TaskDrawer] | CRITICAL | task_states | spec.md#test-data-fixtures |
|
||||
|
||||
**Note**: Tester Agent MUST use these fixtures when writing unit tests for CRITICAL modules. See `.ai/standards/semantics.md` for @TEST_DATA syntax.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Feature Specification: [FEATURE NAME]
|
||||
|
||||
**Feature Branch**: `[###-feature-name]`
|
||||
**Reference UX**: `[ux_reference.md]` (See specific folder)
|
||||
**Created**: [DATE]
|
||||
**Status**: Draft
|
||||
**Input**: User description: "$ARGUMENTS"
|
||||
@@ -114,52 +113,3 @@
|
||||
- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"]
|
||||
- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"]
|
||||
- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"]
|
||||
|
||||
---
|
||||
|
||||
## Test Data Fixtures *(recommended for CRITICAL components)*
|
||||
|
||||
<!--
|
||||
Define reference/fixture data for testing CRITICAL tier components.
|
||||
This data will be used by the Tester Agent when writing unit tests.
|
||||
Format: JSON or YAML that matches the component's data structures.
|
||||
-->
|
||||
|
||||
### Fixtures
|
||||
|
||||
```yaml
|
||||
# Example fixture format
|
||||
fixture_name:
|
||||
description: "Description of this test data"
|
||||
data:
|
||||
# JSON or YAML data structure
|
||||
```
|
||||
|
||||
### Example: Dashboard API
|
||||
|
||||
```yaml
|
||||
valid_dashboard:
|
||||
description: "Valid dashboard object for API responses"
|
||||
data:
|
||||
id: 1
|
||||
title: "Sales Report"
|
||||
slug: "sales"
|
||||
git_status:
|
||||
branch: "main"
|
||||
sync_status: "OK"
|
||||
last_task:
|
||||
task_id: "task-123"
|
||||
status: "SUCCESS"
|
||||
|
||||
empty_dashboards:
|
||||
description: "Empty dashboard list response"
|
||||
data:
|
||||
dashboards: []
|
||||
total: 0
|
||||
page: 1
|
||||
|
||||
error_not_found:
|
||||
description: "404 error response"
|
||||
data:
|
||||
detail: "Dashboard not found"
|
||||
```
|
||||
|
||||
@@ -93,8 +93,7 @@ Examples of foundational tasks (adjust based on your project):
|
||||
- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013)
|
||||
- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py
|
||||
- [ ] T016 [US1] Add validation and error handling
|
||||
- [ ] T017 [US1] [P] Implement UI using Tailwind CSS (minimize scoped styles)
|
||||
- [ ] T018 [US1] Add logging for user story 1 operations
|
||||
- [ ] T017 [US1] Add logging for user story 1 operations
|
||||
|
||||
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently
|
||||
|
||||
|
||||
213
README.md
213
README.md
@@ -1,128 +1,143 @@
|
||||
# Инструменты автоматизации Superset (ss-tools)
|
||||
|
||||
## Обзор
|
||||
**ss-tools** — это современная платформа для автоматизации и управления экосистемой Apache Superset. Проект перешел от набора CLI-скриптов к полноценному веб-приложению с архитектурой Backend (FastAPI) + Frontend (SvelteKit), обеспечивая удобный интерфейс для сложных операций.
|
||||
|
||||
## Основные возможности
|
||||
|
||||
### 🚀 Миграция и управление дашбордами
|
||||
- **Dashboard Grid**: Удобный просмотр всех дашбордов во всех окружениях (Dev, Sandbox, Prod) в едином интерфейсе.
|
||||
- **Интеллектуальный маппинг**: Автоматическое и ручное сопоставление датасетов, таблиц и схем при переносе между окружениями.
|
||||
- **Проверка зависимостей**: Валидация наличия всех необходимых компонентов перед миграцией.
|
||||
|
||||
### 📦 Резервное копирование
|
||||
- **Планировщик (Scheduler)**: Автоматическое создание резервных копий дашбордов и датасетов по расписанию.
|
||||
- **Хранилище**: Локальное хранение артефактов с возможностью управления через UI.
|
||||
|
||||
### 🛠 Git Интеграция
|
||||
- **Version Control**: Возможность версионирования ассетов Superset.
|
||||
- **Git Dashboard**: Управление ветками, коммитами и деплоем изменений напрямую из интерфейса.
|
||||
- **Conflict Resolution**: Встроенные инструменты для разрешения конфликтов в YAML-конфигурациях.
|
||||
|
||||
### 🤖 LLM Анализ (AI Plugin)
|
||||
- **Автоматический аудит**: Анализ состояния дашбордов на основе скриншотов и метаданных.
|
||||
- **Генерация документации**: Автоматическое описание датасетов и колонок с помощью LLM (OpenAI, OpenRouter и др.).
|
||||
- **Smart Validation**: Поиск аномалий и ошибок в визуализациях.
|
||||
|
||||
### 🔐 Безопасность и администрирование
|
||||
- **Multi-user Auth**: Многопользовательский доступ с ролевой моделью (RBAC).
|
||||
- **Управление подключениями**: Централизованная настройка доступов к различным инстансам Superset.
|
||||
- **Логирование**: Подробная история выполнения всех фоновых задач.
|
||||
|
||||
## Технологический стек
|
||||
- **Backend**: Python 3.9+, FastAPI, SQLAlchemy, APScheduler, Pydantic.
|
||||
- **Frontend**: Node.js 18+, SvelteKit, Tailwind CSS.
|
||||
- **Database**: PostgreSQL (для хранения метаданных, задач, логов и конфигурации).
|
||||
|
||||
## Структура проекта
|
||||
- `backend/` — Серверная часть, API и логика плагинов.
|
||||
- `frontend/` — Клиентская часть (SvelteKit приложение).
|
||||
- `specs/` — Спецификации функций и планы реализации.
|
||||
- `docs/` — Дополнительная документация по маппингу и разработке плагинов.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### Требования
|
||||
- Python 3.9+
|
||||
- Node.js 18+
|
||||
- Настроенный доступ к API Superset
|
||||
|
||||
### Запуск
|
||||
Для автоматической настройки окружений и запуска обоих серверов (Backend & Frontend) используйте скрипт:
|
||||
```bash
|
||||
./run.sh
|
||||
```
|
||||
*Скрипт создаст виртуальное окружение Python, установит зависимости `pip` и `npm`, и запустит сервисы.*
|
||||
|
||||
Опции:
|
||||
- `--skip-install`: Пропустить установку зависимостей.
|
||||
- `--help`: Показать справку.
|
||||
|
||||
Переменные окружения:
|
||||
- `BACKEND_PORT`: Порт API (по умолчанию 8000).
|
||||
- `FRONTEND_PORT`: Порт UI (по умолчанию 5173).
|
||||
- `POSTGRES_URL`: Базовый URL PostgreSQL по умолчанию для всех подсистем.
|
||||
- `DATABASE_URL`: URL основной БД (если не задан, используется `POSTGRES_URL`).
|
||||
- `TASKS_DATABASE_URL`: URL БД задач/логов (если не задан, используется `DATABASE_URL`).
|
||||
- `AUTH_DATABASE_URL`: URL БД авторизации (если не задан, используется PostgreSQL дефолт).
|
||||
|
||||
## Разработка
|
||||
Проект следует строгим правилам разработки:
|
||||
1. **Semantic Code Generation**: Использование протокола `.ai/standards/semantics.md` для обеспечения надежности кода.
|
||||
2. **Design by Contract (DbC)**: Определение предусловий и постусловий для ключевых функций.
|
||||
3. **Constitution**: Соблюдение правил, описанных в конституции проекта в папке `.specify/`.
|
||||
|
||||
### Полезные команды
|
||||
- **Backend**: `cd backend && .venv/bin/python3 -m uvicorn src.app:app --reload`
|
||||
- **Frontend**: `cd frontend && npm run dev`
|
||||
- **Тесты**: `cd backend && .venv/bin/pytest`
|
||||
# ss-tools
|
||||
|
||||
## Docker и CI/CD
|
||||
### Локальный запуск в Docker (приложение + PostgreSQL)
|
||||
Инструменты автоматизации для Apache Superset: миграция, маппинг, хранение артефактов, Git-интеграция, отчеты по задачам и LLM-assistant.
|
||||
|
||||
## Возможности
|
||||
- Миграция дашбордов и датасетов между окружениями.
|
||||
- Ручной и полуавтоматический маппинг ресурсов.
|
||||
- Логи фоновых задач и отчеты о выполнении.
|
||||
- Локальное хранилище файлов и бэкапов.
|
||||
- Git-операции по Superset-ассетам через UI.
|
||||
- Модуль LLM-анализа и assistant API.
|
||||
- Многопользовательская авторизация (RBAC).
|
||||
|
||||
## Стек
|
||||
- Backend: Python, FastAPI, SQLAlchemy, APScheduler.
|
||||
- Frontend: SvelteKit, Vite, Tailwind CSS.
|
||||
- База данных: PostgreSQL (основная конфигурация), поддержка миграции с legacy SQLite.
|
||||
|
||||
## Структура репозитория
|
||||
- `backend/` — API, плагины, сервисы, скрипты миграции и тесты.
|
||||
- `frontend/` — SPA-интерфейс (SvelteKit).
|
||||
- `docs/` — документация по архитектуре и плагинам.
|
||||
- `specs/` — спецификации и планы реализации.
|
||||
- `docker/` и `docker-compose.yml` — контейнеризация.
|
||||
|
||||
## Быстрый старт (локально)
|
||||
|
||||
### Требования
|
||||
- Python 3.9+
|
||||
- Node.js 18+
|
||||
- npm
|
||||
|
||||
### Запуск backend + frontend одним скриптом
|
||||
```bash
|
||||
./run.sh
|
||||
```
|
||||
|
||||
Что делает `run.sh`:
|
||||
- проверяет версии Python/npm;
|
||||
- создает `backend/.venv` (если нет);
|
||||
- устанавливает `backend/requirements.txt` и `frontend` зависимости;
|
||||
- запускает backend и frontend параллельно.
|
||||
|
||||
Опции:
|
||||
- `./run.sh --skip-install` — пропустить установку зависимостей.
|
||||
- `./run.sh --help` — показать справку.
|
||||
|
||||
Переменные окружения для локального запуска:
|
||||
- `BACKEND_PORT` (по умолчанию `8000`)
|
||||
- `FRONTEND_PORT` (по умолчанию `5173`)
|
||||
- `POSTGRES_URL`
|
||||
- `DATABASE_URL`
|
||||
- `TASKS_DATABASE_URL`
|
||||
- `AUTH_DATABASE_URL`
|
||||
|
||||
## Docker
|
||||
|
||||
### Запуск
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
После старта:
|
||||
- UI/API: `http://localhost:8000`
|
||||
- PostgreSQL: `localhost:5432` (`postgres/postgres`, DB `ss_tools`)
|
||||
После старта сервисы доступны по адресам:
|
||||
- Frontend: `http://localhost:8000`
|
||||
- Backend API: `http://localhost:8001`
|
||||
- PostgreSQL: `localhost:5432` (`postgres/postgres`, БД `ss_tools`)
|
||||
|
||||
Остановить:
|
||||
### Остановка
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Полная очистка тома БД:
|
||||
### Очистка БД-тома
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
Если `postgres:16-alpine` не тянется из Docker Hub (TLS timeout), используйте fallback image:
|
||||
### Альтернативный образ PostgreSQL
|
||||
Если есть проблемы с pull `postgres:16-alpine`:
|
||||
```bash
|
||||
POSTGRES_IMAGE=mirror.gcr.io/library/postgres:16-alpine docker compose up -d db
|
||||
```
|
||||
или:
|
||||
или
|
||||
```bash
|
||||
POSTGRES_IMAGE=bitnami/postgresql:latest docker compose up -d db
|
||||
```
|
||||
Если на хосте уже занят `5432`, поднимайте Postgres на другом порту:
|
||||
|
||||
Если порт `5432` занят:
|
||||
```bash
|
||||
POSTGRES_HOST_PORT=5433 docker compose up -d db
|
||||
```
|
||||
|
||||
### Миграция legacy-данных в PostgreSQL
|
||||
Если нужно перенести старые данные из `tasks.db`/`config.json`:
|
||||
## Разработка
|
||||
|
||||
### Ручной запуск сервисов
|
||||
```bash
|
||||
cd backend
|
||||
PYTHONPATH=. .venv/bin/python src/scripts/migrate_sqlite_to_postgres.py --sqlite-path tasks.db
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python3 -m uvicorn src.app:app --reload --port 8000
|
||||
```
|
||||
|
||||
### CI/CD
|
||||
Добавлен workflow: `.github/workflows/ci-cd.yml`
|
||||
- backend smoke tests
|
||||
- frontend build
|
||||
- docker build
|
||||
- push образа в GHCR на `main/master`
|
||||
В другом терминале:
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev -- --port 5173
|
||||
```
|
||||
|
||||
## Контакты и вклад
|
||||
Для добавления новых функций или исправления ошибок, пожалуйста, ознакомьтесь с `docs/plugin_dev.md` и создайте соответствующую спецификацию в `specs/`.
|
||||
### Тесты
|
||||
Backend:
|
||||
```bash
|
||||
cd backend
|
||||
source .venv/bin/activate
|
||||
pytest
|
||||
```
|
||||
|
||||
Frontend:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run test
|
||||
```
|
||||
|
||||
## Инициализация auth (опционально)
|
||||
```bash
|
||||
cd backend
|
||||
source .venv/bin/activate
|
||||
python src/scripts/init_auth_db.py
|
||||
python src/scripts/create_admin.py --username admin --password admin
|
||||
```
|
||||
|
||||
## Миграция legacy-данных (опционально)
|
||||
```bash
|
||||
cd backend
|
||||
source .venv/bin/activate
|
||||
PYTHONPATH=. python src/scripts/migrate_sqlite_to_postgres.py --sqlite-path tasks.db
|
||||
```
|
||||
|
||||
## Дополнительная документация
|
||||
- `docs/plugin_dev.md`
|
||||
- `docs/settings.md`
|
||||
- `semantic_protocol.md`
|
||||
|
||||
14
backend/conftest.py
Normal file
14
backend/conftest.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# conftest.py at backend root
|
||||
# Prevents pytest collection errors caused by duplicate test module names
|
||||
# between the root tests/ directory and co-located src/<module>/__tests__/ directories.
|
||||
# Without this, pytest sees e.g. tests/test_auth.py and src/core/auth/__tests__/test_auth.py
|
||||
# and raises "import file mismatch" because both map to module name "test_auth".
|
||||
|
||||
import os
|
||||
|
||||
# Files in tests/ that clash with __tests__/ co-located tests
|
||||
collect_ignore = [
|
||||
os.path.join("tests", "test_auth.py"),
|
||||
os.path.join("tests", "test_logger.py"),
|
||||
os.path.join("tests", "test_models.py"),
|
||||
]
|
||||
Submodule backend/git_repos/12 deleted from 57ab7e8679
148760
backend/logs/app.log.1
148760
backend/logs/app.log.1
File diff suppressed because it is too large
Load Diff
3
backend/pyproject.toml
Normal file
3
backend/pyproject.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
importmode = "importlib"
|
||||
@@ -1,10 +1,23 @@
|
||||
# Lazy loading of route modules to avoid import issues in tests
|
||||
# This allows tests to import routes without triggering all module imports
|
||||
|
||||
__all__ = ['plugins', 'tasks', 'settings', 'connections', 'environments', 'mappings', 'migration', 'git', 'storage', 'admin', 'reports']
|
||||
|
||||
def __getattr__(name):
|
||||
if name in __all__:
|
||||
import importlib
|
||||
return importlib.import_module(f".{name}", __name__)
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
# [DEF:backend.src.api.routes.__init__:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: routes, lazy-import, module-registry
|
||||
# @PURPOSE: Provide lazy route module loading to avoid heavyweight imports during tests.
|
||||
# @LAYER: API
|
||||
# @RELATION: DEPENDS_ON -> importlib
|
||||
# @INVARIANT: Only names listed in __all__ are importable via __getattr__.
|
||||
|
||||
__all__ = ['plugins', 'tasks', 'settings', 'connections', 'environments', 'mappings', 'migration', 'git', 'storage', 'admin', 'reports', 'assistant']
|
||||
|
||||
|
||||
# [DEF:__getattr__:Function]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Lazily import route module by attribute name.
|
||||
# @PRE: name is module candidate exposed in __all__.
|
||||
# @POST: Returns imported submodule or raises AttributeError.
|
||||
def __getattr__(name):
|
||||
if name in __all__:
|
||||
import importlib
|
||||
return importlib.import_module(f".{name}", __name__)
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
# [/DEF:__getattr__:Function]
|
||||
# [/DEF:backend.src.api.routes.__init__:Module]
|
||||
|
||||
697
backend/src/api/routes/__tests__/test_assistant_api.py
Normal file
697
backend/src/api/routes/__tests__/test_assistant_api.py
Normal file
@@ -0,0 +1,697 @@
|
||||
# [DEF:backend.src.api.routes.__tests__.test_assistant_api:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: tests, assistant, api, confirmation, status
|
||||
# @PURPOSE: Validate assistant API endpoint logic via direct async handler invocation.
|
||||
# @LAYER: UI (API Tests)
|
||||
# @RELATION: DEPENDS_ON -> backend.src.api.routes.assistant
|
||||
# @INVARIANT: Every test clears assistant in-memory state before execution.
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from datetime import datetime, timedelta
|
||||
import pytest
|
||||
|
||||
# Force isolated sqlite databases for test module before dependencies import.
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_api.db")
|
||||
os.environ.setdefault("TASKS_DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_tasks.db")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_auth.db")
|
||||
|
||||
from src.api.routes import assistant as assistant_module
|
||||
from src.models.assistant import (
|
||||
AssistantAuditRecord,
|
||||
AssistantConfirmationRecord,
|
||||
AssistantMessageRecord,
|
||||
)
|
||||
|
||||
|
||||
# [DEF:_run_async:Function]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Execute async endpoint handler in synchronous test context.
|
||||
# @PRE: coroutine is awaitable endpoint invocation.
|
||||
# @POST: Returns coroutine result or raises propagated exception.
|
||||
def _run_async(coroutine):
|
||||
return asyncio.run(coroutine)
|
||||
|
||||
|
||||
# [/DEF:_run_async:Function]
|
||||
# [DEF:_FakeTask:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Lightweight task stub used by assistant API tests.
|
||||
class _FakeTask:
|
||||
def __init__(self, task_id: str, status: str = "RUNNING", user_id: str = "u-admin"):
|
||||
self.id = task_id
|
||||
self.status = status
|
||||
self.user_id = user_id
|
||||
|
||||
|
||||
# [/DEF:_FakeTask:Class]
|
||||
# [DEF:_FakeTaskManager:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Minimal async-compatible TaskManager fixture for deterministic test flows.
|
||||
class _FakeTaskManager:
|
||||
def __init__(self):
|
||||
self._created = []
|
||||
|
||||
async def create_task(self, plugin_id, params, user_id=None):
|
||||
task_id = f"task-{len(self._created) + 1}"
|
||||
task = _FakeTask(task_id=task_id, status="RUNNING", user_id=user_id)
|
||||
self._created.append((plugin_id, params, user_id, task))
|
||||
return task
|
||||
|
||||
def get_task(self, task_id):
|
||||
for _, _, _, task in self._created:
|
||||
if task.id == task_id:
|
||||
return task
|
||||
return None
|
||||
|
||||
def get_tasks(self, limit=20, offset=0):
|
||||
return [x[3] for x in self._created][offset : offset + limit]
|
||||
|
||||
|
||||
# [/DEF:_FakeTaskManager:Class]
|
||||
# [DEF:_FakeConfigManager:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Environment config fixture with dev/prod aliases for parser tests.
|
||||
class _FakeConfigManager:
|
||||
def get_environments(self):
|
||||
return [
|
||||
SimpleNamespace(id="dev", name="Development", url="http://dev", credentials_id="dev", username="fakeuser", password="fakepassword"),
|
||||
SimpleNamespace(id="prod", name="Production", url="http://prod", credentials_id="prod", username="fakeuser", password="fakepassword"),
|
||||
]
|
||||
|
||||
def get_config(self):
|
||||
return SimpleNamespace(
|
||||
settings=SimpleNamespace(migration_sync_cron="0 0 * * *"),
|
||||
environments=self.get_environments()
|
||||
)
|
||||
# [/DEF:_FakeConfigManager:Class]
|
||||
# [DEF:_admin_user:Function]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Build admin principal fixture.
|
||||
# @PRE: Test harness requires authenticated admin-like principal object.
|
||||
# @POST: Returns user stub with Admin role.
|
||||
def _admin_user():
|
||||
role = SimpleNamespace(name="Admin", permissions=[])
|
||||
return SimpleNamespace(id="u-admin", username="admin", roles=[role])
|
||||
|
||||
|
||||
# [/DEF:_admin_user:Function]
|
||||
# [DEF:_limited_user:Function]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Build non-admin principal fixture.
|
||||
# @PRE: Test harness requires restricted principal for deny scenarios.
|
||||
# @POST: Returns user stub without admin privileges.
|
||||
def _limited_user():
|
||||
role = SimpleNamespace(name="Operator", permissions=[])
|
||||
return SimpleNamespace(id="u-limited", username="limited", roles=[role])
|
||||
|
||||
|
||||
# [/DEF:_limited_user:Function]
|
||||
# [DEF:_FakeQuery:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Minimal chainable query object for fake SQLAlchemy-like DB behavior in tests.
|
||||
class _FakeQuery:
|
||||
def __init__(self, rows):
|
||||
self._rows = list(rows)
|
||||
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def order_by(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
def all(self):
|
||||
return list(self._rows)
|
||||
|
||||
def count(self):
|
||||
return len(self._rows)
|
||||
|
||||
def offset(self, offset):
|
||||
self._rows = self._rows[offset:]
|
||||
return self
|
||||
|
||||
def limit(self, limit):
|
||||
self._rows = self._rows[:limit]
|
||||
return self
|
||||
|
||||
|
||||
# [/DEF:_FakeQuery:Class]
|
||||
# [DEF:_FakeDb:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: In-memory fake database implementing subset of Session interface used by assistant routes.
|
||||
class _FakeDb:
|
||||
def __init__(self):
|
||||
self._messages = []
|
||||
self._confirmations = []
|
||||
self._audit = []
|
||||
|
||||
def add(self, row):
|
||||
table = getattr(row, "__tablename__", "")
|
||||
if table == "assistant_messages":
|
||||
self._messages.append(row)
|
||||
return
|
||||
if table == "assistant_confirmations":
|
||||
self._confirmations.append(row)
|
||||
return
|
||||
if table == "assistant_audit":
|
||||
self._audit.append(row)
|
||||
|
||||
def merge(self, row):
|
||||
table = getattr(row, "__tablename__", "")
|
||||
if table != "assistant_confirmations":
|
||||
self.add(row)
|
||||
return row
|
||||
|
||||
for i, existing in enumerate(self._confirmations):
|
||||
if getattr(existing, "id", None) == getattr(row, "id", None):
|
||||
self._confirmations[i] = row
|
||||
return row
|
||||
self._confirmations.append(row)
|
||||
return row
|
||||
|
||||
def query(self, model):
|
||||
if model is AssistantMessageRecord:
|
||||
return _FakeQuery(self._messages)
|
||||
if model is AssistantConfirmationRecord:
|
||||
return _FakeQuery(self._confirmations)
|
||||
if model is AssistantAuditRecord:
|
||||
return _FakeQuery(self._audit)
|
||||
return _FakeQuery([])
|
||||
|
||||
def commit(self):
|
||||
return None
|
||||
|
||||
def rollback(self):
|
||||
return None
|
||||
|
||||
|
||||
# [/DEF:_FakeDb:Class]
|
||||
# [DEF:_clear_assistant_state:Function]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Reset in-memory assistant registries for isolation between tests.
|
||||
# @PRE: Assistant module globals may contain residues from previous test runs.
|
||||
# @POST: In-memory conversation/confirmation/audit dictionaries are empty.
|
||||
def _clear_assistant_state():
|
||||
assistant_module.CONVERSATIONS.clear()
|
||||
assistant_module.USER_ACTIVE_CONVERSATION.clear()
|
||||
assistant_module.CONFIRMATIONS.clear()
|
||||
assistant_module.ASSISTANT_AUDIT.clear()
|
||||
|
||||
|
||||
# [/DEF:_clear_assistant_state:Function]
|
||||
# [DEF:test_unknown_command_returns_needs_clarification:Function]
|
||||
# @PURPOSE: Unknown command should return clarification state and unknown intent.
|
||||
# @PRE: Fake dependencies provide admin user and deterministic task/config/db services.
|
||||
# @POST: Response state is needs_clarification and no execution side-effect occurs.
|
||||
def test_unknown_command_returns_needs_clarification():
|
||||
_clear_assistant_state()
|
||||
response = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(message="сделай что-нибудь"),
|
||||
current_user=_admin_user(),
|
||||
task_manager=_FakeTaskManager(),
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=_FakeDb(),
|
||||
)
|
||||
)
|
||||
assert response.state == "needs_clarification"
|
||||
assert response.intent["domain"] == "unknown"
|
||||
|
||||
|
||||
# [/DEF:test_unknown_command_returns_needs_clarification:Function]
|
||||
|
||||
|
||||
# [DEF:test_capabilities_question_returns_successful_help:Function]
|
||||
# @PURPOSE: Capability query should return deterministic help response, not clarification.
|
||||
# @PRE: User sends natural-language "what can you do" style query.
|
||||
# @POST: Response is successful and includes capabilities summary.
|
||||
def test_capabilities_question_returns_successful_help():
|
||||
_clear_assistant_state()
|
||||
response = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(message="Что ты умеешь?"),
|
||||
current_user=_admin_user(),
|
||||
task_manager=_FakeTaskManager(),
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=_FakeDb(),
|
||||
)
|
||||
)
|
||||
assert response.state == "success"
|
||||
assert "Вот что я могу сделать" in response.text
|
||||
assert "Миграции" in response.text or "Git" in response.text
|
||||
|
||||
|
||||
# [/DEF:test_capabilities_question_returns_successful_help:Function]
|
||||
# [DEF:test_non_admin_command_returns_denied:Function]
|
||||
# @PURPOSE: Non-admin user must receive denied state for privileged command.
|
||||
# @PRE: Limited principal executes privileged git branch command.
|
||||
# @POST: Response state is denied and operation is not executed.
|
||||
def test_non_admin_command_returns_denied():
|
||||
_clear_assistant_state()
|
||||
response = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message="создай ветку feature/test для дашборда 12"
|
||||
),
|
||||
current_user=_limited_user(),
|
||||
task_manager=_FakeTaskManager(),
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=_FakeDb(),
|
||||
)
|
||||
)
|
||||
assert response.state == "denied"
|
||||
|
||||
|
||||
# [/DEF:test_non_admin_command_returns_denied:Function]
|
||||
# [DEF:test_migration_to_prod_requires_confirmation_and_can_be_confirmed:Function]
|
||||
# @PURPOSE: Migration to prod must require confirmation and then start task after explicit confirm.
|
||||
# @PRE: Admin principal submits dangerous migration command.
|
||||
# @POST: Confirmation endpoint transitions flow to started state with task id.
|
||||
def test_migration_to_prod_requires_confirmation_and_can_be_confirmed():
|
||||
_clear_assistant_state()
|
||||
task_manager = _FakeTaskManager()
|
||||
db = _FakeDb()
|
||||
|
||||
first = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message="запусти миграцию с dev на prod для дашборда 12"
|
||||
),
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
assert first.state == "needs_confirmation"
|
||||
assert first.confirmation_id
|
||||
|
||||
second = _run_async(
|
||||
assistant_module.confirm_operation(
|
||||
confirmation_id=first.confirmation_id,
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
assert second.state == "started"
|
||||
assert second.task_id.startswith("task-")
|
||||
|
||||
|
||||
# [/DEF:test_migration_to_prod_requires_confirmation_and_can_be_confirmed:Function]
|
||||
# [DEF:test_status_query_returns_task_status:Function]
|
||||
# @PURPOSE: Task status command must surface current status text for existing task id.
|
||||
# @PRE: At least one task exists after confirmed operation.
|
||||
# @POST: Status query returns started/success and includes referenced task id.
|
||||
def test_status_query_returns_task_status():
|
||||
_clear_assistant_state()
|
||||
task_manager = _FakeTaskManager()
|
||||
db = _FakeDb()
|
||||
|
||||
start = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message="запусти миграцию с dev на prod для дашборда 10"
|
||||
),
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
confirm = _run_async(
|
||||
assistant_module.confirm_operation(
|
||||
confirmation_id=start.confirmation_id,
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
task_id = confirm.task_id
|
||||
|
||||
status_resp = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message=f"проверь статус задачи {task_id}"
|
||||
),
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
assert status_resp.state in {"started", "success"}
|
||||
assert task_id in status_resp.text
|
||||
|
||||
|
||||
# [/DEF:test_status_query_returns_task_status:Function]
|
||||
# [DEF:test_status_query_without_task_id_returns_latest_user_task:Function]
|
||||
# @PURPOSE: Status command without explicit task_id should resolve to latest task for current user.
|
||||
# @PRE: User has at least one created task in task manager history.
|
||||
# @POST: Response references latest task status without explicit task id in command.
|
||||
def test_status_query_without_task_id_returns_latest_user_task():
|
||||
_clear_assistant_state()
|
||||
task_manager = _FakeTaskManager()
|
||||
db = _FakeDb()
|
||||
|
||||
start = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message="запусти миграцию с dev на prod для дашборда 33"
|
||||
),
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
_run_async(
|
||||
assistant_module.confirm_operation(
|
||||
confirmation_id=start.confirmation_id,
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
|
||||
status_resp = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message="покажи статус последней задачи"
|
||||
),
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
assert status_resp.state in {"started", "success"}
|
||||
assert "Последняя задача:" in status_resp.text
|
||||
|
||||
|
||||
# [/DEF:test_status_query_without_task_id_returns_latest_user_task:Function]
|
||||
# [DEF:test_llm_validation_with_dashboard_ref_requires_confirmation:Function]
|
||||
# @PURPOSE: LLM validation with dashboard_ref should now require confirmation before dispatch.
|
||||
# @PRE: User sends natural-language validation request with dashboard name (not numeric id).
|
||||
# @POST: Response state is needs_confirmation since all state-changing operations are now gated.
|
||||
def test_llm_validation_with_dashboard_ref_requires_confirmation():
|
||||
_clear_assistant_state()
|
||||
response = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message="Я хочу сделать валидацию дашборда test1"
|
||||
),
|
||||
current_user=_admin_user(),
|
||||
task_manager=_FakeTaskManager(),
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=_FakeDb(),
|
||||
)
|
||||
)
|
||||
|
||||
assert response.state == "needs_confirmation"
|
||||
assert response.confirmation_id is not None
|
||||
action_types = {a.type for a in response.actions}
|
||||
assert "confirm" in action_types
|
||||
assert "cancel" in action_types
|
||||
|
||||
|
||||
# [/DEF:test_llm_validation_missing_dashboard_returns_needs_clarification:Function]
|
||||
|
||||
|
||||
# [DEF:test_list_conversations_groups_by_conversation_and_marks_archived:Function]
|
||||
# @PURPOSE: Conversations endpoint must group messages and compute archived marker by inactivity threshold.
|
||||
# @PRE: Fake DB contains two conversations with different update timestamps.
|
||||
# @POST: Response includes both conversations with archived flag set for stale one.
|
||||
def test_list_conversations_groups_by_conversation_and_marks_archived():
|
||||
_clear_assistant_state()
|
||||
db = _FakeDb()
|
||||
now = datetime.utcnow()
|
||||
|
||||
db.add(
|
||||
AssistantMessageRecord(
|
||||
id="m-1",
|
||||
user_id="u-admin",
|
||||
conversation_id="conv-active",
|
||||
role="user",
|
||||
text="active chat",
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
AssistantMessageRecord(
|
||||
id="m-2",
|
||||
user_id="u-admin",
|
||||
conversation_id="conv-old",
|
||||
role="user",
|
||||
text="old chat",
|
||||
created_at=now - timedelta(days=32), # Hardcoded threshold+2
|
||||
)
|
||||
)
|
||||
|
||||
result = _run_async(
|
||||
assistant_module.list_conversations(
|
||||
page=1,
|
||||
page_size=20,
|
||||
include_archived=True,
|
||||
search=None,
|
||||
current_user=_admin_user(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
|
||||
assert result["total"] == 2
|
||||
by_id = {item["conversation_id"]: item for item in result["items"]}
|
||||
assert by_id["conv-active"]["archived"] is False
|
||||
assert by_id["conv-old"]["archived"] is True
|
||||
|
||||
|
||||
# [/DEF:test_list_conversations_groups_by_conversation_and_marks_archived:Function]
|
||||
|
||||
|
||||
# [DEF:test_history_from_latest_returns_recent_page_first:Function]
|
||||
# @PURPOSE: History endpoint from_latest mode must return newest page while preserving chronological order in chunk.
|
||||
# @PRE: Conversation has more messages than single page size.
|
||||
# @POST: First page returns latest messages and has_next indicates older pages exist.
|
||||
def test_history_from_latest_returns_recent_page_first():
|
||||
_clear_assistant_state()
|
||||
db = _FakeDb()
|
||||
base_time = datetime.utcnow() - timedelta(minutes=10)
|
||||
conv_id = "conv-paginated"
|
||||
for i in range(4, -1, -1):
|
||||
db.add(
|
||||
AssistantMessageRecord(
|
||||
id=f"msg-{i}",
|
||||
user_id="u-admin",
|
||||
conversation_id=conv_id,
|
||||
role="user" if i % 2 == 0 else "assistant",
|
||||
text=f"message-{i}",
|
||||
created_at=base_time + timedelta(minutes=i),
|
||||
)
|
||||
)
|
||||
|
||||
result = _run_async(
|
||||
assistant_module.get_history(
|
||||
page=1,
|
||||
page_size=2,
|
||||
conversation_id=conv_id,
|
||||
from_latest=True,
|
||||
current_user=_admin_user(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
|
||||
assert result["from_latest"] is True
|
||||
assert result["has_next"] is True
|
||||
# Chunk is chronological while representing latest page.
|
||||
assert [item["text"] for item in result["items"]] == ["message-3", "message-4"]
|
||||
|
||||
|
||||
# [/DEF:test_history_from_latest_returns_recent_page_first:Function]
|
||||
|
||||
|
||||
# [DEF:test_list_conversations_archived_only_filters_active:Function]
|
||||
# @PURPOSE: archived_only mode must return only archived conversations.
|
||||
# @PRE: Dataset includes one active and one archived conversation.
|
||||
# @POST: Only archived conversation remains in response payload.
|
||||
def test_list_conversations_archived_only_filters_active():
|
||||
_clear_assistant_state()
|
||||
db = _FakeDb()
|
||||
now = datetime.utcnow()
|
||||
db.add(
|
||||
AssistantMessageRecord(
|
||||
id="m-active",
|
||||
user_id="u-admin",
|
||||
conversation_id="conv-active-2",
|
||||
role="user",
|
||||
text="active",
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
AssistantMessageRecord(
|
||||
id="m-archived",
|
||||
user_id="u-admin",
|
||||
conversation_id="conv-archived-2",
|
||||
role="user",
|
||||
text="archived",
|
||||
created_at=now - timedelta(days=33), # Hardcoded threshold+3
|
||||
)
|
||||
)
|
||||
|
||||
result = _run_async(
|
||||
assistant_module.list_conversations(
|
||||
page=1,
|
||||
page_size=20,
|
||||
include_archived=True,
|
||||
archived_only=True,
|
||||
search=None,
|
||||
current_user=_admin_user(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
|
||||
assert result["total"] == 1
|
||||
assert result["items"][0]["conversation_id"] == "conv-archived-2"
|
||||
assert result["items"][0]["archived"] is True
|
||||
|
||||
|
||||
# [/DEF:test_list_conversations_archived_only_filters_active:Function]
|
||||
|
||||
|
||||
# [DEF:test_guarded_operation_always_requires_confirmation:Function]
|
||||
# @PURPOSE: Non-dangerous (guarded) commands must still require confirmation before execution.
|
||||
# @PRE: Admin user sends a backup command that was previously auto-executed.
|
||||
# @POST: Response state is needs_confirmation with confirm and cancel actions.
|
||||
def test_guarded_operation_always_requires_confirmation():
|
||||
_clear_assistant_state()
|
||||
response = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message="сделай бэкап окружения dev"
|
||||
),
|
||||
current_user=_admin_user(),
|
||||
task_manager=_FakeTaskManager(),
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=_FakeDb(),
|
||||
)
|
||||
)
|
||||
assert response.state == "needs_confirmation"
|
||||
assert response.confirmation_id is not None
|
||||
action_types = {a.type for a in response.actions}
|
||||
assert "confirm" in action_types
|
||||
assert "cancel" in action_types
|
||||
assert "Выполнить" in response.text or "Подтвердите" in response.text
|
||||
|
||||
|
||||
# [/DEF:test_guarded_operation_always_requires_confirmation:Function]
|
||||
|
||||
|
||||
# [DEF:test_guarded_operation_confirm_roundtrip:Function]
|
||||
# @PURPOSE: Guarded operation must execute successfully after explicit confirmation.
|
||||
# @PRE: Admin user sends a non-dangerous migration command (dev → dev).
|
||||
# @POST: After confirmation, response transitions to started/success with task_id.
|
||||
def test_guarded_operation_confirm_roundtrip():
|
||||
_clear_assistant_state()
|
||||
task_manager = _FakeTaskManager()
|
||||
db = _FakeDb()
|
||||
|
||||
first = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message="запусти миграцию с dev на dev для дашборда 5"
|
||||
),
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
assert first.state == "needs_confirmation"
|
||||
assert first.confirmation_id
|
||||
|
||||
second = _run_async(
|
||||
assistant_module.confirm_operation(
|
||||
confirmation_id=first.confirmation_id,
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
assert second.state == "started"
|
||||
assert second.task_id is not None
|
||||
|
||||
|
||||
# [DEF:test_confirm_nonexistent_id_returns_404:Function]
|
||||
# @PURPOSE: Confirming a non-existent ID should raise 404.
|
||||
# @PRE: user tries to confirm a random/fake UUID.
|
||||
# @POST: FastAPI HTTPException with status 404.
|
||||
def test_confirm_nonexistent_id_returns_404():
|
||||
from fastapi import HTTPException
|
||||
_clear_assistant_state()
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_run_async(
|
||||
assistant_module.confirm_operation(
|
||||
confirmation_id="non-existent-id",
|
||||
current_user=_admin_user(),
|
||||
task_manager=_FakeTaskManager(),
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=_FakeDb(),
|
||||
)
|
||||
)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
# [DEF:test_migration_with_dry_run_includes_summary:Function]
|
||||
# @PURPOSE: Migration command with dry run flag must return the dry run summary in confirmation text.
|
||||
# @PRE: user specifies a migration with --dry-run flag.
|
||||
# @POST: Response state is needs_confirmation and text contains dry-run summary counts.
|
||||
def test_migration_with_dry_run_includes_summary(monkeypatch):
|
||||
import src.core.migration.dry_run_orchestrator as dry_run_module
|
||||
from unittest.mock import MagicMock
|
||||
_clear_assistant_state()
|
||||
task_manager = _FakeTaskManager()
|
||||
db = _FakeDb()
|
||||
|
||||
class _FakeDryRunService:
|
||||
def run(self, selection, source_client, target_client, db_session):
|
||||
return {
|
||||
"summary": {
|
||||
"dashboards": {"create": 1, "update": 0, "delete": 0},
|
||||
"charts": {"create": 3, "update": 2, "delete": 1},
|
||||
"datasets": {"create": 0, "update": 1, "delete": 0}
|
||||
}
|
||||
}
|
||||
|
||||
monkeypatch.setattr(dry_run_module, "MigrationDryRunService", _FakeDryRunService)
|
||||
|
||||
import src.core.superset_client as superset_client_module
|
||||
monkeypatch.setattr(superset_client_module, "SupersetClient", lambda env: MagicMock())
|
||||
|
||||
start = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message="миграция с dev на prod для дашборда 10 --dry-run"
|
||||
),
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
|
||||
assert start.state == "needs_confirmation"
|
||||
assert "отчет dry-run: ВКЛ" in start.text
|
||||
assert "Отчет dry-run:" in start.text
|
||||
assert "создано новых объектов: 4" in start.text
|
||||
assert "обновлено: 3" in start.text
|
||||
assert "удалено: 1" in start.text
|
||||
# [/DEF:test_migration_with_dry_run_includes_summary:Function]
|
||||
# [/DEF:backend.src.api.routes.__tests__.test_assistant_api:Module]
|
||||
306
backend/src/api/routes/__tests__/test_assistant_authz.py
Normal file
306
backend/src/api/routes/__tests__/test_assistant_authz.py
Normal file
@@ -0,0 +1,306 @@
|
||||
# [DEF:backend.src.api.routes.__tests__.test_assistant_authz:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: tests, assistant, authz, confirmation, rbac
|
||||
# @PURPOSE: Verify assistant confirmation ownership, expiration, and deny behavior for restricted users.
|
||||
# @LAYER: UI (API Tests)
|
||||
# @RELATION: DEPENDS_ON -> backend.src.api.routes.assistant
|
||||
# @INVARIANT: Security-sensitive flows fail closed for unauthorized actors.
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Force isolated sqlite databases for test module before dependencies import.
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_authz.db")
|
||||
os.environ.setdefault("TASKS_DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_authz_tasks.db")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_authz_auth.db")
|
||||
|
||||
from src.api.routes import assistant as assistant_module
|
||||
from src.models.assistant import (
|
||||
AssistantAuditRecord,
|
||||
AssistantConfirmationRecord,
|
||||
AssistantMessageRecord,
|
||||
)
|
||||
|
||||
|
||||
# [DEF:_run_async:Function]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Execute async endpoint handler in synchronous test context.
|
||||
# @PRE: coroutine is awaitable endpoint invocation.
|
||||
# @POST: Returns coroutine result or raises propagated exception.
|
||||
def _run_async(coroutine):
|
||||
return asyncio.run(coroutine)
|
||||
|
||||
|
||||
# [/DEF:_run_async:Function]
|
||||
# [DEF:_FakeTask:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Lightweight task model used for assistant authz tests.
|
||||
class _FakeTask:
|
||||
def __init__(self, task_id: str, status: str = "RUNNING", user_id: str = "u-admin"):
|
||||
self.id = task_id
|
||||
self.status = status
|
||||
self.user_id = user_id
|
||||
|
||||
|
||||
# [/DEF:_FakeTask:Class]
|
||||
# [DEF:_FakeTaskManager:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Minimal task manager for deterministic operation creation and lookup.
|
||||
class _FakeTaskManager:
|
||||
def __init__(self):
|
||||
self._created = []
|
||||
|
||||
async def create_task(self, plugin_id, params, user_id=None):
|
||||
task_id = f"task-{len(self._created) + 1}"
|
||||
task = _FakeTask(task_id=task_id, status="RUNNING", user_id=user_id)
|
||||
self._created.append((plugin_id, params, user_id, task))
|
||||
return task
|
||||
|
||||
def get_task(self, task_id):
|
||||
for _, _, _, task in self._created:
|
||||
if task.id == task_id:
|
||||
return task
|
||||
return None
|
||||
|
||||
def get_tasks(self, limit=20, offset=0):
|
||||
return [x[3] for x in self._created][offset : offset + limit]
|
||||
|
||||
|
||||
# [/DEF:_FakeTaskManager:Class]
|
||||
# [DEF:_FakeConfigManager:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Provide deterministic environment aliases required by intent parsing.
|
||||
class _FakeConfigManager:
|
||||
def get_environments(self):
|
||||
return [
|
||||
SimpleNamespace(id="dev", name="Development"),
|
||||
SimpleNamespace(id="prod", name="Production"),
|
||||
]
|
||||
|
||||
|
||||
# [/DEF:_FakeConfigManager:Class]
|
||||
# [DEF:_admin_user:Function]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Build admin principal fixture.
|
||||
# @PRE: Test requires privileged principal for risky operations.
|
||||
# @POST: Returns admin-like user stub with Admin role.
|
||||
def _admin_user():
|
||||
role = SimpleNamespace(name="Admin", permissions=[])
|
||||
return SimpleNamespace(id="u-admin", username="admin", roles=[role])
|
||||
|
||||
|
||||
# [/DEF:_admin_user:Function]
|
||||
# [DEF:_other_admin_user:Function]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Build second admin principal fixture for ownership tests.
|
||||
# @PRE: Ownership mismatch scenario needs distinct authenticated actor.
|
||||
# @POST: Returns alternate admin-like user stub.
|
||||
def _other_admin_user():
|
||||
role = SimpleNamespace(name="Admin", permissions=[])
|
||||
return SimpleNamespace(id="u-admin-2", username="admin2", roles=[role])
|
||||
|
||||
|
||||
# [/DEF:_other_admin_user:Function]
|
||||
# [DEF:_limited_user:Function]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Build limited principal without required assistant execution privileges.
|
||||
# @PRE: Permission denial scenario needs non-admin actor.
|
||||
# @POST: Returns restricted user stub.
|
||||
def _limited_user():
|
||||
role = SimpleNamespace(name="Operator", permissions=[])
|
||||
return SimpleNamespace(id="u-limited", username="limited", roles=[role])
|
||||
|
||||
|
||||
# [/DEF:_limited_user:Function]
|
||||
# [DEF:_FakeQuery:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Minimal chainable query object for fake DB interactions.
|
||||
class _FakeQuery:
|
||||
def __init__(self, rows):
|
||||
self._rows = list(rows)
|
||||
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def order_by(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
def all(self):
|
||||
return list(self._rows)
|
||||
|
||||
def limit(self, limit):
|
||||
self._rows = self._rows[:limit]
|
||||
return self
|
||||
|
||||
def offset(self, offset):
|
||||
self._rows = self._rows[offset:]
|
||||
return self
|
||||
|
||||
def count(self):
|
||||
return len(self._rows)
|
||||
|
||||
|
||||
# [/DEF:_FakeQuery:Class]
|
||||
# [DEF:_FakeDb:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: In-memory session substitute for assistant route persistence calls.
|
||||
class _FakeDb:
|
||||
def __init__(self):
|
||||
self._messages = []
|
||||
self._confirmations = []
|
||||
self._audit = []
|
||||
|
||||
def add(self, row):
|
||||
table = getattr(row, "__tablename__", "")
|
||||
if table == "assistant_messages":
|
||||
self._messages.append(row)
|
||||
elif table == "assistant_confirmations":
|
||||
self._confirmations.append(row)
|
||||
elif table == "assistant_audit":
|
||||
self._audit.append(row)
|
||||
|
||||
def merge(self, row):
|
||||
if getattr(row, "__tablename__", "") != "assistant_confirmations":
|
||||
self.add(row)
|
||||
return row
|
||||
|
||||
for i, existing in enumerate(self._confirmations):
|
||||
if getattr(existing, "id", None) == getattr(row, "id", None):
|
||||
self._confirmations[i] = row
|
||||
return row
|
||||
self._confirmations.append(row)
|
||||
return row
|
||||
|
||||
def query(self, model):
|
||||
if model is AssistantMessageRecord:
|
||||
return _FakeQuery(self._messages)
|
||||
if model is AssistantConfirmationRecord:
|
||||
return _FakeQuery(self._confirmations)
|
||||
if model is AssistantAuditRecord:
|
||||
return _FakeQuery(self._audit)
|
||||
return _FakeQuery([])
|
||||
|
||||
def commit(self):
|
||||
return None
|
||||
|
||||
def rollback(self):
|
||||
return None
|
||||
|
||||
|
||||
# [/DEF:_FakeDb:Class]
|
||||
# [DEF:_clear_assistant_state:Function]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Reset assistant process-local state between test cases.
|
||||
# @PRE: Assistant globals may contain state from prior tests.
|
||||
# @POST: Assistant in-memory state dictionaries are cleared.
|
||||
def _clear_assistant_state():
|
||||
assistant_module.CONVERSATIONS.clear()
|
||||
assistant_module.USER_ACTIVE_CONVERSATION.clear()
|
||||
assistant_module.CONFIRMATIONS.clear()
|
||||
assistant_module.ASSISTANT_AUDIT.clear()
|
||||
|
||||
|
||||
# [/DEF:_clear_assistant_state:Function]
|
||||
# [DEF:test_confirmation_owner_mismatch_returns_403:Function]
|
||||
# @PURPOSE: Confirm endpoint should reject requests from user that does not own the confirmation token.
|
||||
# @PRE: Confirmation token is created by first admin actor.
|
||||
# @POST: Second actor receives 403 on confirm operation.
|
||||
def test_confirmation_owner_mismatch_returns_403():
|
||||
_clear_assistant_state()
|
||||
task_manager = _FakeTaskManager()
|
||||
db = _FakeDb()
|
||||
|
||||
create = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message="запусти миграцию с dev на prod для дашборда 18"
|
||||
),
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
assert create.state == "needs_confirmation"
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_run_async(
|
||||
assistant_module.confirm_operation(
|
||||
confirmation_id=create.confirmation_id,
|
||||
current_user=_other_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
# [/DEF:test_confirmation_owner_mismatch_returns_403:Function]
|
||||
# [DEF:test_expired_confirmation_cannot_be_confirmed:Function]
|
||||
# @PURPOSE: Expired confirmation token should be rejected and not create task.
|
||||
# @PRE: Confirmation token exists and is manually expired before confirm request.
|
||||
# @POST: Confirm endpoint raises 400 and no task is created.
|
||||
def test_expired_confirmation_cannot_be_confirmed():
|
||||
_clear_assistant_state()
|
||||
task_manager = _FakeTaskManager()
|
||||
db = _FakeDb()
|
||||
|
||||
create = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message="запусти миграцию с dev на prod для дашборда 19"
|
||||
),
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
assistant_module.CONFIRMATIONS[create.confirmation_id].expires_at = datetime.utcnow() - timedelta(minutes=1)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_run_async(
|
||||
assistant_module.confirm_operation(
|
||||
confirmation_id=create.confirmation_id,
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
assert exc.value.status_code == 400
|
||||
assert task_manager.get_tasks(limit=10, offset=0) == []
|
||||
|
||||
|
||||
# [/DEF:test_expired_confirmation_cannot_be_confirmed:Function]
|
||||
# [DEF:test_limited_user_cannot_launch_restricted_operation:Function]
|
||||
# @PURPOSE: Limited user should receive denied state for privileged operation.
|
||||
# @PRE: Restricted user attempts dangerous deploy command.
|
||||
# @POST: Assistant returns denied state and does not execute operation.
|
||||
def test_limited_user_cannot_launch_restricted_operation():
|
||||
_clear_assistant_state()
|
||||
response = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message="задеплой дашборд 88 в production"
|
||||
),
|
||||
current_user=_limited_user(),
|
||||
task_manager=_FakeTaskManager(),
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=_FakeDb(),
|
||||
)
|
||||
)
|
||||
assert response.state == "denied"
|
||||
|
||||
|
||||
# [/DEF:test_limited_user_cannot_launch_restricted_operation:Function]
|
||||
# [/DEF:backend.src.api.routes.__tests__.test_assistant_authz:Module]
|
||||
@@ -6,9 +6,45 @@
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from datetime import datetime, timezone
|
||||
from fastapi.testclient import TestClient
|
||||
from src.app import app
|
||||
from src.api.routes.dashboards import DashboardsResponse
|
||||
from src.dependencies import get_current_user, has_permission, get_config_manager, get_task_manager, get_resource_service, get_mapping_service
|
||||
|
||||
# Global mock user for get_current_user dependency overrides
|
||||
mock_user = MagicMock()
|
||||
mock_user.username = "testuser"
|
||||
mock_user.roles = []
|
||||
admin_role = MagicMock()
|
||||
admin_role.name = "Admin"
|
||||
mock_user.roles.append(admin_role)
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_deps():
|
||||
config_manager = MagicMock()
|
||||
task_manager = MagicMock()
|
||||
resource_service = MagicMock()
|
||||
mapping_service = MagicMock()
|
||||
|
||||
app.dependency_overrides[get_config_manager] = lambda: config_manager
|
||||
app.dependency_overrides[get_task_manager] = lambda: task_manager
|
||||
app.dependency_overrides[get_resource_service] = lambda: resource_service
|
||||
app.dependency_overrides[get_mapping_service] = lambda: mapping_service
|
||||
app.dependency_overrides[get_current_user] = lambda: mock_user
|
||||
|
||||
app.dependency_overrides[has_permission("plugin:migration", "READ")] = lambda: mock_user
|
||||
app.dependency_overrides[has_permission("plugin:migration", "EXECUTE")] = lambda: mock_user
|
||||
app.dependency_overrides[has_permission("plugin:backup", "EXECUTE")] = lambda: mock_user
|
||||
app.dependency_overrides[has_permission("tasks", "READ")] = lambda: mock_user
|
||||
|
||||
yield {
|
||||
"config": config_manager,
|
||||
"task": task_manager,
|
||||
"resource": resource_service,
|
||||
"mapping": mapping_service
|
||||
}
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
@@ -17,45 +53,35 @@ client = TestClient(app)
|
||||
# @TEST: GET /api/dashboards returns 200 and valid schema
|
||||
# @PRE: env_id exists
|
||||
# @POST: Response matches DashboardsResponse schema
|
||||
def test_get_dashboards_success():
|
||||
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
|
||||
patch("src.api.routes.dashboards.get_resource_service") as mock_service, \
|
||||
patch("src.api.routes.dashboards.get_task_manager") as mock_task_mgr, \
|
||||
patch("src.api.routes.dashboards.has_permission") as mock_perm:
|
||||
|
||||
# Mock environment
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_config.return_value.get_environments.return_value = [mock_env]
|
||||
|
||||
# Mock task manager
|
||||
mock_task_mgr.return_value.get_all_tasks.return_value = []
|
||||
|
||||
# Mock resource service response
|
||||
async def mock_get_dashboards(env, tasks):
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Sales Report",
|
||||
"slug": "sales",
|
||||
"git_status": {"branch": "main", "sync_status": "OK"},
|
||||
"last_task": {"task_id": "task-1", "status": "SUCCESS"}
|
||||
}
|
||||
]
|
||||
mock_service.return_value.get_dashboards_with_status = AsyncMock(
|
||||
side_effect=mock_get_dashboards
|
||||
)
|
||||
|
||||
# Mock permission
|
||||
mock_perm.return_value = lambda: True
|
||||
def test_get_dashboards_success(mock_deps):
|
||||
"""Uses @TEST_FIXTURE: dashboard_list_happy data."""
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||
mock_deps["task"].get_all_tasks.return_value = []
|
||||
|
||||
response = client.get("/api/dashboards?env_id=prod")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "dashboards" in data
|
||||
assert "total" in data
|
||||
assert "page" in data
|
||||
# @TEST_FIXTURE: dashboard_list_happy -> {"id": 1, "title": "Main Revenue"}
|
||||
mock_deps["resource"].get_dashboards_with_status = AsyncMock(return_value=[
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Main Revenue",
|
||||
"slug": "main-revenue",
|
||||
"git_status": {"branch": "main", "sync_status": "OK"},
|
||||
"last_task": {"task_id": "task-1", "status": "SUCCESS"}
|
||||
}
|
||||
])
|
||||
|
||||
response = client.get("/api/dashboards?env_id=prod")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
# exhaustive @POST assertions
|
||||
assert "dashboards" in data
|
||||
assert len(data["dashboards"]) == 1
|
||||
assert data["dashboards"][0]["title"] == "Main Revenue"
|
||||
assert data["total"] == 1
|
||||
assert "page" in data
|
||||
DashboardsResponse(**data)
|
||||
|
||||
|
||||
# [/DEF:test_get_dashboards_success:Function]
|
||||
@@ -65,55 +91,81 @@ def test_get_dashboards_success():
|
||||
# @TEST: GET /api/dashboards filters by search term
|
||||
# @PRE: search parameter provided
|
||||
# @POST: Only matching dashboards returned
|
||||
def test_get_dashboards_with_search():
|
||||
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
|
||||
patch("src.api.routes.dashboards.get_resource_service") as mock_service, \
|
||||
patch("src.api.routes.dashboards.get_task_manager") as mock_task_mgr, \
|
||||
patch("src.api.routes.dashboards.has_permission") as mock_perm:
|
||||
|
||||
# Mock environment
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_config.return_value.get_environments.return_value = [mock_env]
|
||||
|
||||
mock_task_mgr.return_value.get_all_tasks.return_value = []
|
||||
|
||||
async def mock_get_dashboards(env, tasks):
|
||||
return [
|
||||
{"id": 1, "title": "Sales Report", "slug": "sales"},
|
||||
{"id": 2, "title": "Marketing Dashboard", "slug": "marketing"}
|
||||
]
|
||||
mock_service.return_value.get_dashboards_with_status = AsyncMock(
|
||||
side_effect=mock_get_dashboards
|
||||
)
|
||||
|
||||
mock_perm.return_value = lambda: True
|
||||
def test_get_dashboards_with_search(mock_deps):
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||
mock_deps["task"].get_all_tasks.return_value = []
|
||||
|
||||
response = client.get("/api/dashboards?env_id=prod&search=sales")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
# Filtered by search term
|
||||
async def mock_get_dashboards(env, tasks):
|
||||
return [
|
||||
{"id": 1, "title": "Sales Report", "slug": "sales"},
|
||||
{"id": 2, "title": "Marketing Dashboard", "slug": "marketing"}
|
||||
]
|
||||
mock_deps["resource"].get_dashboards_with_status = AsyncMock(
|
||||
side_effect=mock_get_dashboards
|
||||
)
|
||||
|
||||
response = client.get("/api/dashboards?env_id=prod&search=sales")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
# @POST: Filtered result count must match search
|
||||
assert len(data["dashboards"]) == 1
|
||||
assert data["dashboards"][0]["title"] == "Sales Report"
|
||||
|
||||
|
||||
# [/DEF:test_get_dashboards_with_search:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_dashboards_empty:Function]
|
||||
# @TEST_EDGE: empty_dashboards -> {env_id: 'empty_env', expected_total: 0}
|
||||
def test_get_dashboards_empty(mock_deps):
|
||||
"""@TEST_EDGE: empty_dashboards -> {env_id: 'empty_env', expected_total: 0}"""
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "empty_env"
|
||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||
mock_deps["task"].get_all_tasks.return_value = []
|
||||
mock_deps["resource"].get_dashboards_with_status = AsyncMock(return_value=[])
|
||||
|
||||
response = client.get("/api/dashboards?env_id=empty_env")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 0
|
||||
assert len(data["dashboards"]) == 0
|
||||
assert data["total_pages"] == 1
|
||||
DashboardsResponse(**data)
|
||||
# [/DEF:test_get_dashboards_empty:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_dashboards_superset_failure:Function]
|
||||
# @TEST_EDGE: external_superset_failure -> {env_id: 'bad_conn', status: 503}
|
||||
def test_get_dashboards_superset_failure(mock_deps):
|
||||
"""@TEST_EDGE: external_superset_failure -> {env_id: 'bad_conn', status: 503}"""
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "bad_conn"
|
||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||
mock_deps["task"].get_all_tasks.return_value = []
|
||||
mock_deps["resource"].get_dashboards_with_status = AsyncMock(
|
||||
side_effect=Exception("Connection refused")
|
||||
)
|
||||
|
||||
response = client.get("/api/dashboards?env_id=bad_conn")
|
||||
assert response.status_code == 503
|
||||
assert "Failed to fetch dashboards" in response.json()["detail"]
|
||||
# [/DEF:test_get_dashboards_superset_failure:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_dashboards_env_not_found:Function]
|
||||
# @TEST: GET /api/dashboards returns 404 if env_id missing
|
||||
# @PRE: env_id does not exist
|
||||
# @POST: Returns 404 error
|
||||
def test_get_dashboards_env_not_found():
|
||||
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
|
||||
patch("src.api.routes.dashboards.has_permission") as mock_perm:
|
||||
|
||||
mock_config.return_value.get_environments.return_value = []
|
||||
mock_perm.return_value = lambda: True
|
||||
|
||||
response = client.get("/api/dashboards?env_id=nonexistent")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "Environment not found" in response.json()["detail"]
|
||||
def test_get_dashboards_env_not_found(mock_deps):
|
||||
mock_deps["config"].get_environments.return_value = []
|
||||
response = client.get("/api/dashboards?env_id=nonexistent")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "Environment not found" in response.json()["detail"]
|
||||
|
||||
|
||||
# [/DEF:test_get_dashboards_env_not_found:Function]
|
||||
@@ -123,40 +175,29 @@ def test_get_dashboards_env_not_found():
|
||||
# @TEST: GET /api/dashboards returns 400 for invalid page/page_size
|
||||
# @PRE: page < 1 or page_size > 100
|
||||
# @POST: Returns 400 error
|
||||
def test_get_dashboards_invalid_pagination():
|
||||
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
|
||||
patch("src.api.routes.dashboards.has_permission") as mock_perm:
|
||||
def test_get_dashboards_invalid_pagination(mock_deps):
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||
# Invalid page
|
||||
response = client.get("/api/dashboards?env_id=prod&page=0")
|
||||
assert response.status_code == 400
|
||||
assert "Page must be >= 1" in response.json()["detail"]
|
||||
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_config.return_value.get_environments.return_value = [mock_env]
|
||||
mock_perm.return_value = lambda: True
|
||||
|
||||
# Invalid page
|
||||
response = client.get("/api/dashboards?env_id=prod&page=0")
|
||||
assert response.status_code == 400
|
||||
assert "Page must be >= 1" in response.json()["detail"]
|
||||
|
||||
# Invalid page_size
|
||||
response = client.get("/api/dashboards?env_id=prod&page_size=101")
|
||||
assert response.status_code == 400
|
||||
assert "Page size must be between 1 and 100" in response.json()["detail"]
|
||||
|
||||
|
||||
# Invalid page_size
|
||||
response = client.get("/api/dashboards?env_id=prod&page_size=101")
|
||||
assert response.status_code == 400
|
||||
assert "Page size must be between 1 and 100" in response.json()["detail"]
|
||||
# [/DEF:test_get_dashboards_invalid_pagination:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_dashboard_detail_success:Function]
|
||||
# @TEST: GET /api/dashboards/{id} returns dashboard detail with charts and datasets
|
||||
def test_get_dashboard_detail_success():
|
||||
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
|
||||
patch("src.api.routes.dashboards.has_permission") as mock_perm, \
|
||||
patch("src.api.routes.dashboards.SupersetClient") as mock_client_cls:
|
||||
|
||||
def test_get_dashboard_detail_success(mock_deps):
|
||||
with patch("src.api.routes.dashboards.SupersetClient") as mock_client_cls:
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_config.return_value.get_environments.return_value = [mock_env]
|
||||
mock_perm.return_value = lambda: True
|
||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_dashboard_detail.return_value = {
|
||||
@@ -204,56 +245,46 @@ def test_get_dashboard_detail_success():
|
||||
|
||||
# [DEF:test_get_dashboard_detail_env_not_found:Function]
|
||||
# @TEST: GET /api/dashboards/{id} returns 404 for missing environment
|
||||
def test_get_dashboard_detail_env_not_found():
|
||||
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
|
||||
patch("src.api.routes.dashboards.has_permission") as mock_perm:
|
||||
mock_config.return_value.get_environments.return_value = []
|
||||
mock_perm.return_value = lambda: True
|
||||
def test_get_dashboard_detail_env_not_found(mock_deps):
|
||||
mock_deps["config"].get_environments.return_value = []
|
||||
|
||||
response = client.get("/api/dashboards/42?env_id=missing")
|
||||
|
||||
response = client.get("/api/dashboards/42?env_id=missing")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "Environment not found" in response.json()["detail"]
|
||||
assert response.status_code == 404
|
||||
assert "Environment not found" in response.json()["detail"]
|
||||
# [/DEF:test_get_dashboard_detail_env_not_found:Function]
|
||||
|
||||
|
||||
# [DEF:test_migrate_dashboards_success:Function]
|
||||
# @TEST: POST /api/dashboards/migrate creates migration task
|
||||
# @PRE: Valid source_env_id, target_env_id, dashboard_ids
|
||||
# @POST: Returns task_id
|
||||
def test_migrate_dashboards_success():
|
||||
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
|
||||
patch("src.api.routes.dashboards.get_task_manager") as mock_task_mgr, \
|
||||
patch("src.api.routes.dashboards.has_permission") as mock_perm:
|
||||
|
||||
# Mock environments
|
||||
mock_source = MagicMock()
|
||||
mock_source.id = "source"
|
||||
mock_target = MagicMock()
|
||||
mock_target.id = "target"
|
||||
mock_config.return_value.get_environments.return_value = [mock_source, mock_target]
|
||||
|
||||
# Mock task manager
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = "task-migrate-123"
|
||||
mock_task_mgr.return_value.create_task = AsyncMock(return_value=mock_task)
|
||||
|
||||
# Mock permission
|
||||
mock_perm.return_value = lambda: True
|
||||
# @POST: Returns task_id and create_task was called
|
||||
def test_migrate_dashboards_success(mock_deps):
|
||||
mock_source = MagicMock()
|
||||
mock_source.id = "source"
|
||||
mock_target = MagicMock()
|
||||
mock_target.id = "target"
|
||||
mock_deps["config"].get_environments.return_value = [mock_source, mock_target]
|
||||
|
||||
response = client.post(
|
||||
"/api/dashboards/migrate",
|
||||
json={
|
||||
"source_env_id": "source",
|
||||
"target_env_id": "target",
|
||||
"dashboard_ids": [1, 2, 3],
|
||||
"db_mappings": {"old_db": "new_db"}
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "task_id" in data
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = "task-migrate-123"
|
||||
mock_deps["task"].create_task = AsyncMock(return_value=mock_task)
|
||||
|
||||
response = client.post(
|
||||
"/api/dashboards/migrate",
|
||||
json={
|
||||
"source_env_id": "source",
|
||||
"target_env_id": "target",
|
||||
"dashboard_ids": [1, 2, 3],
|
||||
"db_mappings": {"old_db": "new_db"}
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "task_id" in data
|
||||
# @POST/@SIDE_EFFECT: create_task was called
|
||||
mock_deps["task"].create_task.assert_called_once()
|
||||
|
||||
|
||||
# [/DEF:test_migrate_dashboards_success:Function]
|
||||
@@ -263,95 +294,205 @@ def test_migrate_dashboards_success():
|
||||
# @TEST: POST /api/dashboards/migrate returns 400 for empty dashboard_ids
|
||||
# @PRE: dashboard_ids is empty
|
||||
# @POST: Returns 400 error
|
||||
def test_migrate_dashboards_no_ids():
|
||||
with patch("src.api.routes.dashboards.has_permission") as mock_perm:
|
||||
mock_perm.return_value = lambda: True
|
||||
def test_migrate_dashboards_no_ids(mock_deps):
|
||||
response = client.post(
|
||||
"/api/dashboards/migrate",
|
||||
json={
|
||||
"source_env_id": "source",
|
||||
"target_env_id": "target",
|
||||
"dashboard_ids": []
|
||||
}
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/dashboards/migrate",
|
||||
json={
|
||||
"source_env_id": "source",
|
||||
"target_env_id": "target",
|
||||
"dashboard_ids": []
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "At least one dashboard ID must be provided" in response.json()["detail"]
|
||||
assert response.status_code == 400
|
||||
assert "At least one dashboard ID must be provided" in response.json()["detail"]
|
||||
|
||||
|
||||
# [/DEF:test_migrate_dashboards_no_ids:Function]
|
||||
|
||||
|
||||
# [DEF:test_migrate_dashboards_env_not_found:Function]
|
||||
# @PRE: source_env_id and target_env_id are valid environment IDs
|
||||
def test_migrate_dashboards_env_not_found(mock_deps):
|
||||
"""@PRE: source_env_id and target_env_id are valid environment IDs."""
|
||||
mock_deps["config"].get_environments.return_value = []
|
||||
response = client.post(
|
||||
"/api/dashboards/migrate",
|
||||
json={
|
||||
"source_env_id": "ghost",
|
||||
"target_env_id": "t",
|
||||
"dashboard_ids": [1]
|
||||
}
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Source environment not found" in response.json()["detail"]
|
||||
# [/DEF:test_migrate_dashboards_env_not_found:Function]
|
||||
|
||||
|
||||
# [DEF:test_backup_dashboards_success:Function]
|
||||
# @TEST: POST /api/dashboards/backup creates backup task
|
||||
# @PRE: Valid env_id, dashboard_ids
|
||||
# @POST: Returns task_id
|
||||
def test_backup_dashboards_success():
|
||||
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
|
||||
patch("src.api.routes.dashboards.get_task_manager") as mock_task_mgr, \
|
||||
patch("src.api.routes.dashboards.has_permission") as mock_perm:
|
||||
|
||||
# Mock environment
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_config.return_value.get_environments.return_value = [mock_env]
|
||||
|
||||
# Mock task manager
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = "task-backup-456"
|
||||
mock_task_mgr.return_value.create_task = AsyncMock(return_value=mock_task)
|
||||
|
||||
# Mock permission
|
||||
mock_perm.return_value = lambda: True
|
||||
# @POST: Returns task_id and create_task was called
|
||||
def test_backup_dashboards_success(mock_deps):
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||
|
||||
response = client.post(
|
||||
"/api/dashboards/backup",
|
||||
json={
|
||||
"env_id": "prod",
|
||||
"dashboard_ids": [1, 2, 3],
|
||||
"schedule": "0 0 * * *"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "task_id" in data
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = "task-backup-456"
|
||||
mock_deps["task"].create_task = AsyncMock(return_value=mock_task)
|
||||
|
||||
response = client.post(
|
||||
"/api/dashboards/backup",
|
||||
json={
|
||||
"env_id": "prod",
|
||||
"dashboard_ids": [1, 2, 3],
|
||||
"schedule": "0 0 * * *"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "task_id" in data
|
||||
# @POST/@SIDE_EFFECT: create_task was called
|
||||
mock_deps["task"].create_task.assert_called_once()
|
||||
|
||||
|
||||
# [/DEF:test_backup_dashboards_success:Function]
|
||||
|
||||
|
||||
# [DEF:test_backup_dashboards_env_not_found:Function]
|
||||
# @PRE: env_id is a valid environment ID
|
||||
def test_backup_dashboards_env_not_found(mock_deps):
|
||||
"""@PRE: env_id is a valid environment ID."""
|
||||
mock_deps["config"].get_environments.return_value = []
|
||||
response = client.post(
|
||||
"/api/dashboards/backup",
|
||||
json={
|
||||
"env_id": "ghost",
|
||||
"dashboard_ids": [1]
|
||||
}
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Environment not found" in response.json()["detail"]
|
||||
# [/DEF:test_backup_dashboards_env_not_found:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_database_mappings_success:Function]
|
||||
# @TEST: GET /api/dashboards/db-mappings returns mapping suggestions
|
||||
# @PRE: Valid source_env_id, target_env_id
|
||||
# @POST: Returns list of database mappings
|
||||
def test_get_database_mappings_success():
|
||||
with patch("src.api.routes.dashboards.get_mapping_service") as mock_service, \
|
||||
patch("src.api.routes.dashboards.has_permission") as mock_perm:
|
||||
|
||||
# Mock mapping service
|
||||
mock_service.return_value.get_suggestions = AsyncMock(return_value=[
|
||||
{
|
||||
"source_db": "old_sales",
|
||||
"target_db": "new_sales",
|
||||
"source_db_uuid": "uuid-1",
|
||||
"target_db_uuid": "uuid-2",
|
||||
"confidence": 0.95
|
||||
}
|
||||
])
|
||||
|
||||
# Mock permission
|
||||
mock_perm.return_value = lambda: True
|
||||
def test_get_database_mappings_success(mock_deps):
|
||||
mock_source = MagicMock()
|
||||
mock_source.id = "prod"
|
||||
mock_target = MagicMock()
|
||||
mock_target.id = "staging"
|
||||
mock_deps["config"].get_environments.return_value = [mock_source, mock_target]
|
||||
|
||||
response = client.get("/api/dashboards/db-mappings?source_env_id=prod&target_env_id=staging")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "mappings" in data
|
||||
mock_deps["mapping"].get_suggestions = AsyncMock(return_value=[
|
||||
{
|
||||
"source_db": "old_sales",
|
||||
"target_db": "new_sales",
|
||||
"source_db_uuid": "uuid-1",
|
||||
"target_db_uuid": "uuid-2",
|
||||
"confidence": 0.95
|
||||
}
|
||||
])
|
||||
|
||||
response = client.get("/api/dashboards/db-mappings?source_env_id=prod&target_env_id=staging")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "mappings" in data
|
||||
assert len(data["mappings"]) == 1
|
||||
assert data["mappings"][0]["confidence"] == 0.95
|
||||
|
||||
|
||||
# [/DEF:test_get_database_mappings_success:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_database_mappings_env_not_found:Function]
|
||||
# @PRE: source_env_id and target_env_id are valid environment IDs
|
||||
def test_get_database_mappings_env_not_found(mock_deps):
|
||||
"""@PRE: source_env_id must be a valid environment."""
|
||||
mock_deps["config"].get_environments.return_value = []
|
||||
response = client.get("/api/dashboards/db-mappings?source_env_id=ghost&target_env_id=t")
|
||||
assert response.status_code == 404
|
||||
# [/DEF:test_get_database_mappings_env_not_found:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_dashboard_tasks_history_filters_success:Function]
|
||||
# @TEST: GET /api/dashboards/{id}/tasks returns backup and llm tasks for dashboard
|
||||
def test_get_dashboard_tasks_history_filters_success(mock_deps):
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
llm_task = MagicMock()
|
||||
llm_task.id = "task-llm-1"
|
||||
llm_task.plugin_id = "llm_dashboard_validation"
|
||||
llm_task.status = "SUCCESS"
|
||||
llm_task.started_at = now
|
||||
llm_task.finished_at = now
|
||||
llm_task.params = {"dashboard_id": "42", "environment_id": "prod"}
|
||||
llm_task.result = {"summary": "LLM validation complete"}
|
||||
|
||||
backup_task = MagicMock()
|
||||
backup_task.id = "task-backup-1"
|
||||
backup_task.plugin_id = "superset-backup"
|
||||
backup_task.status = "RUNNING"
|
||||
backup_task.started_at = now
|
||||
backup_task.finished_at = None
|
||||
backup_task.params = {"env": "prod", "dashboards": [42]}
|
||||
backup_task.result = {}
|
||||
|
||||
other_task = MagicMock()
|
||||
other_task.id = "task-other"
|
||||
other_task.plugin_id = "superset-backup"
|
||||
other_task.status = "SUCCESS"
|
||||
other_task.started_at = now
|
||||
other_task.finished_at = now
|
||||
other_task.params = {"env": "prod", "dashboards": [777]}
|
||||
other_task.result = {}
|
||||
|
||||
mock_deps["task"].get_all_tasks.return_value = [other_task, llm_task, backup_task]
|
||||
|
||||
response = client.get("/api/dashboards/42/tasks?env_id=prod&limit=10")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["dashboard_id"] == 42
|
||||
assert len(data["items"]) == 2
|
||||
assert {item["plugin_id"] for item in data["items"]} == {"llm_dashboard_validation", "superset-backup"}
|
||||
# [/DEF:test_get_dashboard_tasks_history_filters_success:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_dashboard_thumbnail_success:Function]
|
||||
# @TEST: GET /api/dashboards/{id}/thumbnail proxies image bytes from Superset
|
||||
def test_get_dashboard_thumbnail_success(mock_deps):
|
||||
with patch("src.api.routes.dashboards.SupersetClient") as mock_client_cls:
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.content = b"fake-image-bytes"
|
||||
mock_response.headers = {"Content-Type": "image/png"}
|
||||
|
||||
def _network_request(method, endpoint, **kwargs):
|
||||
if method == "POST":
|
||||
return {"image_url": "/api/v1/dashboard/42/screenshot/abc123/"}
|
||||
return mock_response
|
||||
|
||||
mock_client.network.request.side_effect = _network_request
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
response = client.get("/api/dashboards/42/thumbnail?env_id=prod")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.content == b"fake-image-bytes"
|
||||
assert response.headers["content-type"].startswith("image/png")
|
||||
# [/DEF:test_get_dashboard_thumbnail_success:Function]
|
||||
|
||||
|
||||
# [/DEF:backend.src.api.routes.__tests__.test_dashboards:Module]
|
||||
|
||||
@@ -11,6 +11,41 @@ from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from fastapi.testclient import TestClient
|
||||
from src.app import app
|
||||
from src.api.routes.datasets import DatasetsResponse, DatasetDetailResponse
|
||||
from src.dependencies import get_current_user, has_permission, get_config_manager, get_task_manager, get_resource_service, get_mapping_service
|
||||
|
||||
# Global mock user for get_current_user dependency overrides
|
||||
mock_user = MagicMock()
|
||||
mock_user.username = "testuser"
|
||||
mock_user.roles = []
|
||||
admin_role = MagicMock()
|
||||
admin_role.name = "Admin"
|
||||
mock_user.roles.append(admin_role)
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_deps():
|
||||
config_manager = MagicMock()
|
||||
task_manager = MagicMock()
|
||||
resource_service = MagicMock()
|
||||
mapping_service = MagicMock()
|
||||
|
||||
app.dependency_overrides[get_config_manager] = lambda: config_manager
|
||||
app.dependency_overrides[get_task_manager] = lambda: task_manager
|
||||
app.dependency_overrides[get_resource_service] = lambda: resource_service
|
||||
app.dependency_overrides[get_mapping_service] = lambda: mapping_service
|
||||
app.dependency_overrides[get_current_user] = lambda: mock_user
|
||||
|
||||
app.dependency_overrides[has_permission("plugin:migration", "READ")] = lambda: mock_user
|
||||
app.dependency_overrides[has_permission("plugin:migration", "EXECUTE")] = lambda: mock_user
|
||||
app.dependency_overrides[has_permission("plugin:backup", "EXECUTE")] = lambda: mock_user
|
||||
app.dependency_overrides[has_permission("tasks", "READ")] = lambda: mock_user
|
||||
|
||||
yield {
|
||||
"config": config_manager,
|
||||
"task": task_manager,
|
||||
"resource": resource_service,
|
||||
"mapping": mapping_service
|
||||
}
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
@@ -20,41 +55,34 @@ client = TestClient(app)
|
||||
# @TEST: GET /api/datasets returns 200 and valid schema
|
||||
# @PRE: env_id exists
|
||||
# @POST: Response matches DatasetsResponse schema
|
||||
def test_get_datasets_success():
|
||||
with patch("src.api.routes.datasets.get_config_manager") as mock_config, \
|
||||
patch("src.api.routes.datasets.get_resource_service") as mock_service, \
|
||||
patch("src.api.routes.datasets.has_permission") as mock_perm:
|
||||
|
||||
# Mock environment
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_config.return_value.get_environments.return_value = [mock_env]
|
||||
|
||||
# Mock resource service response
|
||||
mock_service.return_value.get_datasets_with_status.return_value = AsyncMock()(
|
||||
return_value=[
|
||||
{
|
||||
"id": 1,
|
||||
"table_name": "sales_data",
|
||||
"schema": "public",
|
||||
"database": "sales_db",
|
||||
"mapped_fields": {"total": 10, "mapped": 5},
|
||||
"last_task": {"task_id": "task-1", "status": "SUCCESS"}
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
# Mock permission
|
||||
mock_perm.return_value = lambda: True
|
||||
def test_get_datasets_success(mock_deps):
|
||||
# Mock environment
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||
|
||||
# Mock resource service response
|
||||
mock_deps["resource"].get_datasets_with_status = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"id": 1,
|
||||
"table_name": "sales_data",
|
||||
"schema": "public",
|
||||
"database": "sales_db",
|
||||
"mapped_fields": {"total": 10, "mapped": 5},
|
||||
"last_task": {"task_id": "task-1", "status": "SUCCESS"}
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
response = client.get("/api/datasets?env_id=prod")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "datasets" in data
|
||||
assert len(data["datasets"]) >= 0
|
||||
# Validate against Pydantic model
|
||||
DatasetsResponse(**data)
|
||||
response = client.get("/api/datasets?env_id=prod")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "datasets" in data
|
||||
assert len(data["datasets"]) >= 0
|
||||
# Validate against Pydantic model
|
||||
DatasetsResponse(**data)
|
||||
|
||||
|
||||
# [/DEF:test_get_datasets_success:Function]
|
||||
@@ -64,17 +92,13 @@ def test_get_datasets_success():
|
||||
# @TEST: GET /api/datasets returns 404 if env_id missing
|
||||
# @PRE: env_id does not exist
|
||||
# @POST: Returns 404 error
|
||||
def test_get_datasets_env_not_found():
|
||||
with patch("src.api.routes.datasets.get_config_manager") as mock_config, \
|
||||
patch("src.api.routes.datasets.has_permission") as mock_perm:
|
||||
|
||||
mock_config.return_value.get_environments.return_value = []
|
||||
mock_perm.return_value = lambda: True
|
||||
def test_get_datasets_env_not_found(mock_deps):
|
||||
mock_deps["config"].get_environments.return_value = []
|
||||
|
||||
response = client.get("/api/datasets?env_id=nonexistent")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "Environment not found" in response.json()["detail"]
|
||||
response = client.get("/api/datasets?env_id=nonexistent")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "Environment not found" in response.json()["detail"]
|
||||
|
||||
|
||||
# [/DEF:test_get_datasets_env_not_found:Function]
|
||||
@@ -84,24 +108,25 @@ def test_get_datasets_env_not_found():
|
||||
# @TEST: GET /api/datasets returns 400 for invalid page/page_size
|
||||
# @PRE: page < 1 or page_size > 100
|
||||
# @POST: Returns 400 error
|
||||
def test_get_datasets_invalid_pagination():
|
||||
with patch("src.api.routes.datasets.get_config_manager") as mock_config, \
|
||||
patch("src.api.routes.datasets.has_permission") as mock_perm:
|
||||
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_config.return_value.get_environments.return_value = [mock_env]
|
||||
mock_perm.return_value = lambda: True
|
||||
def test_get_datasets_invalid_pagination(mock_deps):
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||
|
||||
# Invalid page
|
||||
response = client.get("/api/datasets?env_id=prod&page=0")
|
||||
assert response.status_code == 400
|
||||
assert "Page must be >= 1" in response.json()["detail"]
|
||||
|
||||
# Invalid page_size
|
||||
response = client.get("/api/datasets?env_id=prod&page_size=0")
|
||||
assert response.status_code == 400
|
||||
assert "Page size must be between 1 and 100" in response.json()["detail"]
|
||||
# Invalid page
|
||||
response = client.get("/api/datasets?env_id=prod&page=0")
|
||||
assert response.status_code == 400
|
||||
assert "Page must be >= 1" in response.json()["detail"]
|
||||
|
||||
# Invalid page_size (too small)
|
||||
response = client.get("/api/datasets?env_id=prod&page_size=0")
|
||||
assert response.status_code == 400
|
||||
assert "Page size must be between 1 and 100" in response.json()["detail"]
|
||||
|
||||
# @TEST_EDGE: page_size > 100 exceeds max
|
||||
response = client.get("/api/datasets?env_id=prod&page_size=101")
|
||||
assert response.status_code == 400
|
||||
assert "Page size must be between 1 and 100" in response.json()["detail"]
|
||||
|
||||
|
||||
# [/DEF:test_get_datasets_invalid_pagination:Function]
|
||||
@@ -111,36 +136,31 @@ def test_get_datasets_invalid_pagination():
|
||||
# @TEST: POST /api/datasets/map-columns creates mapping task
|
||||
# @PRE: Valid env_id, dataset_ids, source_type
|
||||
# @POST: Returns task_id
|
||||
def test_map_columns_success():
|
||||
with patch("src.api.routes.datasets.get_config_manager") as mock_config, \
|
||||
patch("src.api.routes.datasets.get_task_manager") as mock_task_mgr, \
|
||||
patch("src.api.routes.datasets.has_permission") as mock_perm:
|
||||
|
||||
# Mock environment
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_config.return_value.get_environments.return_value = [mock_env]
|
||||
|
||||
# Mock task manager
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = "task-123"
|
||||
mock_task_mgr.return_value.create_task = AsyncMock(return_value=mock_task)
|
||||
|
||||
# Mock permission
|
||||
mock_perm.return_value = lambda: True
|
||||
def test_map_columns_success(mock_deps):
|
||||
# Mock environment
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||
|
||||
# Mock task manager
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = "task-123"
|
||||
mock_deps["task"].create_task = AsyncMock(return_value=mock_task)
|
||||
|
||||
response = client.post(
|
||||
"/api/datasets/map-columns",
|
||||
json={
|
||||
"env_id": "prod",
|
||||
"dataset_ids": [1, 2, 3],
|
||||
"source_type": "postgresql"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "task_id" in data
|
||||
response = client.post(
|
||||
"/api/datasets/map-columns",
|
||||
json={
|
||||
"env_id": "prod",
|
||||
"dataset_ids": [1, 2, 3],
|
||||
"source_type": "postgresql"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "task_id" in data
|
||||
# @POST/@SIDE_EFFECT: create_task was called
|
||||
mock_deps["task"].create_task.assert_called_once()
|
||||
|
||||
|
||||
# [/DEF:test_map_columns_success:Function]
|
||||
@@ -150,21 +170,18 @@ def test_map_columns_success():
|
||||
# @TEST: POST /api/datasets/map-columns returns 400 for invalid source_type
|
||||
# @PRE: source_type is not 'postgresql' or 'xlsx'
|
||||
# @POST: Returns 400 error
|
||||
def test_map_columns_invalid_source_type():
|
||||
with patch("src.api.routes.datasets.has_permission") as mock_perm:
|
||||
mock_perm.return_value = lambda: True
|
||||
|
||||
response = client.post(
|
||||
"/api/datasets/map-columns",
|
||||
json={
|
||||
"env_id": "prod",
|
||||
"dataset_ids": [1],
|
||||
"source_type": "invalid"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Source type must be 'postgresql' or 'xlsx'" in response.json()["detail"]
|
||||
def test_map_columns_invalid_source_type(mock_deps):
|
||||
response = client.post(
|
||||
"/api/datasets/map-columns",
|
||||
json={
|
||||
"env_id": "prod",
|
||||
"dataset_ids": [1],
|
||||
"source_type": "invalid"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Source type must be 'postgresql' or 'xlsx'" in response.json()["detail"]
|
||||
|
||||
|
||||
# [/DEF:test_map_columns_invalid_source_type:Function]
|
||||
@@ -174,39 +191,110 @@ def test_map_columns_invalid_source_type():
|
||||
# @TEST: POST /api/datasets/generate-docs creates doc generation task
|
||||
# @PRE: Valid env_id, dataset_ids, llm_provider
|
||||
# @POST: Returns task_id
|
||||
def test_generate_docs_success():
|
||||
with patch("src.api.routes.datasets.get_config_manager") as mock_config, \
|
||||
patch("src.api.routes.datasets.get_task_manager") as mock_task_mgr, \
|
||||
patch("src.api.routes.datasets.has_permission") as mock_perm:
|
||||
|
||||
# Mock environment
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_config.return_value.get_environments.return_value = [mock_env]
|
||||
|
||||
# Mock task manager
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = "task-456"
|
||||
mock_task_mgr.return_value.create_task = AsyncMock(return_value=mock_task)
|
||||
|
||||
# Mock permission
|
||||
mock_perm.return_value = lambda: True
|
||||
def test_generate_docs_success(mock_deps):
|
||||
# Mock environment
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||
|
||||
# Mock task manager
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = "task-456"
|
||||
mock_deps["task"].create_task = AsyncMock(return_value=mock_task)
|
||||
|
||||
response = client.post(
|
||||
"/api/datasets/generate-docs",
|
||||
json={
|
||||
"env_id": "prod",
|
||||
"dataset_ids": [1],
|
||||
"llm_provider": "openai"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "task_id" in data
|
||||
response = client.post(
|
||||
"/api/datasets/generate-docs",
|
||||
json={
|
||||
"env_id": "prod",
|
||||
"dataset_ids": [1],
|
||||
"llm_provider": "openai"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "task_id" in data
|
||||
# @POST/@SIDE_EFFECT: create_task was called
|
||||
mock_deps["task"].create_task.assert_called_once()
|
||||
|
||||
|
||||
# [/DEF:test_generate_docs_success:Function]
|
||||
|
||||
|
||||
# [DEF:test_map_columns_empty_ids:Function]
|
||||
# @TEST: POST /api/datasets/map-columns returns 400 for empty dataset_ids
|
||||
# @PRE: dataset_ids is empty
|
||||
# @POST: Returns 400 error
|
||||
def test_map_columns_empty_ids(mock_deps):
|
||||
"""@PRE: dataset_ids must be non-empty."""
|
||||
response = client.post(
|
||||
"/api/datasets/map-columns",
|
||||
json={
|
||||
"env_id": "prod",
|
||||
"dataset_ids": [],
|
||||
"source_type": "postgresql"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "At least one dataset ID must be provided" in response.json()["detail"]
|
||||
# [/DEF:test_map_columns_empty_ids:Function]
|
||||
|
||||
|
||||
# [DEF:test_generate_docs_empty_ids:Function]
|
||||
# @TEST: POST /api/datasets/generate-docs returns 400 for empty dataset_ids
|
||||
# @PRE: dataset_ids is empty
|
||||
# @POST: Returns 400 error
|
||||
def test_generate_docs_empty_ids(mock_deps):
|
||||
"""@PRE: dataset_ids must be non-empty."""
|
||||
response = client.post(
|
||||
"/api/datasets/generate-docs",
|
||||
json={
|
||||
"env_id": "prod",
|
||||
"dataset_ids": [],
|
||||
"llm_provider": "openai"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "At least one dataset ID must be provided" in response.json()["detail"]
|
||||
# [/DEF:test_generate_docs_empty_ids:Function]
|
||||
|
||||
|
||||
# [DEF:test_generate_docs_env_not_found:Function]
|
||||
# @TEST: POST /api/datasets/generate-docs returns 404 for missing env
|
||||
# @PRE: env_id does not exist
|
||||
# @POST: Returns 404 error
|
||||
def test_generate_docs_env_not_found(mock_deps):
|
||||
"""@PRE: env_id must be a valid environment."""
|
||||
mock_deps["config"].get_environments.return_value = []
|
||||
response = client.post(
|
||||
"/api/datasets/generate-docs",
|
||||
json={
|
||||
"env_id": "ghost",
|
||||
"dataset_ids": [1],
|
||||
"llm_provider": "openai"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Environment not found" in response.json()["detail"]
|
||||
# [/DEF:test_generate_docs_env_not_found:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_datasets_superset_failure:Function]
|
||||
# @TEST_EDGE: external_superset_failure -> {status: 503}
|
||||
def test_get_datasets_superset_failure(mock_deps):
|
||||
"""@TEST_EDGE: external_superset_failure -> {status: 503}"""
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "bad_conn"
|
||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||
mock_deps["task"].get_all_tasks.return_value = []
|
||||
mock_deps["resource"].get_datasets_with_status = AsyncMock(
|
||||
side_effect=Exception("Connection refused")
|
||||
)
|
||||
|
||||
response = client.get("/api/datasets?env_id=bad_conn")
|
||||
assert response.status_code == 503
|
||||
assert "Failed to fetch datasets" in response.json()["detail"]
|
||||
# [/DEF:test_get_datasets_superset_failure:Function]
|
||||
|
||||
|
||||
# [/DEF:backend.src.api.routes.__tests__.test_datasets:Module]
|
||||
198
backend/src/api/routes/__tests__/test_git_status_route.py
Normal file
198
backend/src/api/routes/__tests__/test_git_status_route.py
Normal file
@@ -0,0 +1,198 @@
|
||||
# [DEF:backend.src.api.routes.__tests__.test_git_status_route:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: tests, git, api, status, no_repo
|
||||
# @PURPOSE: Validate status endpoint behavior for missing and error repository states.
|
||||
# @LAYER: Domain (Tests)
|
||||
# @RELATION: CALLS -> src.api.routes.git.get_repository_status
|
||||
|
||||
from fastapi import HTTPException
|
||||
import pytest
|
||||
import asyncio
|
||||
|
||||
from src.api.routes import git as git_routes
|
||||
|
||||
|
||||
# [DEF:test_get_repository_status_returns_no_repo_payload_for_missing_repo:Function]
|
||||
# @PURPOSE: Ensure missing local repository is represented as NO_REPO payload instead of an API error.
|
||||
# @PRE: GitService.get_status raises HTTPException(404).
|
||||
# @POST: Route returns a deterministic NO_REPO status payload.
|
||||
def test_get_repository_status_returns_no_repo_payload_for_missing_repo(monkeypatch):
|
||||
class MissingRepoGitService:
|
||||
def _get_repo_path(self, dashboard_id: int) -> str:
|
||||
return f"/tmp/missing-repo-{dashboard_id}"
|
||||
|
||||
def get_status(self, dashboard_id: int) -> dict:
|
||||
raise AssertionError("get_status must not be called when repository path is missing")
|
||||
|
||||
monkeypatch.setattr(git_routes, "git_service", MissingRepoGitService())
|
||||
|
||||
response = asyncio.run(git_routes.get_repository_status(34))
|
||||
|
||||
assert response["sync_status"] == "NO_REPO"
|
||||
assert response["sync_state"] == "NO_REPO"
|
||||
assert response["has_repo"] is False
|
||||
assert response["current_branch"] is None
|
||||
# [/DEF:test_get_repository_status_returns_no_repo_payload_for_missing_repo:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_repository_status_propagates_non_404_http_exception:Function]
|
||||
# @PURPOSE: Ensure HTTP exceptions other than 404 are not masked.
|
||||
# @PRE: GitService.get_status raises HTTPException with non-404 status.
|
||||
# @POST: Raised exception preserves original status and detail.
|
||||
def test_get_repository_status_propagates_non_404_http_exception(monkeypatch):
|
||||
class ConflictGitService:
|
||||
def _get_repo_path(self, dashboard_id: int) -> str:
|
||||
return f"/tmp/existing-repo-{dashboard_id}"
|
||||
|
||||
def get_status(self, dashboard_id: int) -> dict:
|
||||
raise HTTPException(status_code=409, detail="Conflict")
|
||||
|
||||
monkeypatch.setattr(git_routes, "git_service", ConflictGitService())
|
||||
monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.run(git_routes.get_repository_status(34))
|
||||
|
||||
assert exc_info.value.status_code == 409
|
||||
assert exc_info.value.detail == "Conflict"
|
||||
# [/DEF:test_get_repository_status_propagates_non_404_http_exception:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_repository_diff_propagates_http_exception:Function]
|
||||
# @PURPOSE: Ensure diff endpoint preserves domain HTTP errors from GitService.
|
||||
# @PRE: GitService.get_diff raises HTTPException.
|
||||
# @POST: Endpoint raises same HTTPException values.
|
||||
def test_get_repository_diff_propagates_http_exception(monkeypatch):
|
||||
class DiffGitService:
|
||||
def get_diff(self, dashboard_id: int, file_path=None, staged: bool = False) -> str:
|
||||
raise HTTPException(status_code=404, detail="Repository missing")
|
||||
|
||||
monkeypatch.setattr(git_routes, "git_service", DiffGitService())
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.run(git_routes.get_repository_diff(12))
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert exc_info.value.detail == "Repository missing"
|
||||
# [/DEF:test_get_repository_diff_propagates_http_exception:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_history_wraps_unexpected_error_as_500:Function]
|
||||
# @PURPOSE: Ensure non-HTTP exceptions in history endpoint become deterministic 500 errors.
|
||||
# @PRE: GitService.get_commit_history raises ValueError.
|
||||
# @POST: Endpoint returns HTTPException with status 500 and route context.
|
||||
def test_get_history_wraps_unexpected_error_as_500(monkeypatch):
|
||||
class HistoryGitService:
|
||||
def get_commit_history(self, dashboard_id: int, limit: int = 50):
|
||||
raise ValueError("broken parser")
|
||||
|
||||
monkeypatch.setattr(git_routes, "git_service", HistoryGitService())
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.run(git_routes.get_history(12))
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert exc_info.value.detail == "get_history failed: broken parser"
|
||||
# [/DEF:test_get_history_wraps_unexpected_error_as_500:Function]
|
||||
|
||||
|
||||
# [DEF:test_commit_changes_wraps_unexpected_error_as_500:Function]
|
||||
# @PURPOSE: Ensure commit endpoint does not leak unexpected errors as 400.
|
||||
# @PRE: GitService.commit_changes raises RuntimeError.
|
||||
# @POST: Endpoint raises HTTPException(500) with route context.
|
||||
def test_commit_changes_wraps_unexpected_error_as_500(monkeypatch):
|
||||
class CommitGitService:
|
||||
def commit_changes(self, dashboard_id: int, message: str, files):
|
||||
raise RuntimeError("index lock")
|
||||
|
||||
class CommitPayload:
|
||||
message = "test"
|
||||
files = ["dashboards/a.yaml"]
|
||||
|
||||
monkeypatch.setattr(git_routes, "git_service", CommitGitService())
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.run(git_routes.commit_changes(12, CommitPayload()))
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert exc_info.value.detail == "commit_changes failed: index lock"
|
||||
# [/DEF:test_commit_changes_wraps_unexpected_error_as_500:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_repository_status_batch_returns_mixed_statuses:Function]
|
||||
# @PURPOSE: Ensure batch endpoint returns per-dashboard statuses in one response.
|
||||
# @PRE: Some repositories are missing and some are initialized.
|
||||
# @POST: Returned map includes resolved status for each requested dashboard ID.
|
||||
def test_get_repository_status_batch_returns_mixed_statuses(monkeypatch):
|
||||
class BatchGitService:
|
||||
def _get_repo_path(self, dashboard_id: int) -> str:
|
||||
return f"/tmp/repo-{dashboard_id}"
|
||||
|
||||
def get_status(self, dashboard_id: int) -> dict:
|
||||
if dashboard_id == 2:
|
||||
return {"sync_state": "SYNCED", "sync_status": "OK"}
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
|
||||
monkeypatch.setattr(git_routes, "git_service", BatchGitService())
|
||||
monkeypatch.setattr(git_routes.os.path, "exists", lambda path: path.endswith("/repo-2"))
|
||||
|
||||
class BatchRequest:
|
||||
dashboard_ids = [1, 2]
|
||||
|
||||
response = asyncio.run(git_routes.get_repository_status_batch(BatchRequest()))
|
||||
|
||||
assert response.statuses["1"]["sync_status"] == "NO_REPO"
|
||||
assert response.statuses["2"]["sync_state"] == "SYNCED"
|
||||
# [/DEF:test_get_repository_status_batch_returns_mixed_statuses:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_repository_status_batch_marks_item_as_error_on_service_failure:Function]
|
||||
# @PURPOSE: Ensure batch endpoint marks failed items as ERROR without failing entire request.
|
||||
# @PRE: GitService raises non-HTTP exception for one dashboard.
|
||||
# @POST: Failed dashboard status is marked as ERROR.
|
||||
def test_get_repository_status_batch_marks_item_as_error_on_service_failure(monkeypatch):
|
||||
class BatchErrorGitService:
|
||||
def _get_repo_path(self, dashboard_id: int) -> str:
|
||||
return f"/tmp/repo-{dashboard_id}"
|
||||
|
||||
def get_status(self, dashboard_id: int) -> dict:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(git_routes, "git_service", BatchErrorGitService())
|
||||
monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True)
|
||||
|
||||
class BatchRequest:
|
||||
dashboard_ids = [9]
|
||||
|
||||
response = asyncio.run(git_routes.get_repository_status_batch(BatchRequest()))
|
||||
|
||||
assert response.statuses["9"]["sync_status"] == "ERROR"
|
||||
assert response.statuses["9"]["sync_state"] == "ERROR"
|
||||
# [/DEF:test_get_repository_status_batch_marks_item_as_error_on_service_failure:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_repository_status_batch_deduplicates_and_truncates_ids:Function]
|
||||
# @PURPOSE: Ensure batch endpoint protects server from oversized payloads.
|
||||
# @PRE: request includes duplicate IDs and more than MAX_REPOSITORY_STATUS_BATCH entries.
|
||||
# @POST: Result contains unique IDs up to configured cap.
|
||||
def test_get_repository_status_batch_deduplicates_and_truncates_ids(monkeypatch):
|
||||
class SafeBatchGitService:
|
||||
def _get_repo_path(self, dashboard_id: int) -> str:
|
||||
return f"/tmp/repo-{dashboard_id}"
|
||||
|
||||
def get_status(self, dashboard_id: int) -> dict:
|
||||
return {"sync_state": "SYNCED", "sync_status": "OK"}
|
||||
|
||||
monkeypatch.setattr(git_routes, "git_service", SafeBatchGitService())
|
||||
monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True)
|
||||
|
||||
class BatchRequest:
|
||||
dashboard_ids = [1, 1] + list(range(2, 90))
|
||||
|
||||
response = asyncio.run(git_routes.get_repository_status_batch(BatchRequest()))
|
||||
|
||||
assert len(response.statuses) == git_routes.MAX_REPOSITORY_STATUS_BATCH
|
||||
assert "1" in response.statuses
|
||||
# [/DEF:test_get_repository_status_batch_deduplicates_and_truncates_ids:Function]
|
||||
|
||||
# [/DEF:backend.src.api.routes.__tests__.test_git_status_route:Module]
|
||||
510
backend/src/api/routes/__tests__/test_migration_routes.py
Normal file
510
backend/src/api/routes/__tests__/test_migration_routes.py
Normal file
@@ -0,0 +1,510 @@
|
||||
# [DEF:backend.src.api.routes.__tests__.test_migration_routes:Module]
|
||||
#
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Unit tests for migration API route handlers.
|
||||
# @LAYER: API
|
||||
# @RELATION: VERIFIES -> backend.src.api.routes.migration
|
||||
#
|
||||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Add backend directory to sys.path
|
||||
backend_dir = str(Path(__file__).parent.parent.parent.parent.resolve())
|
||||
if backend_dir not in sys.path:
|
||||
sys.path.insert(0, backend_dir)
|
||||
|
||||
import os
|
||||
# Force SQLite in-memory for all database connections BEFORE importing any application code
|
||||
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
|
||||
os.environ["TASKS_DATABASE_URL"] = "sqlite:///:memory:"
|
||||
os.environ["AUTH_DATABASE_URL"] = "sqlite:///:memory:"
|
||||
os.environ["ENVIRONMENT"] = "testing"
|
||||
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from src.models.mapping import Base, ResourceMapping, ResourceType
|
||||
|
||||
# Patch the get_db dependency if `src.api.routes.migration` imports it
|
||||
from unittest.mock import patch
|
||||
patch('src.core.database.get_db').start()
|
||||
|
||||
# --- Fixtures ---
|
||||
|
||||
@pytest.fixture
|
||||
def db_session():
|
||||
"""In-memory SQLite session for testing."""
|
||||
from sqlalchemy.pool import StaticPool
|
||||
engine = create_engine(
|
||||
'sqlite:///:memory:',
|
||||
connect_args={'check_same_thread': False},
|
||||
poolclass=StaticPool
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
def _make_config_manager(cron="0 2 * * *"):
|
||||
"""Creates a mock config manager with a realistic AppConfig-like object."""
|
||||
settings = MagicMock()
|
||||
settings.migration_sync_cron = cron
|
||||
config = MagicMock()
|
||||
config.settings = settings
|
||||
cm = MagicMock()
|
||||
cm.get_config.return_value = config
|
||||
cm.save_config = MagicMock()
|
||||
return cm
|
||||
|
||||
|
||||
# --- get_migration_settings tests ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_migration_settings_returns_default_cron():
|
||||
"""Verify the settings endpoint returns the stored cron string."""
|
||||
from src.api.routes.migration import get_migration_settings
|
||||
|
||||
cm = _make_config_manager(cron="0 3 * * *")
|
||||
|
||||
# Call the handler directly, bypassing Depends
|
||||
result = await get_migration_settings(config_manager=cm, _=None)
|
||||
|
||||
assert result == {"cron": "0 3 * * *"}
|
||||
cm.get_config.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_migration_settings_returns_fallback_when_no_cron():
|
||||
"""When migration_sync_cron uses the default, should return '0 2 * * *'."""
|
||||
from src.api.routes.migration import get_migration_settings
|
||||
|
||||
# Use the default cron value (simulating a fresh config)
|
||||
cm = _make_config_manager()
|
||||
|
||||
result = await get_migration_settings(config_manager=cm, _=None)
|
||||
|
||||
assert result == {"cron": "0 2 * * *"}
|
||||
|
||||
|
||||
# --- update_migration_settings tests ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_migration_settings_saves_cron():
|
||||
"""Verify that a valid cron update saves to config."""
|
||||
from src.api.routes.migration import update_migration_settings
|
||||
|
||||
cm = _make_config_manager()
|
||||
|
||||
result = await update_migration_settings(
|
||||
payload={"cron": "0 4 * * *"},
|
||||
config_manager=cm,
|
||||
_=None
|
||||
)
|
||||
|
||||
assert result["cron"] == "0 4 * * *"
|
||||
assert result["status"] == "updated"
|
||||
cm.save_config.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_migration_settings_rejects_missing_cron():
|
||||
"""Verify 400 error when 'cron' key is missing from payload."""
|
||||
from src.api.routes.migration import update_migration_settings
|
||||
|
||||
cm = _make_config_manager()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_migration_settings(
|
||||
payload={"interval": "daily"},
|
||||
config_manager=cm,
|
||||
_=None
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "cron" in exc_info.value.detail.lower()
|
||||
|
||||
|
||||
# --- get_resource_mappings tests ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_mappings_returns_formatted_list(db_session):
|
||||
"""Verify mappings are returned as formatted dicts with correct keys."""
|
||||
from src.api.routes.migration import get_resource_mappings
|
||||
|
||||
# Populate test data
|
||||
m1 = ResourceMapping(
|
||||
environment_id="prod",
|
||||
resource_type=ResourceType.CHART,
|
||||
uuid="uuid-1",
|
||||
remote_integer_id="42",
|
||||
resource_name="Sales Chart",
|
||||
last_synced_at=datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
)
|
||||
db_session.add(m1)
|
||||
db_session.commit()
|
||||
|
||||
result = await get_resource_mappings(skip=0, limit=50, search=None, env_id=None, resource_type=None, db=db_session, _=None)
|
||||
|
||||
assert result["total"] == 1
|
||||
assert len(result["items"]) == 1
|
||||
assert result["items"][0]["environment_id"] == "prod"
|
||||
assert result["items"][0]["resource_type"] == "chart"
|
||||
assert result["items"][0]["uuid"] == "uuid-1"
|
||||
assert result["items"][0]["remote_id"] == "42"
|
||||
assert result["items"][0]["resource_name"] == "Sales Chart"
|
||||
assert result["items"][0]["last_synced_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_mappings_respects_pagination(db_session):
|
||||
"""Verify skip and limit parameters work correctly."""
|
||||
from src.api.routes.migration import get_resource_mappings
|
||||
|
||||
for i in range(5):
|
||||
db_session.add(ResourceMapping(
|
||||
environment_id="prod",
|
||||
resource_type=ResourceType.DATASET,
|
||||
uuid=f"uuid-{i}",
|
||||
remote_integer_id=str(i),
|
||||
))
|
||||
db_session.commit()
|
||||
|
||||
result = await get_resource_mappings(skip=2, limit=2, search=None, env_id=None, resource_type=None, db=db_session, _=None)
|
||||
|
||||
assert result["total"] == 5
|
||||
assert len(result["items"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_mappings_search_by_name(db_session):
|
||||
"""Verify search filters by resource_name."""
|
||||
from src.api.routes.migration import get_resource_mappings
|
||||
|
||||
db_session.add(ResourceMapping(environment_id="prod", resource_type=ResourceType.CHART, uuid="u1", remote_integer_id="1", resource_name="Sales Chart"))
|
||||
db_session.add(ResourceMapping(environment_id="prod", resource_type=ResourceType.CHART, uuid="u2", remote_integer_id="2", resource_name="Revenue Dashboard"))
|
||||
db_session.commit()
|
||||
|
||||
result = await get_resource_mappings(skip=0, limit=50, search="sales", env_id=None, resource_type=None, db=db_session, _=None)
|
||||
assert result["total"] == 1
|
||||
assert result["items"][0]["resource_name"] == "Sales Chart"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_mappings_filter_by_env(db_session):
|
||||
"""Verify env_id filter returns only matching environment."""
|
||||
from src.api.routes.migration import get_resource_mappings
|
||||
|
||||
db_session.add(ResourceMapping(environment_id="ss1", resource_type=ResourceType.CHART, uuid="u1", remote_integer_id="1", resource_name="Chart A"))
|
||||
db_session.add(ResourceMapping(environment_id="ss2", resource_type=ResourceType.CHART, uuid="u2", remote_integer_id="2", resource_name="Chart B"))
|
||||
db_session.commit()
|
||||
|
||||
result = await get_resource_mappings(skip=0, limit=50, search=None, env_id="ss2", resource_type=None, db=db_session, _=None)
|
||||
assert result["total"] == 1
|
||||
assert result["items"][0]["environment_id"] == "ss2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_mappings_filter_by_type(db_session):
|
||||
"""Verify resource_type filter returns only matching type."""
|
||||
from src.api.routes.migration import get_resource_mappings
|
||||
|
||||
db_session.add(ResourceMapping(environment_id="prod", resource_type=ResourceType.CHART, uuid="u1", remote_integer_id="1", resource_name="My Chart"))
|
||||
db_session.add(ResourceMapping(environment_id="prod", resource_type=ResourceType.DATASET, uuid="u2", remote_integer_id="2", resource_name="My Dataset"))
|
||||
db_session.commit()
|
||||
|
||||
result = await get_resource_mappings(skip=0, limit=50, search=None, env_id=None, resource_type="dataset", db=db_session, _=None)
|
||||
assert result["total"] == 1
|
||||
assert result["items"][0]["resource_type"] == "dataset"
|
||||
|
||||
|
||||
# --- trigger_sync_now tests ---
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_env():
|
||||
"""Creates a mock config environment object."""
|
||||
env = MagicMock()
|
||||
env.id = "test-env-1"
|
||||
env.name = "Test Env"
|
||||
env.url = "http://superset.test"
|
||||
env.username = "admin"
|
||||
env.password = "admin"
|
||||
env.verify_ssl = False
|
||||
env.timeout = 30
|
||||
return env
|
||||
|
||||
|
||||
def _make_sync_config_manager(environments):
|
||||
"""Creates a mock config manager with environments list."""
|
||||
settings = MagicMock()
|
||||
settings.migration_sync_cron = "0 2 * * *"
|
||||
config = MagicMock()
|
||||
config.settings = settings
|
||||
config.environments = environments
|
||||
cm = MagicMock()
|
||||
cm.get_config.return_value = config
|
||||
cm.get_environments.return_value = environments
|
||||
return cm
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_sync_now_creates_env_row_and_syncs(db_session, _mock_env):
|
||||
"""Verify that trigger_sync_now creates an Environment row in DB before syncing,
|
||||
preventing FK constraint violations on resource_mappings inserts."""
|
||||
from src.api.routes.migration import trigger_sync_now
|
||||
from src.models.mapping import Environment as EnvironmentModel
|
||||
|
||||
cm = _make_sync_config_manager([_mock_env])
|
||||
|
||||
with patch("src.api.routes.migration.SupersetClient") as MockClient, \
|
||||
patch("src.api.routes.migration.IdMappingService") as MockService:
|
||||
mock_client_instance = MagicMock()
|
||||
MockClient.return_value = mock_client_instance
|
||||
mock_service_instance = MagicMock()
|
||||
MockService.return_value = mock_service_instance
|
||||
|
||||
result = await trigger_sync_now(config_manager=cm, db=db_session, _=None)
|
||||
|
||||
# Environment row must exist in DB
|
||||
env_row = db_session.query(EnvironmentModel).filter_by(id="test-env-1").first()
|
||||
assert env_row is not None
|
||||
assert env_row.name == "Test Env"
|
||||
assert env_row.url == "http://superset.test"
|
||||
|
||||
# Sync must have been called
|
||||
mock_service_instance.sync_environment.assert_called_once_with("test-env-1", mock_client_instance)
|
||||
assert result["synced_count"] == 1
|
||||
assert result["failed_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_sync_now_rejects_empty_environments(db_session):
|
||||
"""Verify 400 error when no environments are configured."""
|
||||
from src.api.routes.migration import trigger_sync_now
|
||||
|
||||
cm = _make_sync_config_manager([])
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await trigger_sync_now(config_manager=cm, db=db_session, _=None)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "No environments" in exc_info.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_sync_now_handles_partial_failure(db_session, _mock_env):
|
||||
"""Verify that if sync_environment raises for one env, it's captured in failed list."""
|
||||
from src.api.routes.migration import trigger_sync_now
|
||||
|
||||
env2 = MagicMock()
|
||||
env2.id = "test-env-2"
|
||||
env2.name = "Failing Env"
|
||||
env2.url = "http://fail.test"
|
||||
env2.username = "admin"
|
||||
env2.password = "admin"
|
||||
env2.verify_ssl = False
|
||||
env2.timeout = 30
|
||||
|
||||
cm = _make_sync_config_manager([_mock_env, env2])
|
||||
|
||||
with patch("src.api.routes.migration.SupersetClient") as MockClient, \
|
||||
patch("src.api.routes.migration.IdMappingService") as MockService:
|
||||
mock_service_instance = MagicMock()
|
||||
mock_service_instance.sync_environment.side_effect = [None, RuntimeError("Connection refused")]
|
||||
MockService.return_value = mock_service_instance
|
||||
MockClient.return_value = MagicMock()
|
||||
|
||||
result = await trigger_sync_now(config_manager=cm, db=db_session, _=None)
|
||||
|
||||
assert result["synced_count"] == 1
|
||||
assert result["failed_count"] == 1
|
||||
assert result["details"]["failed"][0]["env_id"] == "test-env-2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_sync_now_idempotent_env_upsert(db_session, _mock_env):
|
||||
"""Verify that calling sync twice doesn't duplicate the Environment row."""
|
||||
from src.api.routes.migration import trigger_sync_now
|
||||
from src.models.mapping import Environment as EnvironmentModel
|
||||
|
||||
cm = _make_sync_config_manager([_mock_env])
|
||||
|
||||
with patch("src.api.routes.migration.SupersetClient"), \
|
||||
patch("src.api.routes.migration.IdMappingService"):
|
||||
await trigger_sync_now(config_manager=cm, db=db_session, _=None)
|
||||
await trigger_sync_now(config_manager=cm, db=db_session, _=None)
|
||||
|
||||
env_count = db_session.query(EnvironmentModel).filter_by(id="test-env-1").count()
|
||||
assert env_count == 1
|
||||
|
||||
|
||||
# --- get_dashboards tests ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboards_success(_mock_env):
|
||||
from src.api.routes.migration import get_dashboards
|
||||
cm = _make_sync_config_manager([_mock_env])
|
||||
|
||||
with patch("src.api.routes.migration.SupersetClient") as MockClient:
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_dashboards_summary.return_value = [{"id": 1, "title": "Test"}]
|
||||
MockClient.return_value = mock_client
|
||||
|
||||
result = await get_dashboards(env_id="test-env-1", config_manager=cm, _=None)
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboards_invalid_env_raises_404(_mock_env):
|
||||
from src.api.routes.migration import get_dashboards
|
||||
cm = _make_sync_config_manager([_mock_env])
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_dashboards(env_id="wrong-env", config_manager=cm, _=None)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
# --- execute_migration tests ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_migration_success(_mock_env):
|
||||
from src.api.routes.migration import execute_migration
|
||||
from src.models.dashboard import DashboardSelection
|
||||
|
||||
cm = _make_sync_config_manager([_mock_env, _mock_env]) # Need both source/target
|
||||
tm = MagicMock()
|
||||
tm.create_task = AsyncMock(return_value=MagicMock(id="task-123"))
|
||||
|
||||
selection = DashboardSelection(
|
||||
source_env_id="test-env-1",
|
||||
target_env_id="test-env-1",
|
||||
selected_ids=[1, 2]
|
||||
)
|
||||
|
||||
result = await execute_migration(selection=selection, config_manager=cm, task_manager=tm, _=None)
|
||||
assert result["task_id"] == "task-123"
|
||||
tm.create_task.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_migration_invalid_env_raises_400(_mock_env):
|
||||
from src.api.routes.migration import execute_migration
|
||||
from src.models.dashboard import DashboardSelection
|
||||
|
||||
cm = _make_sync_config_manager([_mock_env])
|
||||
selection = DashboardSelection(
|
||||
source_env_id="test-env-1",
|
||||
target_env_id="non-existent",
|
||||
selected_ids=[1]
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await execute_migration(selection=selection, config_manager=cm, task_manager=MagicMock(), _=None)
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dry_run_migration_returns_diff_and_risk(db_session):
|
||||
# @TEST_EDGE: missing_target_datasource -> validates high risk item generation
|
||||
# @TEST_EDGE: breaking_reference -> validates high risk on missing dataset link
|
||||
from src.api.routes.migration import dry_run_migration
|
||||
from src.models.dashboard import DashboardSelection
|
||||
|
||||
env_source = MagicMock()
|
||||
env_source.id = "src"
|
||||
env_source.name = "Source"
|
||||
env_source.url = "http://source"
|
||||
env_source.username = "admin"
|
||||
env_source.password = "admin"
|
||||
env_source.verify_ssl = False
|
||||
env_source.timeout = 30
|
||||
|
||||
env_target = MagicMock()
|
||||
env_target.id = "tgt"
|
||||
env_target.name = "Target"
|
||||
env_target.url = "http://target"
|
||||
env_target.username = "admin"
|
||||
env_target.password = "admin"
|
||||
env_target.verify_ssl = False
|
||||
env_target.timeout = 30
|
||||
|
||||
cm = _make_sync_config_manager([env_source, env_target])
|
||||
selection = DashboardSelection(
|
||||
selected_ids=[42],
|
||||
source_env_id="src",
|
||||
target_env_id="tgt",
|
||||
replace_db_config=False,
|
||||
fix_cross_filters=True,
|
||||
)
|
||||
|
||||
with patch("src.api.routes.migration.SupersetClient") as MockClient, \
|
||||
patch("src.api.routes.migration.MigrationDryRunService") as MockService:
|
||||
source_client = MagicMock()
|
||||
target_client = MagicMock()
|
||||
MockClient.side_effect = [source_client, target_client]
|
||||
|
||||
service_instance = MagicMock()
|
||||
service_payload = {
|
||||
"generated_at": "2026-02-27T00:00:00+00:00",
|
||||
"selection": selection.model_dump(),
|
||||
"selected_dashboard_titles": ["Sales"],
|
||||
"diff": {
|
||||
"dashboards": {"create": [], "update": [{"uuid": "dash-1"}], "delete": []},
|
||||
"charts": {"create": [{"uuid": "chart-1"}], "update": [], "delete": []},
|
||||
"datasets": {"create": [{"uuid": "dataset-1"}], "update": [], "delete": []},
|
||||
},
|
||||
"summary": {
|
||||
"dashboards": {"create": 0, "update": 1, "delete": 0},
|
||||
"charts": {"create": 1, "update": 0, "delete": 0},
|
||||
"datasets": {"create": 1, "update": 0, "delete": 0},
|
||||
"selected_dashboards": 1,
|
||||
},
|
||||
"risk": {
|
||||
"score": 75,
|
||||
"level": "high",
|
||||
"items": [
|
||||
{"code": "missing_datasource"},
|
||||
{"code": "breaking_reference"},
|
||||
],
|
||||
},
|
||||
}
|
||||
service_instance.run.return_value = service_payload
|
||||
MockService.return_value = service_instance
|
||||
|
||||
result = await dry_run_migration(selection=selection, config_manager=cm, db=db_session, _=None)
|
||||
|
||||
assert result["summary"]["dashboards"]["update"] == 1
|
||||
assert result["summary"]["charts"]["create"] == 1
|
||||
assert result["summary"]["datasets"]["create"] == 1
|
||||
assert result["risk"]["score"] > 0
|
||||
assert any(item["code"] == "missing_datasource" for item in result["risk"]["items"])
|
||||
assert any(item["code"] == "breaking_reference" for item in result["risk"]["items"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dry_run_migration_rejects_same_environment(db_session):
|
||||
from src.api.routes.migration import dry_run_migration
|
||||
from src.models.dashboard import DashboardSelection
|
||||
|
||||
env = MagicMock()
|
||||
env.id = "same"
|
||||
env.name = "Same"
|
||||
env.url = "http://same"
|
||||
env.username = "admin"
|
||||
env.password = "admin"
|
||||
env.verify_ssl = False
|
||||
env.timeout = 30
|
||||
|
||||
cm = _make_sync_config_manager([env])
|
||||
selection = DashboardSelection(selected_ids=[1], source_env_id="same", target_env_id="same")
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await dry_run_migration(selection=selection, config_manager=cm, db=db_session, _=None)
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
# [/DEF:backend.src.api.routes.__tests__.test_migration_routes:Module]
|
||||
@@ -1,5 +1,5 @@
|
||||
# [DEF:backend.tests.test_reports_api:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: tests, reports, api, contract, pagination, filtering
|
||||
# @PURPOSE: Contract tests for GET /api/reports defaults, pagination, and filtering behavior.
|
||||
# @LAYER: Domain (Tests)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# [DEF:backend.tests.test_reports_detail_api:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: tests, reports, api, detail, diagnostics
|
||||
# @PURPOSE: Contract tests for GET /api/reports/{report_id} detail endpoint behavior.
|
||||
# @LAYER: Domain (Tests)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# [DEF:backend.tests.test_reports_openapi_conformance:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: tests, reports, openapi, conformance
|
||||
# @PURPOSE: Validate implemented reports payload shape against OpenAPI-required top-level contract fields.
|
||||
# @LAYER: Domain (Tests)
|
||||
|
||||
2114
backend/src/api/routes/assistant.py
Normal file
2114
backend/src/api/routes/assistant.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
# [DEF:backend.src.api.routes.dashboards:Module]
|
||||
#
|
||||
# @TIER: STANDARD
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: api, dashboards, resources, hub
|
||||
# @PURPOSE: API endpoints for the Dashboard Hub - listing dashboards with Git and task status
|
||||
# @LAYER: API
|
||||
@@ -9,14 +9,40 @@
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.superset_client
|
||||
#
|
||||
# @INVARIANT: All dashboard responses include git_status and last_task metadata
|
||||
#
|
||||
# @TEST_CONTRACT: DashboardsAPI -> {
|
||||
# required_fields: {env_id: string, page: integer, page_size: integer},
|
||||
# optional_fields: {search: string},
|
||||
# invariants: ["Pagination must be valid", "Environment must exist"]
|
||||
# }
|
||||
#
|
||||
# @TEST_FIXTURE: dashboard_list_happy -> {
|
||||
# "env_id": "prod",
|
||||
# "expected_count": 1,
|
||||
# "dashboards": [{"id": 1, "title": "Main Revenue"}]
|
||||
# }
|
||||
#
|
||||
# @TEST_EDGE: pagination_zero_page -> {"env_id": "prod", "page": 0, "status": 400}
|
||||
# @TEST_EDGE: pagination_oversize -> {"env_id": "prod", "page_size": 101, "status": 400}
|
||||
# @TEST_EDGE: missing_env -> {"env_id": "ghost", "status": 404}
|
||||
# @TEST_EDGE: empty_dashboards -> {"env_id": "empty_env", "expected_total": 0}
|
||||
# @TEST_EDGE: external_superset_failure -> {"env_id": "bad_conn", "status": 503}
|
||||
#
|
||||
# @TEST_INVARIANT: metadata_consistency -> verifies: [dashboard_list_happy, empty_dashboards]
|
||||
#
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import List, Optional, Dict
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from typing import List, Optional, Dict, Any
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
from pydantic import BaseModel, Field
|
||||
from ...dependencies import get_config_manager, get_task_manager, get_resource_service, get_mapping_service, has_permission
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.superset_client import SupersetClient
|
||||
from ...core.utils.network import DashboardNotFoundError
|
||||
from ...services.resource_service import ResourceService
|
||||
# [/SECTION]
|
||||
|
||||
router = APIRouter(prefix="/api/dashboards", tags=["Dashboards"])
|
||||
@@ -24,13 +50,19 @@ router = APIRouter(prefix="/api/dashboards", tags=["Dashboards"])
|
||||
# [DEF:GitStatus:DataClass]
|
||||
class GitStatus(BaseModel):
|
||||
branch: Optional[str] = None
|
||||
sync_status: Optional[str] = Field(None, pattern="^OK|DIFF$")
|
||||
sync_status: Optional[str] = Field(None, pattern="^OK|DIFF|NO_REPO|ERROR$")
|
||||
has_repo: Optional[bool] = None
|
||||
has_changes_for_commit: Optional[bool] = None
|
||||
# [/DEF:GitStatus:DataClass]
|
||||
|
||||
# [DEF:LastTask:DataClass]
|
||||
class LastTask(BaseModel):
|
||||
task_id: Optional[str] = None
|
||||
status: Optional[str] = Field(None, pattern="^RUNNING|SUCCESS|ERROR|WAITING_INPUT$")
|
||||
status: Optional[str] = Field(
|
||||
None,
|
||||
pattern="^PENDING|RUNNING|SUCCESS|FAILED|ERROR|AWAITING_INPUT|WAITING_INPUT|AWAITING_MAPPING$",
|
||||
)
|
||||
validation_status: Optional[str] = Field(None, pattern="^PASS|FAIL|WARN|UNKNOWN$")
|
||||
# [/DEF:LastTask:DataClass]
|
||||
|
||||
# [DEF:DashboardItem:DataClass]
|
||||
@@ -40,6 +72,9 @@ class DashboardItem(BaseModel):
|
||||
slug: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
last_modified: Optional[str] = None
|
||||
created_by: Optional[str] = None
|
||||
modified_by: Optional[str] = None
|
||||
owners: Optional[List[str]] = None
|
||||
git_status: Optional[GitStatus] = None
|
||||
last_task: Optional[LastTask] = None
|
||||
# [/DEF:DashboardItem:DataClass]
|
||||
@@ -88,6 +123,38 @@ class DashboardDetailResponse(BaseModel):
|
||||
dataset_count: int
|
||||
# [/DEF:DashboardDetailResponse:DataClass]
|
||||
|
||||
# [DEF:DashboardTaskHistoryItem:DataClass]
|
||||
class DashboardTaskHistoryItem(BaseModel):
|
||||
id: str
|
||||
plugin_id: str
|
||||
status: str
|
||||
validation_status: Optional[str] = None
|
||||
started_at: Optional[str] = None
|
||||
finished_at: Optional[str] = None
|
||||
env_id: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
# [/DEF:DashboardTaskHistoryItem:DataClass]
|
||||
|
||||
# [DEF:DashboardTaskHistoryResponse:DataClass]
|
||||
class DashboardTaskHistoryResponse(BaseModel):
|
||||
dashboard_id: int
|
||||
items: List[DashboardTaskHistoryItem]
|
||||
# [/DEF:DashboardTaskHistoryResponse:DataClass]
|
||||
|
||||
# [DEF:DatabaseMapping:DataClass]
|
||||
class DatabaseMapping(BaseModel):
|
||||
source_db: str
|
||||
target_db: str
|
||||
source_db_uuid: Optional[str] = None
|
||||
target_db_uuid: Optional[str] = None
|
||||
confidence: float
|
||||
# [/DEF:DatabaseMapping:DataClass]
|
||||
|
||||
# [DEF:DatabaseMappingsResponse:DataClass]
|
||||
class DatabaseMappingsResponse(BaseModel):
|
||||
mappings: List[DatabaseMapping]
|
||||
# [/DEF:DatabaseMappingsResponse:DataClass]
|
||||
|
||||
# [DEF:get_dashboards:Function]
|
||||
# @PURPOSE: Fetch list of dashboards from a specific environment with Git status and last task status
|
||||
# @PRE: env_id must be a valid environment ID
|
||||
@@ -131,27 +198,66 @@ async def get_dashboards(
|
||||
try:
|
||||
# Get all tasks for status lookup
|
||||
all_tasks = task_manager.get_all_tasks()
|
||||
|
||||
# Fetch dashboards with status using ResourceService
|
||||
dashboards = await resource_service.get_dashboards_with_status(env, all_tasks)
|
||||
|
||||
# Apply search filter if provided
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
dashboards = [
|
||||
d for d in dashboards
|
||||
if search_lower in d.get('title', '').lower()
|
||||
or search_lower in d.get('slug', '').lower()
|
||||
]
|
||||
|
||||
# Calculate pagination
|
||||
total = len(dashboards)
|
||||
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
|
||||
# Slice dashboards for current page
|
||||
paginated_dashboards = dashboards[start_idx:end_idx]
|
||||
|
||||
# Fast path: real ResourceService -> one Superset page call per API request.
|
||||
if isinstance(resource_service, ResourceService):
|
||||
try:
|
||||
page_payload = await resource_service.get_dashboards_page_with_status(
|
||||
env,
|
||||
all_tasks,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
include_git_status=False,
|
||||
)
|
||||
paginated_dashboards = page_payload["dashboards"]
|
||||
total = page_payload["total"]
|
||||
total_pages = page_payload["total_pages"]
|
||||
except Exception as page_error:
|
||||
logger.warning(
|
||||
"[get_dashboards][Action] Page-based fetch failed; using compatibility fallback: %s",
|
||||
page_error,
|
||||
)
|
||||
dashboards = await resource_service.get_dashboards_with_status(
|
||||
env,
|
||||
all_tasks,
|
||||
include_git_status=False,
|
||||
)
|
||||
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
dashboards = [
|
||||
d for d in dashboards
|
||||
if search_lower in d.get('title', '').lower()
|
||||
or search_lower in d.get('slug', '').lower()
|
||||
]
|
||||
|
||||
total = len(dashboards)
|
||||
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
paginated_dashboards = dashboards[start_idx:end_idx]
|
||||
else:
|
||||
# Compatibility path for mocked services in route tests.
|
||||
dashboards = await resource_service.get_dashboards_with_status(
|
||||
env,
|
||||
all_tasks,
|
||||
include_git_status=False,
|
||||
)
|
||||
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
dashboards = [
|
||||
d for d in dashboards
|
||||
if search_lower in d.get('title', '').lower()
|
||||
or search_lower in d.get('slug', '').lower()
|
||||
]
|
||||
|
||||
total = len(dashboards)
|
||||
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
paginated_dashboards = dashboards[start_idx:end_idx]
|
||||
|
||||
logger.info(f"[get_dashboards][Coherence:OK] Returning {len(paginated_dashboards)} dashboards (page {page}/{total_pages}, total: {total})")
|
||||
|
||||
@@ -168,12 +274,67 @@ async def get_dashboards(
|
||||
raise HTTPException(status_code=503, detail=f"Failed to fetch dashboards: {str(e)}")
|
||||
# [/DEF:get_dashboards:Function]
|
||||
|
||||
# [DEF:get_database_mappings:Function]
|
||||
# @PURPOSE: Get database mapping suggestions between source and target environments
|
||||
# @PRE: User has permission plugin:migration:read
|
||||
# @PRE: source_env_id and target_env_id are valid environment IDs
|
||||
# @POST: Returns list of suggested database mappings with confidence scores
|
||||
# @PARAM: source_env_id (str) - Source environment ID
|
||||
# @PARAM: target_env_id (str) - Target environment ID
|
||||
# @RETURN: DatabaseMappingsResponse - List of suggested mappings
|
||||
# @RELATION: CALLS -> MappingService.get_suggestions
|
||||
@router.get("/db-mappings", response_model=DatabaseMappingsResponse)
|
||||
async def get_database_mappings(
|
||||
source_env_id: str,
|
||||
target_env_id: str,
|
||||
config_manager=Depends(get_config_manager),
|
||||
mapping_service=Depends(get_mapping_service),
|
||||
_ = Depends(has_permission("plugin:migration", "READ"))
|
||||
):
|
||||
with belief_scope("get_database_mappings", f"source={source_env_id}, target={target_env_id}"):
|
||||
# Validate environments exist
|
||||
environments = config_manager.get_environments()
|
||||
source_env = next((e for e in environments if e.id == source_env_id), None)
|
||||
target_env = next((e for e in environments if e.id == target_env_id), None)
|
||||
|
||||
if not source_env:
|
||||
logger.error(f"[get_database_mappings][Coherence:Failed] Source environment not found: {source_env_id}")
|
||||
raise HTTPException(status_code=404, detail="Source environment not found")
|
||||
if not target_env:
|
||||
logger.error(f"[get_database_mappings][Coherence:Failed] Target environment not found: {target_env_id}")
|
||||
raise HTTPException(status_code=404, detail="Target environment not found")
|
||||
|
||||
try:
|
||||
# Get mapping suggestions using MappingService
|
||||
suggestions = await mapping_service.get_suggestions(source_env_id, target_env_id)
|
||||
|
||||
# Format suggestions as DatabaseMapping objects
|
||||
mappings = [
|
||||
DatabaseMapping(
|
||||
source_db=s.get('source_db', ''),
|
||||
target_db=s.get('target_db', ''),
|
||||
source_db_uuid=s.get('source_db_uuid'),
|
||||
target_db_uuid=s.get('target_db_uuid'),
|
||||
confidence=s.get('confidence', 0.0)
|
||||
)
|
||||
for s in suggestions
|
||||
]
|
||||
|
||||
logger.info(f"[get_database_mappings][Coherence:OK] Returning {len(mappings)} database mapping suggestions")
|
||||
|
||||
return DatabaseMappingsResponse(mappings=mappings)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[get_database_mappings][Coherence:Failed] Failed to get database mappings: {e}")
|
||||
raise HTTPException(status_code=503, detail=f"Failed to get database mappings: {str(e)}")
|
||||
# [/DEF:get_database_mappings:Function]
|
||||
|
||||
# [DEF:get_dashboard_detail:Function]
|
||||
# @PURPOSE: Fetch detailed dashboard info with related charts and datasets
|
||||
# @PRE: env_id must be valid and dashboard_id must exist
|
||||
# @POST: Returns dashboard detail payload for overview page
|
||||
# @RELATION: CALLS -> SupersetClient.get_dashboard_detail
|
||||
@router.get("/{dashboard_id}", response_model=DashboardDetailResponse)
|
||||
@router.get("/{dashboard_id:int}", response_model=DashboardDetailResponse)
|
||||
async def get_dashboard_detail(
|
||||
dashboard_id: int,
|
||||
env_id: str,
|
||||
@@ -201,6 +362,190 @@ async def get_dashboard_detail(
|
||||
raise HTTPException(status_code=503, detail=f"Failed to fetch dashboard detail: {str(e)}")
|
||||
# [/DEF:get_dashboard_detail:Function]
|
||||
|
||||
|
||||
# [DEF:_task_matches_dashboard:Function]
|
||||
# @PURPOSE: Checks whether task params are tied to a specific dashboard and environment.
|
||||
# @PRE: task-like object exposes plugin_id and params fields.
|
||||
# @POST: Returns True only for supported task plugins tied to dashboard_id (+optional env_id).
|
||||
def _task_matches_dashboard(task: Any, dashboard_id: int, env_id: Optional[str]) -> bool:
|
||||
plugin_id = getattr(task, "plugin_id", None)
|
||||
if plugin_id not in {"superset-backup", "llm_dashboard_validation"}:
|
||||
return False
|
||||
|
||||
params = getattr(task, "params", {}) or {}
|
||||
dashboard_id_str = str(dashboard_id)
|
||||
|
||||
if plugin_id == "llm_dashboard_validation":
|
||||
task_dashboard_id = params.get("dashboard_id")
|
||||
if str(task_dashboard_id) != dashboard_id_str:
|
||||
return False
|
||||
if env_id:
|
||||
task_env = params.get("environment_id")
|
||||
return str(task_env) == str(env_id)
|
||||
return True
|
||||
|
||||
# superset-backup can pass dashboards as "dashboard_ids" or "dashboards"
|
||||
dashboard_ids = params.get("dashboard_ids") or params.get("dashboards") or []
|
||||
normalized_ids = {str(item) for item in dashboard_ids}
|
||||
if dashboard_id_str not in normalized_ids:
|
||||
return False
|
||||
if env_id:
|
||||
task_env = params.get("environment_id") or params.get("env")
|
||||
return str(task_env) == str(env_id)
|
||||
return True
|
||||
# [/DEF:_task_matches_dashboard:Function]
|
||||
|
||||
|
||||
# [DEF:get_dashboard_tasks_history:Function]
|
||||
# @PURPOSE: Returns history of backup and LLM validation tasks for a dashboard.
|
||||
# @PRE: dashboard_id is valid integer.
|
||||
# @POST: Response contains sorted task history (newest first).
|
||||
@router.get("/{dashboard_id:int}/tasks", response_model=DashboardTaskHistoryResponse)
|
||||
async def get_dashboard_tasks_history(
|
||||
dashboard_id: int,
|
||||
env_id: Optional[str] = None,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
task_manager=Depends(get_task_manager),
|
||||
_ = Depends(has_permission("tasks", "READ"))
|
||||
):
|
||||
with belief_scope("get_dashboard_tasks_history", f"dashboard_id={dashboard_id}, env_id={env_id}, limit={limit}"):
|
||||
matching_tasks = []
|
||||
for task in task_manager.get_all_tasks():
|
||||
if _task_matches_dashboard(task, dashboard_id, env_id):
|
||||
matching_tasks.append(task)
|
||||
|
||||
def _sort_key(task_obj: Any) -> str:
|
||||
return (
|
||||
str(getattr(task_obj, "started_at", "") or "")
|
||||
or str(getattr(task_obj, "finished_at", "") or "")
|
||||
)
|
||||
|
||||
matching_tasks.sort(key=_sort_key, reverse=True)
|
||||
selected = matching_tasks[:limit]
|
||||
|
||||
items = []
|
||||
for task in selected:
|
||||
result = getattr(task, "result", None)
|
||||
summary = None
|
||||
validation_status = None
|
||||
if isinstance(result, dict):
|
||||
raw_validation_status = result.get("status")
|
||||
if raw_validation_status is not None:
|
||||
validation_status = str(raw_validation_status)
|
||||
summary = (
|
||||
result.get("summary")
|
||||
or result.get("status")
|
||||
or result.get("message")
|
||||
)
|
||||
params = getattr(task, "params", {}) or {}
|
||||
items.append(
|
||||
DashboardTaskHistoryItem(
|
||||
id=str(getattr(task, "id", "")),
|
||||
plugin_id=str(getattr(task, "plugin_id", "")),
|
||||
status=str(getattr(task, "status", "")),
|
||||
validation_status=validation_status,
|
||||
started_at=getattr(task, "started_at", None).isoformat() if getattr(task, "started_at", None) else None,
|
||||
finished_at=getattr(task, "finished_at", None).isoformat() if getattr(task, "finished_at", None) else None,
|
||||
env_id=str(params.get("environment_id") or params.get("env")) if (params.get("environment_id") or params.get("env")) else None,
|
||||
summary=summary,
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(f"[get_dashboard_tasks_history][Coherence:OK] Found {len(items)} tasks for dashboard {dashboard_id}")
|
||||
return DashboardTaskHistoryResponse(dashboard_id=dashboard_id, items=items)
|
||||
# [/DEF:get_dashboard_tasks_history:Function]
|
||||
|
||||
|
||||
# [DEF:get_dashboard_thumbnail:Function]
|
||||
# @PURPOSE: Proxies Superset dashboard thumbnail with cache support.
|
||||
# @PRE: env_id must exist.
|
||||
# @POST: Returns image bytes or 202 when thumbnail is being prepared by Superset.
|
||||
@router.get("/{dashboard_id:int}/thumbnail")
|
||||
async def get_dashboard_thumbnail(
|
||||
dashboard_id: int,
|
||||
env_id: str,
|
||||
force: bool = Query(False),
|
||||
config_manager=Depends(get_config_manager),
|
||||
_ = Depends(has_permission("plugin:migration", "READ"))
|
||||
):
|
||||
with belief_scope("get_dashboard_thumbnail", f"dashboard_id={dashboard_id}, env_id={env_id}, force={force}"):
|
||||
environments = config_manager.get_environments()
|
||||
env = next((e for e in environments if e.id == env_id), None)
|
||||
if not env:
|
||||
logger.error(f"[get_dashboard_thumbnail][Coherence:Failed] Environment not found: {env_id}")
|
||||
raise HTTPException(status_code=404, detail="Environment not found")
|
||||
|
||||
try:
|
||||
client = SupersetClient(env)
|
||||
digest = None
|
||||
thumb_endpoint = None
|
||||
|
||||
# Preferred flow (newer Superset): ask server to cache screenshot and return digest/image_url.
|
||||
try:
|
||||
screenshot_payload = client.network.request(
|
||||
method="POST",
|
||||
endpoint=f"/dashboard/{dashboard_id}/cache_dashboard_screenshot/",
|
||||
json={"force": force},
|
||||
)
|
||||
payload = screenshot_payload.get("result", screenshot_payload) if isinstance(screenshot_payload, dict) else {}
|
||||
image_url = payload.get("image_url", "") if isinstance(payload, dict) else ""
|
||||
if isinstance(image_url, str) and image_url:
|
||||
matched = re.search(r"/dashboard/\d+/(?:thumbnail|screenshot)/([^/]+)/?$", image_url)
|
||||
if matched:
|
||||
digest = matched.group(1)
|
||||
except DashboardNotFoundError:
|
||||
logger.warning(
|
||||
"[get_dashboard_thumbnail][Fallback] cache_dashboard_screenshot endpoint unavailable, fallback to dashboard.thumbnail_url"
|
||||
)
|
||||
|
||||
# Fallback flow (older Superset): read thumbnail_url from dashboard payload.
|
||||
if not digest:
|
||||
dashboard_payload = client.network.request(
|
||||
method="GET",
|
||||
endpoint=f"/dashboard/{dashboard_id}",
|
||||
)
|
||||
dashboard_data = dashboard_payload.get("result", dashboard_payload) if isinstance(dashboard_payload, dict) else {}
|
||||
thumbnail_url = dashboard_data.get("thumbnail_url", "") if isinstance(dashboard_data, dict) else ""
|
||||
if isinstance(thumbnail_url, str) and thumbnail_url:
|
||||
parsed = urlparse(thumbnail_url)
|
||||
parsed_path = parsed.path or thumbnail_url
|
||||
if parsed_path.startswith("/api/v1/"):
|
||||
parsed_path = parsed_path[len("/api/v1"):]
|
||||
thumb_endpoint = parsed_path
|
||||
matched = re.search(r"/dashboard/\d+/(?:thumbnail|screenshot)/([^/]+)/?$", parsed_path)
|
||||
if matched:
|
||||
digest = matched.group(1)
|
||||
|
||||
if not thumb_endpoint:
|
||||
thumb_endpoint = f"/dashboard/{dashboard_id}/thumbnail/{digest or 'latest'}/"
|
||||
|
||||
thumb_response = client.network.request(
|
||||
method="GET",
|
||||
endpoint=thumb_endpoint,
|
||||
raw_response=True,
|
||||
allow_redirects=True,
|
||||
)
|
||||
|
||||
if thumb_response.status_code == 202:
|
||||
payload_202: Dict[str, Any] = {}
|
||||
try:
|
||||
payload_202 = thumb_response.json()
|
||||
except Exception:
|
||||
payload_202 = {"message": "Thumbnail is being generated"}
|
||||
return JSONResponse(status_code=202, content=payload_202)
|
||||
|
||||
content_type = thumb_response.headers.get("Content-Type", "image/png")
|
||||
return Response(content=thumb_response.content, media_type=content_type)
|
||||
except DashboardNotFoundError as e:
|
||||
logger.error(f"[get_dashboard_thumbnail][Coherence:Failed] Dashboard not found for thumbnail: {e}")
|
||||
raise HTTPException(status_code=404, detail="Dashboard thumbnail not found")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[get_dashboard_thumbnail][Coherence:Failed] Failed to fetch dashboard thumbnail: {e}")
|
||||
raise HTTPException(status_code=503, detail=f"Failed to fetch dashboard thumbnail: {str(e)}")
|
||||
# [/DEF:get_dashboard_thumbnail:Function]
|
||||
|
||||
# [DEF:MigrateRequest:DataClass]
|
||||
class MigrateRequest(BaseModel):
|
||||
source_env_id: str = Field(..., description="Source environment ID")
|
||||
@@ -337,60 +682,4 @@ async def backup_dashboards(
|
||||
raise HTTPException(status_code=503, detail=f"Failed to create backup task: {str(e)}")
|
||||
# [/DEF:backup_dashboards:Function]
|
||||
|
||||
# [DEF:DatabaseMapping:DataClass]
|
||||
class DatabaseMapping(BaseModel):
|
||||
source_db: str
|
||||
target_db: str
|
||||
source_db_uuid: Optional[str] = None
|
||||
target_db_uuid: Optional[str] = None
|
||||
confidence: float
|
||||
# [/DEF:DatabaseMapping:DataClass]
|
||||
|
||||
# [DEF:DatabaseMappingsResponse:DataClass]
|
||||
class DatabaseMappingsResponse(BaseModel):
|
||||
mappings: List[DatabaseMapping]
|
||||
# [/DEF:DatabaseMappingsResponse:DataClass]
|
||||
|
||||
# [DEF:get_database_mappings:Function]
|
||||
# @PURPOSE: Get database mapping suggestions between source and target environments
|
||||
# @PRE: User has permission plugin:migration:read
|
||||
# @PRE: source_env_id and target_env_id are valid environment IDs
|
||||
# @POST: Returns list of suggested database mappings with confidence scores
|
||||
# @PARAM: source_env_id (str) - Source environment ID
|
||||
# @PARAM: target_env_id (str) - Target environment ID
|
||||
# @RETURN: DatabaseMappingsResponse - List of suggested mappings
|
||||
# @RELATION: CALLS -> MappingService.get_suggestions
|
||||
@router.get("/db-mappings", response_model=DatabaseMappingsResponse)
|
||||
async def get_database_mappings(
|
||||
source_env_id: str,
|
||||
target_env_id: str,
|
||||
mapping_service=Depends(get_mapping_service),
|
||||
_ = Depends(has_permission("plugin:migration", "READ"))
|
||||
):
|
||||
with belief_scope("get_database_mappings", f"source={source_env_id}, target={target_env_id}"):
|
||||
try:
|
||||
# Get mapping suggestions using MappingService
|
||||
suggestions = await mapping_service.get_suggestions(source_env_id, target_env_id)
|
||||
|
||||
# Format suggestions as DatabaseMapping objects
|
||||
mappings = [
|
||||
DatabaseMapping(
|
||||
source_db=s.get('source_db', ''),
|
||||
target_db=s.get('target_db', ''),
|
||||
source_db_uuid=s.get('source_db_uuid'),
|
||||
target_db_uuid=s.get('target_db_uuid'),
|
||||
confidence=s.get('confidence', 0.0)
|
||||
)
|
||||
for s in suggestions
|
||||
]
|
||||
|
||||
logger.info(f"[get_database_mappings][Coherence:OK] Returning {len(mappings)} database mapping suggestions")
|
||||
|
||||
return DatabaseMappingsResponse(mappings=mappings)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[get_database_mappings][Coherence:Failed] Failed to get database mappings: {e}")
|
||||
raise HTTPException(status_code=503, detail=f"Failed to get database mappings: {str(e)}")
|
||||
# [/DEF:get_database_mappings:Function]
|
||||
|
||||
# [/DEF:backend.src.api.routes.dashboards:Module]
|
||||
|
||||
@@ -31,6 +31,7 @@ class EnvironmentResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
url: str
|
||||
is_production: bool = False
|
||||
backup_schedule: Optional[ScheduleSchema] = None
|
||||
# [/DEF:EnvironmentResponse:DataClass]
|
||||
|
||||
@@ -63,6 +64,7 @@ async def get_environments(
|
||||
id=e.id,
|
||||
name=e.name,
|
||||
url=e.url,
|
||||
is_production=getattr(e, "is_production", False),
|
||||
backup_schedule=ScheduleSchema(
|
||||
enabled=e.backup_schedule.enabled,
|
||||
cron_expression=e.backup_schedule.cron_expression
|
||||
|
||||
@@ -14,6 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
import typing
|
||||
import os
|
||||
from src.dependencies import get_config_manager, has_permission
|
||||
from src.core.database import get_db
|
||||
from src.models.git import GitServerConfig, GitRepository
|
||||
@@ -21,13 +22,82 @@ from src.api.routes.git_schemas import (
|
||||
GitServerConfigSchema, GitServerConfigCreate,
|
||||
BranchSchema, BranchCreate,
|
||||
BranchCheckout, CommitSchema, CommitCreate,
|
||||
DeploymentEnvironmentSchema, DeployRequest, RepoInitRequest
|
||||
DeploymentEnvironmentSchema, DeployRequest, RepoInitRequest,
|
||||
RepoStatusBatchRequest, RepoStatusBatchResponse,
|
||||
)
|
||||
from src.services.git_service import GitService
|
||||
from src.core.logger import logger, belief_scope
|
||||
from ...services.llm_prompt_templates import (
|
||||
DEFAULT_LLM_PROMPTS,
|
||||
normalize_llm_settings,
|
||||
resolve_bound_provider_id,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["git"])
|
||||
git_service = GitService()
|
||||
MAX_REPOSITORY_STATUS_BATCH = 50
|
||||
|
||||
|
||||
# [DEF:_build_no_repo_status_payload:Function]
|
||||
# @PURPOSE: Build a consistent status payload for dashboards without initialized repositories.
|
||||
# @PRE: None.
|
||||
# @POST: Returns a stable payload compatible with frontend repository status parsing.
|
||||
# @RETURN: dict
|
||||
def _build_no_repo_status_payload() -> dict:
|
||||
return {
|
||||
"is_dirty": False,
|
||||
"untracked_files": [],
|
||||
"modified_files": [],
|
||||
"staged_files": [],
|
||||
"current_branch": None,
|
||||
"upstream_branch": None,
|
||||
"has_upstream": False,
|
||||
"ahead_count": 0,
|
||||
"behind_count": 0,
|
||||
"is_diverged": False,
|
||||
"sync_state": "NO_REPO",
|
||||
"sync_status": "NO_REPO",
|
||||
"has_repo": False,
|
||||
}
|
||||
# [/DEF:_build_no_repo_status_payload:Function]
|
||||
|
||||
|
||||
# [DEF:_handle_unexpected_git_route_error:Function]
|
||||
# @PURPOSE: Convert unexpected route-level exceptions to stable 500 API responses.
|
||||
# @PRE: `error` is a non-HTTPException instance.
|
||||
# @POST: Raises HTTPException(500) with route-specific context.
|
||||
# @PARAM: route_name (str)
|
||||
# @PARAM: error (Exception)
|
||||
def _handle_unexpected_git_route_error(route_name: str, error: Exception) -> None:
|
||||
logger.error(f"[{route_name}][Coherence:Failed] {error}")
|
||||
raise HTTPException(status_code=500, detail=f"{route_name} failed: {str(error)}")
|
||||
# [/DEF:_handle_unexpected_git_route_error:Function]
|
||||
|
||||
|
||||
# [DEF:_resolve_repository_status:Function]
|
||||
# @PURPOSE: Resolve repository status for one dashboard with graceful NO_REPO semantics.
|
||||
# @PRE: `dashboard_id` is a valid integer.
|
||||
# @POST: Returns standard status payload or `NO_REPO` payload when repository path is absent.
|
||||
# @PARAM: dashboard_id (int)
|
||||
# @RETURN: dict
|
||||
def _resolve_repository_status(dashboard_id: int) -> dict:
|
||||
repo_path = git_service._get_repo_path(dashboard_id)
|
||||
if not os.path.exists(repo_path):
|
||||
logger.debug(
|
||||
f"[get_repository_status][Action] Repository is not initialized for dashboard {dashboard_id}"
|
||||
)
|
||||
return _build_no_repo_status_payload()
|
||||
|
||||
try:
|
||||
return git_service.get_status(dashboard_id)
|
||||
except HTTPException as e:
|
||||
if e.status_code == 404:
|
||||
logger.debug(
|
||||
f"[get_repository_status][Action] Repository is not initialized for dashboard {dashboard_id}"
|
||||
)
|
||||
return _build_no_repo_status_payload()
|
||||
raise
|
||||
# [/DEF:_resolve_repository_status:Function]
|
||||
|
||||
# [DEF:get_git_configs:Function]
|
||||
# @PURPOSE: List all configured Git servers.
|
||||
@@ -148,7 +218,9 @@ async def init_repository(
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"[init_repository][Coherence:Failed] Failed to init repository: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
if isinstance(e, HTTPException):
|
||||
raise
|
||||
_handle_unexpected_git_route_error("init_repository", e)
|
||||
# [/DEF:init_repository:Function]
|
||||
|
||||
# [DEF:get_branches:Function]
|
||||
@@ -165,8 +237,10 @@ async def get_branches(
|
||||
with belief_scope("get_branches"):
|
||||
try:
|
||||
return git_service.list_branches(dashboard_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
_handle_unexpected_git_route_error("get_branches", e)
|
||||
# [/DEF:get_branches:Function]
|
||||
|
||||
# [DEF:create_branch:Function]
|
||||
@@ -185,8 +259,10 @@ async def create_branch(
|
||||
try:
|
||||
git_service.create_branch(dashboard_id, branch_data.name, branch_data.from_branch)
|
||||
return {"status": "success"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
_handle_unexpected_git_route_error("create_branch", e)
|
||||
# [/DEF:create_branch:Function]
|
||||
|
||||
# [DEF:checkout_branch:Function]
|
||||
@@ -205,8 +281,10 @@ async def checkout_branch(
|
||||
try:
|
||||
git_service.checkout_branch(dashboard_id, checkout_data.name)
|
||||
return {"status": "success"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
_handle_unexpected_git_route_error("checkout_branch", e)
|
||||
# [/DEF:checkout_branch:Function]
|
||||
|
||||
# [DEF:commit_changes:Function]
|
||||
@@ -225,8 +303,10 @@ async def commit_changes(
|
||||
try:
|
||||
git_service.commit_changes(dashboard_id, commit_data.message, commit_data.files)
|
||||
return {"status": "success"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
_handle_unexpected_git_route_error("commit_changes", e)
|
||||
# [/DEF:commit_changes:Function]
|
||||
|
||||
# [DEF:push_changes:Function]
|
||||
@@ -243,8 +323,10 @@ async def push_changes(
|
||||
try:
|
||||
git_service.push_changes(dashboard_id)
|
||||
return {"status": "success"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
_handle_unexpected_git_route_error("push_changes", e)
|
||||
# [/DEF:push_changes:Function]
|
||||
|
||||
# [DEF:pull_changes:Function]
|
||||
@@ -261,8 +343,10 @@ async def pull_changes(
|
||||
try:
|
||||
git_service.pull_changes(dashboard_id)
|
||||
return {"status": "success"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
_handle_unexpected_git_route_error("pull_changes", e)
|
||||
# [/DEF:pull_changes:Function]
|
||||
|
||||
# [DEF:sync_dashboard:Function]
|
||||
@@ -286,8 +370,10 @@ async def sync_dashboard(
|
||||
"dashboard_id": dashboard_id,
|
||||
"source_env_id": source_env_id
|
||||
})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
_handle_unexpected_git_route_error("sync_dashboard", e)
|
||||
# [/DEF:sync_dashboard:Function]
|
||||
|
||||
# [DEF:get_environments:Function]
|
||||
@@ -333,8 +419,10 @@ async def deploy_dashboard(
|
||||
"dashboard_id": dashboard_id,
|
||||
"environment_id": deploy_data.environment_id
|
||||
})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
_handle_unexpected_git_route_error("deploy_dashboard", e)
|
||||
# [/DEF:deploy_dashboard:Function]
|
||||
|
||||
# [DEF:get_history:Function]
|
||||
@@ -353,14 +441,16 @@ async def get_history(
|
||||
with belief_scope("get_history"):
|
||||
try:
|
||||
return git_service.get_commit_history(dashboard_id, limit)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
_handle_unexpected_git_route_error("get_history", e)
|
||||
# [/DEF:get_history:Function]
|
||||
|
||||
# [DEF:get_repository_status:Function]
|
||||
# @PURPOSE: Get current Git status for a dashboard repository.
|
||||
# @PRE: `dashboard_id` repository exists.
|
||||
# @POST: Returns the status of the working directory (staged, unstaged, untracked).
|
||||
# @PRE: `dashboard_id` is a valid integer.
|
||||
# @POST: Returns repository status; if repo is not initialized, returns `NO_REPO` payload.
|
||||
# @PARAM: dashboard_id (int)
|
||||
# @RETURN: dict
|
||||
@router.get("/repositories/{dashboard_id}/status")
|
||||
@@ -370,11 +460,57 @@ async def get_repository_status(
|
||||
):
|
||||
with belief_scope("get_repository_status"):
|
||||
try:
|
||||
return git_service.get_status(dashboard_id)
|
||||
return _resolve_repository_status(dashboard_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
_handle_unexpected_git_route_error("get_repository_status", e)
|
||||
# [/DEF:get_repository_status:Function]
|
||||
|
||||
|
||||
# [DEF:get_repository_status_batch:Function]
|
||||
# @PURPOSE: Get Git statuses for multiple dashboard repositories in one request.
|
||||
# @PRE: `request.dashboard_ids` is provided.
|
||||
# @POST: Returns `statuses` map where each key is dashboard ID and value is repository status payload.
|
||||
# @PARAM: request (RepoStatusBatchRequest)
|
||||
# @RETURN: RepoStatusBatchResponse
|
||||
@router.post("/repositories/status/batch", response_model=RepoStatusBatchResponse)
|
||||
async def get_repository_status_batch(
|
||||
request: RepoStatusBatchRequest,
|
||||
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||
):
|
||||
with belief_scope("get_repository_status_batch"):
|
||||
dashboard_ids = list(dict.fromkeys(request.dashboard_ids))
|
||||
if len(dashboard_ids) > MAX_REPOSITORY_STATUS_BATCH:
|
||||
logger.warning(
|
||||
"[get_repository_status_batch][Action] Batch size %s exceeds limit %s. Truncating request.",
|
||||
len(dashboard_ids),
|
||||
MAX_REPOSITORY_STATUS_BATCH,
|
||||
)
|
||||
dashboard_ids = dashboard_ids[:MAX_REPOSITORY_STATUS_BATCH]
|
||||
|
||||
statuses = {}
|
||||
for dashboard_id in dashboard_ids:
|
||||
try:
|
||||
statuses[str(dashboard_id)] = _resolve_repository_status(dashboard_id)
|
||||
except HTTPException:
|
||||
statuses[str(dashboard_id)] = {
|
||||
**_build_no_repo_status_payload(),
|
||||
"sync_state": "ERROR",
|
||||
"sync_status": "ERROR",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[get_repository_status_batch][Coherence:Failed] Failed for dashboard {dashboard_id}: {e}"
|
||||
)
|
||||
statuses[str(dashboard_id)] = {
|
||||
**_build_no_repo_status_payload(),
|
||||
"sync_state": "ERROR",
|
||||
"sync_status": "ERROR",
|
||||
}
|
||||
return RepoStatusBatchResponse(statuses=statuses)
|
||||
# [/DEF:get_repository_status_batch:Function]
|
||||
|
||||
# [DEF:get_repository_diff:Function]
|
||||
# @PURPOSE: Get Git diff for a dashboard repository.
|
||||
# @PRE: `dashboard_id` repository exists.
|
||||
@@ -394,8 +530,10 @@ async def get_repository_diff(
|
||||
try:
|
||||
diff_text = git_service.get_diff(dashboard_id, file_path, staged)
|
||||
return diff_text
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
_handle_unexpected_git_route_error("get_repository_diff", e)
|
||||
# [/DEF:get_repository_diff:Function]
|
||||
|
||||
# [DEF:generate_commit_message:Function]
|
||||
@@ -406,6 +544,7 @@ async def get_repository_diff(
|
||||
async def generate_commit_message(
|
||||
dashboard_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
config_manager = Depends(get_config_manager),
|
||||
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||
):
|
||||
with belief_scope("generate_commit_message"):
|
||||
@@ -429,7 +568,11 @@ async def generate_commit_message(
|
||||
|
||||
llm_service = LLMProviderService(db)
|
||||
providers = llm_service.get_all_providers()
|
||||
provider = next((p for p in providers if p.is_active), None)
|
||||
llm_settings = normalize_llm_settings(config_manager.get_config().settings.llm)
|
||||
bound_provider_id = resolve_bound_provider_id(llm_settings, "git_commit")
|
||||
provider = next((p for p in providers if p.id == bound_provider_id), None)
|
||||
if not provider:
|
||||
provider = next((p for p in providers if p.is_active), None)
|
||||
|
||||
if not provider:
|
||||
raise HTTPException(status_code=400, detail="No active LLM provider found")
|
||||
@@ -445,12 +588,21 @@ async def generate_commit_message(
|
||||
# 4. Generate Message
|
||||
from ...plugins.git.llm_extension import GitLLMExtension
|
||||
extension = GitLLMExtension(client)
|
||||
message = await extension.suggest_commit_message(diff, history)
|
||||
git_prompt = llm_settings["prompts"].get(
|
||||
"git_commit_prompt",
|
||||
DEFAULT_LLM_PROMPTS["git_commit_prompt"],
|
||||
)
|
||||
message = await extension.suggest_commit_message(
|
||||
diff,
|
||||
history,
|
||||
prompt_template=git_prompt,
|
||||
)
|
||||
|
||||
return {"message": message}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate commit message: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
_handle_unexpected_git_route_error("generate_commit_message", e)
|
||||
# [/DEF:generate_commit_message:Function]
|
||||
|
||||
# [/DEF:backend.src.api.routes.git:Module]
|
||||
# [/DEF:backend.src.api.routes.git:Module]
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
# @INVARIANT: All schemas must be compatible with the FastAPI router.
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
from src.models.git import GitProvider, GitStatus, SyncStatus
|
||||
|
||||
@@ -141,4 +141,17 @@ class RepoInitRequest(BaseModel):
|
||||
remote_url: str
|
||||
# [/DEF:RepoInitRequest:Class]
|
||||
|
||||
# [/DEF:backend.src.api.routes.git_schemas:Module]
|
||||
# [DEF:RepoStatusBatchRequest:Class]
|
||||
# @PURPOSE: Schema for requesting repository statuses for multiple dashboards in a single call.
|
||||
class RepoStatusBatchRequest(BaseModel):
|
||||
dashboard_ids: List[int] = Field(default_factory=list, description="Dashboard IDs to resolve repository statuses for")
|
||||
# [/DEF:RepoStatusBatchRequest:Class]
|
||||
|
||||
|
||||
# [DEF:RepoStatusBatchResponse:Class]
|
||||
# @PURPOSE: Schema for returning repository statuses keyed by dashboard ID.
|
||||
class RepoStatusBatchResponse(BaseModel):
|
||||
statuses: Dict[str, Dict[str, Any]]
|
||||
# [/DEF:RepoStatusBatchResponse:Class]
|
||||
|
||||
# [/DEF:backend.src.api.routes.git_schemas:Module]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# @LAYER: UI (API)
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
from ...core.logger import logger
|
||||
from ...schemas.auth import User
|
||||
from ...dependencies import get_current_user as get_current_active_user
|
||||
@@ -19,6 +19,20 @@ from sqlalchemy.orm import Session
|
||||
router = APIRouter(tags=["LLM"])
|
||||
# [/DEF:router:Global]
|
||||
|
||||
|
||||
# [DEF:_is_valid_runtime_api_key:Function]
|
||||
# @PURPOSE: Validate decrypted runtime API key presence/shape.
|
||||
# @PRE: value can be None.
|
||||
# @POST: Returns True only for non-placeholder key.
|
||||
def _is_valid_runtime_api_key(value: Optional[str]) -> bool:
|
||||
key = (value or "").strip()
|
||||
if not key:
|
||||
return False
|
||||
if key in {"********", "EMPTY_OR_NONE"}:
|
||||
return False
|
||||
return len(key) >= 16
|
||||
# [/DEF:_is_valid_runtime_api_key:Function]
|
||||
|
||||
# [DEF:get_providers:Function]
|
||||
# @PURPOSE: Retrieve all LLM provider configurations.
|
||||
# @PRE: User is authenticated.
|
||||
@@ -47,6 +61,37 @@ async def get_providers(
|
||||
]
|
||||
# [/DEF:get_providers:Function]
|
||||
|
||||
|
||||
# [DEF:get_llm_status:Function]
|
||||
# @PURPOSE: Returns whether LLM runtime is configured for dashboard validation.
|
||||
# @PRE: User is authenticated.
|
||||
# @POST: configured=true only when an active provider with valid decrypted key exists.
|
||||
@router.get("/status")
|
||||
async def get_llm_status(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
service = LLMProviderService(db)
|
||||
providers = service.get_all_providers()
|
||||
active_provider = next((p for p in providers if p.is_active), None)
|
||||
|
||||
if not active_provider:
|
||||
return {"configured": False, "reason": "no_active_provider"}
|
||||
|
||||
api_key = service.get_decrypted_api_key(active_provider.id)
|
||||
if not _is_valid_runtime_api_key(api_key):
|
||||
return {"configured": False, "reason": "invalid_api_key"}
|
||||
|
||||
return {
|
||||
"configured": True,
|
||||
"reason": "ok",
|
||||
"provider_id": active_provider.id,
|
||||
"provider_name": active_provider.name,
|
||||
"provider_type": active_provider.provider_type,
|
||||
"default_model": active_provider.default_model,
|
||||
}
|
||||
# [/DEF:get_llm_status:Function]
|
||||
|
||||
# [DEF:create_provider:Function]
|
||||
# @PURPOSE: Create a new LLM provider configuration.
|
||||
# @PRE: User is authenticated and has admin permissions.
|
||||
@@ -204,4 +249,4 @@ async def test_provider_config(
|
||||
return {"success": False, "error": str(e)}
|
||||
# [/DEF:test_provider_config:Function]
|
||||
|
||||
# [/DEF:backend/src/api/routes/llm.py]
|
||||
# [/DEF:backend/src/api/routes/llm.py]
|
||||
|
||||
@@ -6,12 +6,17 @@
|
||||
# @RELATION: DEPENDS_ON -> backend.src.dependencies
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.dashboard
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from typing import List, Dict, Any, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from ...dependencies import get_config_manager, get_task_manager, has_permission
|
||||
from ...core.database import get_db
|
||||
from ...models.dashboard import DashboardMetadata, DashboardSelection
|
||||
from ...core.superset_client import SupersetClient
|
||||
from ...core.logger import belief_scope
|
||||
from ...core.migration.dry_run_orchestrator import MigrationDryRunService
|
||||
from ...core.mapping_service import IdMappingService
|
||||
from ...models.mapping import ResourceMapping
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["migration"])
|
||||
|
||||
@@ -44,7 +49,7 @@ async def get_dashboards(
|
||||
# @POST: Starts the migration task and returns the task ID.
|
||||
# @PARAM: selection (DashboardSelection) - The dashboards to migrate.
|
||||
# @RETURN: Dict - {"task_id": str, "message": str}
|
||||
@router.post("/execute")
|
||||
@router.post("/migration/execute")
|
||||
async def execute_migration(
|
||||
selection: DashboardSelection,
|
||||
config_manager=Depends(get_config_manager),
|
||||
@@ -61,9 +66,10 @@ async def execute_migration(
|
||||
# Create migration task with debug logging
|
||||
from ...core.logger import logger
|
||||
|
||||
# Include replace_db_config in the task parameters
|
||||
# Include replace_db_config and fix_cross_filters in the task parameters
|
||||
task_params = selection.dict()
|
||||
task_params['replace_db_config'] = selection.replace_db_config
|
||||
task_params['fix_cross_filters'] = selection.fix_cross_filters
|
||||
|
||||
logger.info(f"Creating migration task with params: {task_params}")
|
||||
logger.info(f"Available environments: {env_ids}")
|
||||
@@ -78,4 +84,180 @@ async def execute_migration(
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create migration task: {str(e)}")
|
||||
# [/DEF:execute_migration:Function]
|
||||
|
||||
# [/DEF:backend.src.api.routes.migration:Module]
|
||||
|
||||
# [DEF:dry_run_migration:Function]
|
||||
# @PURPOSE: Build pre-flight diff and risk summary without applying migration.
|
||||
# @PRE: Selection and environments are valid.
|
||||
# @POST: Returns deterministic JSON diff and risk scoring.
|
||||
@router.post("/migration/dry-run", response_model=Dict[str, Any])
|
||||
async def dry_run_migration(
|
||||
selection: DashboardSelection,
|
||||
config_manager=Depends(get_config_manager),
|
||||
db: Session = Depends(get_db),
|
||||
_ = Depends(has_permission("plugin:migration", "EXECUTE"))
|
||||
):
|
||||
with belief_scope("dry_run_migration"):
|
||||
environments = config_manager.get_environments()
|
||||
env_map = {env.id: env for env in environments}
|
||||
source_env = env_map.get(selection.source_env_id)
|
||||
target_env = env_map.get(selection.target_env_id)
|
||||
if not source_env or not target_env:
|
||||
raise HTTPException(status_code=400, detail="Invalid source or target environment")
|
||||
if selection.source_env_id == selection.target_env_id:
|
||||
raise HTTPException(status_code=400, detail="Source and target environments must be different")
|
||||
if not selection.selected_ids:
|
||||
raise HTTPException(status_code=400, detail="No dashboards selected for dry run")
|
||||
|
||||
service = MigrationDryRunService()
|
||||
source_client = SupersetClient(source_env)
|
||||
target_client = SupersetClient(target_env)
|
||||
try:
|
||||
return service.run(
|
||||
selection=selection,
|
||||
source_client=source_client,
|
||||
target_client=target_client,
|
||||
db=db,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
# [/DEF:dry_run_migration:Function]
|
||||
|
||||
# [DEF:get_migration_settings:Function]
|
||||
# @PURPOSE: Get current migration Cron string explicitly.
|
||||
@router.get("/migration/settings", response_model=Dict[str, str])
|
||||
async def get_migration_settings(
|
||||
config_manager=Depends(get_config_manager),
|
||||
_ = Depends(has_permission("plugin:migration", "READ"))
|
||||
):
|
||||
with belief_scope("get_migration_settings"):
|
||||
config = config_manager.get_config()
|
||||
cron = config.settings.migration_sync_cron
|
||||
return {"cron": cron}
|
||||
# [/DEF:get_migration_settings:Function]
|
||||
|
||||
# [DEF:update_migration_settings:Function]
|
||||
# @PURPOSE: Update migration Cron string.
|
||||
@router.put("/migration/settings", response_model=Dict[str, str])
|
||||
async def update_migration_settings(
|
||||
payload: Dict[str, str],
|
||||
config_manager=Depends(get_config_manager),
|
||||
_ = Depends(has_permission("plugin:migration", "WRITE"))
|
||||
):
|
||||
with belief_scope("update_migration_settings"):
|
||||
if "cron" not in payload:
|
||||
raise HTTPException(status_code=400, detail="Missing 'cron' field in payload")
|
||||
|
||||
cron_expr = payload["cron"]
|
||||
|
||||
config = config_manager.get_config()
|
||||
config.settings.migration_sync_cron = cron_expr
|
||||
config_manager.save_config(config)
|
||||
|
||||
return {"cron": cron_expr, "status": "updated"}
|
||||
# [/DEF:update_migration_settings:Function]
|
||||
|
||||
# [DEF:get_resource_mappings:Function]
|
||||
# @PURPOSE: Fetch synchronized object mappings with search, filtering, and pagination.
|
||||
@router.get("/migration/mappings-data", response_model=Dict[str, Any])
|
||||
async def get_resource_mappings(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
search: Optional[str] = Query(None, description="Search by resource name or UUID"),
|
||||
env_id: Optional[str] = Query(None, description="Filter by environment ID"),
|
||||
resource_type: Optional[str] = Query(None, description="Filter by resource type"),
|
||||
db: Session = Depends(get_db),
|
||||
_ = Depends(has_permission("plugin:migration", "READ"))
|
||||
):
|
||||
with belief_scope("get_resource_mappings"):
|
||||
query = db.query(ResourceMapping)
|
||||
|
||||
if env_id:
|
||||
query = query.filter(ResourceMapping.environment_id == env_id)
|
||||
|
||||
if resource_type:
|
||||
query = query.filter(ResourceMapping.resource_type == resource_type.upper())
|
||||
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.filter(
|
||||
(ResourceMapping.resource_name.ilike(search_term)) |
|
||||
(ResourceMapping.uuid.ilike(search_term))
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
mappings = query.order_by(ResourceMapping.resource_type, ResourceMapping.resource_name).offset(skip).limit(limit).all()
|
||||
|
||||
items = []
|
||||
for m in mappings:
|
||||
items.append({
|
||||
"id": m.id,
|
||||
"environment_id": m.environment_id,
|
||||
"resource_type": m.resource_type.value if m.resource_type else None,
|
||||
"uuid": m.uuid,
|
||||
"remote_id": m.remote_integer_id,
|
||||
"resource_name": m.resource_name,
|
||||
"last_synced_at": m.last_synced_at.isoformat() if m.last_synced_at else None
|
||||
})
|
||||
|
||||
return {"items": items, "total": total}
|
||||
# [/DEF:get_resource_mappings:Function]
|
||||
|
||||
# [DEF:trigger_sync_now:Function]
|
||||
# @PURPOSE: Triggers an immediate ID synchronization for all environments.
|
||||
# @PRE: At least one environment must be configured.
|
||||
# @POST: Environment rows are ensured in DB; sync_environment is called for each.
|
||||
@router.post("/migration/sync-now", response_model=Dict[str, Any])
|
||||
async def trigger_sync_now(
|
||||
config_manager=Depends(get_config_manager),
|
||||
db: Session = Depends(get_db),
|
||||
_ = Depends(has_permission("plugin:migration", "EXECUTE"))
|
||||
):
|
||||
with belief_scope("trigger_sync_now"):
|
||||
from ...core.logger import logger
|
||||
from ...models.mapping import Environment as EnvironmentModel
|
||||
|
||||
config = config_manager.get_config()
|
||||
environments = config.environments
|
||||
|
||||
if not environments:
|
||||
raise HTTPException(status_code=400, detail="No environments configured")
|
||||
|
||||
# Ensure each environment exists in DB (upsert) to satisfy FK constraints
|
||||
for env in environments:
|
||||
existing = db.query(EnvironmentModel).filter_by(id=env.id).first()
|
||||
if not existing:
|
||||
db_env = EnvironmentModel(
|
||||
id=env.id,
|
||||
name=env.name,
|
||||
url=env.url,
|
||||
credentials_id=env.id, # Use env.id as credentials reference
|
||||
)
|
||||
db.add(db_env)
|
||||
logger.info(f"[trigger_sync_now][Action] Created environment row for {env.id}")
|
||||
else:
|
||||
existing.name = env.name
|
||||
existing.url = env.url
|
||||
db.commit()
|
||||
|
||||
service = IdMappingService(db)
|
||||
results = {"synced": [], "failed": []}
|
||||
|
||||
for env in environments:
|
||||
try:
|
||||
client = SupersetClient(env)
|
||||
service.sync_environment(env.id, client)
|
||||
results["synced"].append(env.id)
|
||||
logger.info(f"[trigger_sync_now][Action] Synced environment {env.id}")
|
||||
except Exception as e:
|
||||
results["failed"].append({"env_id": env.id, "error": str(e)})
|
||||
logger.error(f"[trigger_sync_now][Error] Failed to sync {env.id}: {e}")
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"synced_count": len(results["synced"]),
|
||||
"failed_count": len(results["failed"]),
|
||||
"details": results
|
||||
}
|
||||
# [/DEF:trigger_sync_now:Function]
|
||||
|
||||
# [/DEF:backend.src.api.routes.migration:Module]
|
||||
|
||||
@@ -32,27 +32,28 @@ router = APIRouter(prefix="/api/reports", tags=["Reports"])
|
||||
# @PARAM: field_name (str) - Query field name for diagnostics.
|
||||
# @RETURN: List - Parsed enum values.
|
||||
def _parse_csv_enum_list(raw: Optional[str], enum_cls, field_name: str) -> List:
|
||||
if raw is None or not raw.strip():
|
||||
return []
|
||||
values = [item.strip() for item in raw.split(",") if item.strip()]
|
||||
parsed = []
|
||||
invalid = []
|
||||
for value in values:
|
||||
try:
|
||||
parsed.append(enum_cls(value))
|
||||
except ValueError:
|
||||
invalid.append(value)
|
||||
if invalid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"message": f"Invalid values for '{field_name}'",
|
||||
"field": field_name,
|
||||
"invalid_values": invalid,
|
||||
"allowed_values": [item.value for item in enum_cls],
|
||||
},
|
||||
)
|
||||
return parsed
|
||||
with belief_scope("_parse_csv_enum_list"):
|
||||
if raw is None or not raw.strip():
|
||||
return []
|
||||
values = [item.strip() for item in raw.split(",") if item.strip()]
|
||||
parsed = []
|
||||
invalid = []
|
||||
for value in values:
|
||||
try:
|
||||
parsed.append(enum_cls(value))
|
||||
except ValueError:
|
||||
invalid.append(value)
|
||||
if invalid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"message": f"Invalid values for '{field_name}'",
|
||||
"field": field_name,
|
||||
"invalid_values": invalid,
|
||||
"allowed_values": [item.value for item in enum_cls],
|
||||
},
|
||||
)
|
||||
return parsed
|
||||
# [/DEF:_parse_csv_enum_list:Function]
|
||||
|
||||
|
||||
@@ -61,6 +62,20 @@ def _parse_csv_enum_list(raw: Optional[str], enum_cls, field_name: str) -> List:
|
||||
# @PRE: authenticated/authorized request and validated query params.
|
||||
# @POST: returns {items,total,page,page_size,has_next,applied_filters}.
|
||||
# @POST: deterministic error payload for invalid filters.
|
||||
#
|
||||
# @TEST_CONTRACT: ListReportsApi ->
|
||||
# {
|
||||
# required_fields: {page: int, page_size: int, sort_by: str, sort_order: str},
|
||||
# optional_fields: {task_types: str, statuses: str, search: str},
|
||||
# invariants: [
|
||||
# "Returns ReportCollection on success",
|
||||
# "Raises HTTPException 400 for invalid query parameters"
|
||||
# ]
|
||||
# }
|
||||
# @TEST_FIXTURE: valid_list_request -> {"page": 1, "page_size": 20}
|
||||
# @TEST_EDGE: invalid_task_type_filter -> raises HTTPException(400)
|
||||
# @TEST_EDGE: malformed_query -> raises HTTPException(400)
|
||||
# @TEST_INVARIANT: consistent_list_payload -> verifies: [valid_list_request]
|
||||
@router.get("", response_model=ReportCollection)
|
||||
async def list_reports(
|
||||
page: int = Query(1, ge=1),
|
||||
|
||||
@@ -16,9 +16,10 @@ from pydantic import BaseModel
|
||||
from ...core.config_models import AppConfig, Environment, GlobalSettings, LoggingConfig
|
||||
from ...models.storage import StorageConfig
|
||||
from ...dependencies import get_config_manager, has_permission
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.superset_client import SupersetClient
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.superset_client import SupersetClient
|
||||
from ...services.llm_prompt_templates import normalize_llm_settings
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:LoggingConfigResponse:Class]
|
||||
@@ -38,13 +39,14 @@ router = APIRouter()
|
||||
# @POST: Returns masked AppConfig.
|
||||
# @RETURN: AppConfig - The current configuration.
|
||||
@router.get("", response_model=AppConfig)
|
||||
async def get_settings(
|
||||
async def get_settings(
|
||||
config_manager: ConfigManager = Depends(get_config_manager),
|
||||
_ = Depends(has_permission("admin:settings", "READ"))
|
||||
):
|
||||
with belief_scope("get_settings"):
|
||||
logger.info("[get_settings][Entry] Fetching all settings")
|
||||
config = config_manager.get_config().copy(deep=True)
|
||||
config = config_manager.get_config().copy(deep=True)
|
||||
config.settings.llm = normalize_llm_settings(config.settings.llm)
|
||||
# Mask passwords
|
||||
for env in config.environments:
|
||||
if env.password:
|
||||
@@ -279,7 +281,7 @@ async def update_logging_config(
|
||||
# [/DEF:update_logging_config:Function]
|
||||
|
||||
# [DEF:ConsolidatedSettingsResponse:Class]
|
||||
class ConsolidatedSettingsResponse(BaseModel):
|
||||
class ConsolidatedSettingsResponse(BaseModel):
|
||||
environments: List[dict]
|
||||
connections: List[dict]
|
||||
llm: dict
|
||||
@@ -294,7 +296,7 @@ class ConsolidatedSettingsResponse(BaseModel):
|
||||
# @POST: Returns all consolidated settings.
|
||||
# @RETURN: ConsolidatedSettingsResponse - All settings categories.
|
||||
@router.get("/consolidated", response_model=ConsolidatedSettingsResponse)
|
||||
async def get_consolidated_settings(
|
||||
async def get_consolidated_settings(
|
||||
config_manager: ConfigManager = Depends(get_config_manager),
|
||||
_ = Depends(has_permission("admin:settings", "READ"))
|
||||
):
|
||||
@@ -323,14 +325,16 @@ async def get_consolidated_settings(
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return ConsolidatedSettingsResponse(
|
||||
environments=[env.dict() for env in config.environments],
|
||||
connections=config.settings.connections,
|
||||
llm=config.settings.llm,
|
||||
llm_providers=llm_providers_list,
|
||||
logging=config.settings.logging.dict(),
|
||||
storage=config.settings.storage.dict()
|
||||
)
|
||||
normalized_llm = normalize_llm_settings(config.settings.llm)
|
||||
|
||||
return ConsolidatedSettingsResponse(
|
||||
environments=[env.dict() for env in config.environments],
|
||||
connections=config.settings.connections,
|
||||
llm=normalized_llm,
|
||||
llm_providers=llm_providers_list,
|
||||
logging=config.settings.logging.dict(),
|
||||
storage=config.settings.storage.dict()
|
||||
)
|
||||
# [/DEF:get_consolidated_settings:Function]
|
||||
|
||||
# [DEF:update_consolidated_settings:Function]
|
||||
@@ -353,9 +357,9 @@ async def update_consolidated_settings(
|
||||
if "connections" in settings_patch:
|
||||
current_settings.connections = settings_patch["connections"]
|
||||
|
||||
# Update LLM if provided
|
||||
if "llm" in settings_patch:
|
||||
current_settings.llm = settings_patch["llm"]
|
||||
# Update LLM if provided
|
||||
if "llm" in settings_patch:
|
||||
current_settings.llm = normalize_llm_settings(settings_patch["llm"])
|
||||
|
||||
# Update Logging if provided
|
||||
if "logging" in settings_patch:
|
||||
|
||||
@@ -36,6 +36,7 @@ router = APIRouter(tags=["storage"])
|
||||
async def list_files(
|
||||
category: Optional[FileCategory] = None,
|
||||
path: Optional[str] = None,
|
||||
recursive: bool = False,
|
||||
plugin_loader=Depends(get_plugin_loader),
|
||||
_ = Depends(has_permission("plugin:storage", "READ"))
|
||||
):
|
||||
@@ -43,7 +44,7 @@ async def list_files(
|
||||
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
|
||||
if not storage_plugin:
|
||||
raise HTTPException(status_code=500, detail="Storage plugin not loaded")
|
||||
return storage_plugin.list_files(category, path)
|
||||
return storage_plugin.list_files(category, path, recursive)
|
||||
# [/DEF:list_files:Function]
|
||||
|
||||
# [DEF:upload_file:Function]
|
||||
@@ -143,4 +144,46 @@ async def download_file(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
# [/DEF:download_file:Function]
|
||||
|
||||
# [/DEF:storage_routes:Module]
|
||||
# [DEF:get_file_by_path:Function]
|
||||
# @PURPOSE: Retrieve a file by validated absolute/relative path under storage root.
|
||||
#
|
||||
# @PRE: path must resolve under configured storage root.
|
||||
# @POST: Returns a FileResponse for existing files.
|
||||
#
|
||||
# @PARAM: path (str) - Absolute or storage-root-relative file path.
|
||||
# @RETURN: FileResponse - The file content.
|
||||
#
|
||||
# @RELATION: CALLS -> StoragePlugin.get_storage_root
|
||||
# @RELATION: CALLS -> StoragePlugin.validate_path
|
||||
@router.get("/file")
|
||||
async def get_file_by_path(
|
||||
path: str,
|
||||
plugin_loader=Depends(get_plugin_loader),
|
||||
_ = Depends(has_permission("plugin:storage", "READ"))
|
||||
):
|
||||
with belief_scope("get_file_by_path"):
|
||||
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
|
||||
if not storage_plugin:
|
||||
raise HTTPException(status_code=500, detail="Storage plugin not loaded")
|
||||
|
||||
requested_path = (path or "").strip()
|
||||
if not requested_path:
|
||||
raise HTTPException(status_code=400, detail="Path is required")
|
||||
|
||||
try:
|
||||
candidate = Path(requested_path)
|
||||
if candidate.is_absolute():
|
||||
abs_path = storage_plugin.validate_path(candidate)
|
||||
else:
|
||||
storage_root = storage_plugin.get_storage_root()
|
||||
abs_path = storage_plugin.validate_path(storage_root / candidate)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
if not abs_path.exists() or not abs_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
return FileResponse(path=str(abs_path), filename=abs_path.name)
|
||||
# [/DEF:get_file_by_path:Function]
|
||||
|
||||
# [/DEF:storage_routes:Module]
|
||||
|
||||
@@ -9,9 +9,15 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from pydantic import BaseModel
|
||||
from ...core.logger import belief_scope
|
||||
|
||||
from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry
|
||||
from ...core.task_manager.models import LogFilter, LogStats
|
||||
from ...dependencies import get_task_manager, has_permission, get_current_user
|
||||
from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry
|
||||
from ...core.task_manager.models import LogFilter, LogStats
|
||||
from ...dependencies import get_task_manager, has_permission, get_current_user, get_config_manager
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...services.llm_prompt_templates import (
|
||||
is_multimodal_model,
|
||||
normalize_llm_settings,
|
||||
resolve_bound_provider_id,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -39,32 +45,54 @@ class ResumeTaskRequest(BaseModel):
|
||||
# @PRE: plugin_id must exist and params must be valid for that plugin.
|
||||
# @POST: A new task is created and started.
|
||||
# @RETURN: Task - The created task instance.
|
||||
async def create_task(
|
||||
request: CreateTaskRequest,
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
async def create_task(
|
||||
request: CreateTaskRequest,
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
current_user = Depends(get_current_user),
|
||||
config_manager: ConfigManager = Depends(get_config_manager),
|
||||
):
|
||||
# Dynamic permission check based on plugin_id
|
||||
has_permission(f"plugin:{request.plugin_id}", "EXECUTE")(current_user)
|
||||
"""
|
||||
Create and start a new task for a given plugin.
|
||||
"""
|
||||
with belief_scope("create_task"):
|
||||
try:
|
||||
# Special handling for validation task to include provider config
|
||||
if request.plugin_id == "llm_dashboard_validation":
|
||||
from ...core.database import SessionLocal
|
||||
from ...services.llm_provider import LLMProviderService
|
||||
db = SessionLocal()
|
||||
try:
|
||||
llm_service = LLMProviderService(db)
|
||||
provider_id = request.params.get("provider_id")
|
||||
if provider_id:
|
||||
db_provider = llm_service.get_provider(provider_id)
|
||||
if not db_provider:
|
||||
raise ValueError(f"LLM Provider {provider_id} not found")
|
||||
finally:
|
||||
db.close()
|
||||
try:
|
||||
# Special handling for LLM tasks to resolve provider config by task binding.
|
||||
if request.plugin_id in {"llm_dashboard_validation", "llm_documentation"}:
|
||||
from ...core.database import SessionLocal
|
||||
from ...services.llm_provider import LLMProviderService
|
||||
db = SessionLocal()
|
||||
try:
|
||||
llm_service = LLMProviderService(db)
|
||||
provider_id = request.params.get("provider_id")
|
||||
if not provider_id:
|
||||
llm_settings = normalize_llm_settings(config_manager.get_config().settings.llm)
|
||||
binding_key = "dashboard_validation" if request.plugin_id == "llm_dashboard_validation" else "documentation"
|
||||
provider_id = resolve_bound_provider_id(llm_settings, binding_key)
|
||||
if provider_id:
|
||||
request.params["provider_id"] = provider_id
|
||||
if not provider_id:
|
||||
providers = llm_service.get_all_providers()
|
||||
active_provider = next((p for p in providers if p.is_active), None)
|
||||
if active_provider:
|
||||
provider_id = active_provider.id
|
||||
request.params["provider_id"] = provider_id
|
||||
|
||||
if provider_id:
|
||||
db_provider = llm_service.get_provider(provider_id)
|
||||
if not db_provider:
|
||||
raise ValueError(f"LLM Provider {provider_id} not found")
|
||||
if request.plugin_id == "llm_dashboard_validation" and not is_multimodal_model(
|
||||
db_provider.default_model,
|
||||
db_provider.provider_type,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Selected provider model is not multimodal for dashboard validation",
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
task = await task_manager.create_task(
|
||||
plugin_id=request.plugin_id,
|
||||
|
||||
@@ -21,7 +21,7 @@ import asyncio
|
||||
from .dependencies import get_task_manager, get_scheduler_service
|
||||
from .core.utils.network import NetworkError
|
||||
from .core.logger import logger, belief_scope
|
||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets, reports
|
||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets, reports, assistant
|
||||
from .api import auth
|
||||
|
||||
# [DEF:App:Global]
|
||||
@@ -72,12 +72,12 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
# [DEF:log_requests:Function]
|
||||
# @PURPOSE: Middleware to log incoming HTTP requests and their response status.
|
||||
# [DEF:network_error_handler:Function]
|
||||
# @PURPOSE: Global exception handler for NetworkError.
|
||||
# @PRE: request is a FastAPI Request object.
|
||||
# @POST: Logs request and response details.
|
||||
# @POST: Returns 503 HTTP Exception.
|
||||
# @PARAM: request (Request) - The incoming request object.
|
||||
# @PARAM: call_next (Callable) - The next middleware or route handler.
|
||||
# @PARAM: exc (NetworkError) - The exception instance.
|
||||
@app.exception_handler(NetworkError)
|
||||
async def network_error_handler(request: Request, exc: NetworkError):
|
||||
with belief_scope("network_error_handler"):
|
||||
@@ -86,26 +86,34 @@ async def network_error_handler(request: Request, exc: NetworkError):
|
||||
status_code=503,
|
||||
detail="Environment unavailable. Please check if the Superset instance is running."
|
||||
)
|
||||
# [/DEF:network_error_handler:Function]
|
||||
|
||||
# [DEF:log_requests:Function]
|
||||
# @PURPOSE: Middleware to log incoming HTTP requests and their response status.
|
||||
# @PRE: request is a FastAPI Request object.
|
||||
# @POST: Logs request and response details.
|
||||
# @PARAM: request (Request) - The incoming request object.
|
||||
# @PARAM: call_next (Callable) - The next middleware or route handler.
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
# Avoid spamming logs for polling endpoints
|
||||
is_polling = request.url.path.endswith("/api/tasks") and request.method == "GET"
|
||||
|
||||
if not is_polling:
|
||||
logger.info(f"Incoming request: {request.method} {request.url.path}")
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
with belief_scope("log_requests"):
|
||||
# Avoid spamming logs for polling endpoints
|
||||
is_polling = request.url.path.endswith("/api/tasks") and request.method == "GET"
|
||||
|
||||
if not is_polling:
|
||||
logger.info(f"Response status: {response.status_code} for {request.url.path}")
|
||||
return response
|
||||
except NetworkError as e:
|
||||
logger.error(f"Network error caught in middleware: {e}")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Environment unavailable. Please check if the Superset instance is running."
|
||||
)
|
||||
logger.info(f"Incoming request: {request.method} {request.url.path}")
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
if not is_polling:
|
||||
logger.info(f"Response status: {response.status_code} for {request.url.path}")
|
||||
return response
|
||||
except NetworkError as e:
|
||||
logger.error(f"Network error caught in middleware: {e}")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Environment unavailable. Please check if the Superset instance is running."
|
||||
)
|
||||
# [/DEF:log_requests:Function]
|
||||
|
||||
# Include API routes
|
||||
@@ -124,6 +132,7 @@ app.include_router(storage.router, prefix="/api/storage", tags=["Storage"])
|
||||
app.include_router(dashboards.router)
|
||||
app.include_router(datasets.router)
|
||||
app.include_router(reports.router)
|
||||
app.include_router(assistant.router, prefix="/api/assistant", tags=["Assistant"])
|
||||
|
||||
|
||||
# [DEF:api.include_routers:Action]
|
||||
@@ -138,6 +147,21 @@ app.include_router(reports.router)
|
||||
# @POST: WebSocket connection is managed and logs are streamed until disconnect.
|
||||
# @TIER: CRITICAL
|
||||
# @UX_STATE: Connecting -> Streaming -> (Disconnected)
|
||||
#
|
||||
# @TEST_CONTRACT: WebSocketLogStreamApi ->
|
||||
# {
|
||||
# required_fields: {websocket: WebSocket, task_id: str},
|
||||
# optional_fields: {source: str, level: str},
|
||||
# invariants: [
|
||||
# "Accepts the WebSocket connection",
|
||||
# "Applies source and level filters correctly to streamed logs",
|
||||
# "Cleans up subscriptions on disconnect"
|
||||
# ]
|
||||
# }
|
||||
# @TEST_FIXTURE: valid_ws_connection -> {"task_id": "test_1", "source": "plugin"}
|
||||
# @TEST_EDGE: task_not_found_ws -> closes connection or sends error
|
||||
# @TEST_EDGE: empty_task_logs -> waits for new logs
|
||||
# @TEST_INVARIANT: consistent_streaming -> verifies: [valid_ws_connection]
|
||||
@app.websocket("/ws/logs/{task_id}")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
@@ -248,12 +272,13 @@ if frontend_path.exists():
|
||||
# @POST: Returns the requested file or index.html.
|
||||
@app.get("/{file_path:path}", include_in_schema=False)
|
||||
async def serve_spa(file_path: str):
|
||||
# Only serve SPA for non-API paths
|
||||
# API routes are registered separately and should be matched by FastAPI first
|
||||
if file_path and (file_path.startswith("api/") or file_path.startswith("/api/") or file_path == "api"):
|
||||
# This should not happen if API routers are properly registered
|
||||
# Return 404 instead of serving HTML
|
||||
raise HTTPException(status_code=404, detail=f"API endpoint not found: {file_path}")
|
||||
with belief_scope("serve_spa"):
|
||||
# Only serve SPA for non-API paths
|
||||
# API routes are registered separately and should be matched by FastAPI first
|
||||
if file_path and (file_path.startswith("api/") or file_path.startswith("/api/") or file_path == "api"):
|
||||
# This should not happen if API routers are properly registered
|
||||
# Return 404 instead of serving HTML
|
||||
raise HTTPException(status_code=404, detail=f"API endpoint not found: {file_path}")
|
||||
|
||||
full_path = frontend_path / file_path
|
||||
if file_path and full_path.is_file():
|
||||
|
||||
@@ -14,6 +14,8 @@ import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from src.core.database import Base
|
||||
# Import all models to ensure they are registered with Base before create_all - must import both auth and mapping to ensure Base knows about all tables
|
||||
from src.models import mapping, auth, task, report
|
||||
from src.models.auth import User, Role, Permission, ADGroupMapping
|
||||
from src.services.auth_service import AuthService
|
||||
from src.core.auth.repository import AuthRepository
|
||||
@@ -176,4 +178,94 @@ def test_ad_group_mapping(auth_repo):
|
||||
assert retrieved_mapping.role_id == role.id
|
||||
|
||||
|
||||
def test_authenticate_user_updates_last_login(auth_service, auth_repo):
|
||||
"""@SIDE_EFFECT: authenticate_user updates last_login timestamp on success."""
|
||||
user = User(
|
||||
username="loginuser",
|
||||
email="login@example.com",
|
||||
password_hash=get_password_hash("mypassword"),
|
||||
auth_source="LOCAL"
|
||||
)
|
||||
auth_repo.db.add(user)
|
||||
auth_repo.db.commit()
|
||||
|
||||
assert user.last_login is None
|
||||
|
||||
authenticated = auth_service.authenticate_user("loginuser", "mypassword")
|
||||
assert authenticated is not None
|
||||
assert authenticated.last_login is not None
|
||||
|
||||
|
||||
def test_authenticate_inactive_user(auth_service, auth_repo):
|
||||
"""@PRE: User with is_active=False should not authenticate."""
|
||||
user = User(
|
||||
username="inactive_user",
|
||||
email="inactive@example.com",
|
||||
password_hash=get_password_hash("testpass"),
|
||||
auth_source="LOCAL",
|
||||
is_active=False
|
||||
)
|
||||
auth_repo.db.add(user)
|
||||
auth_repo.db.commit()
|
||||
|
||||
result = auth_service.authenticate_user("inactive_user", "testpass")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_verify_password_empty_hash():
|
||||
"""@PRE: verify_password with empty/None hash returns False."""
|
||||
assert verify_password("anypassword", "") is False
|
||||
assert verify_password("anypassword", None) is False
|
||||
|
||||
|
||||
def test_provision_adfs_user_new(auth_service, auth_repo):
|
||||
"""@POST: provision_adfs_user creates a new ADFS user with correct roles."""
|
||||
# Set up a role and AD group mapping
|
||||
role = Role(name="ADFS_Viewer", description="ADFS viewer role")
|
||||
auth_repo.db.add(role)
|
||||
auth_repo.db.commit()
|
||||
|
||||
mapping = ADGroupMapping(ad_group="DOMAIN\\Viewers", role_id=role.id)
|
||||
auth_repo.db.add(mapping)
|
||||
auth_repo.db.commit()
|
||||
|
||||
user_info = {
|
||||
"upn": "newadfsuser@domain.com",
|
||||
"email": "newadfsuser@domain.com",
|
||||
"groups": ["DOMAIN\\Viewers"]
|
||||
}
|
||||
|
||||
user = auth_service.provision_adfs_user(user_info)
|
||||
assert user is not None
|
||||
assert user.username == "newadfsuser@domain.com"
|
||||
assert user.auth_source == "ADFS"
|
||||
assert user.is_active is True
|
||||
assert len(user.roles) == 1
|
||||
assert user.roles[0].name == "ADFS_Viewer"
|
||||
|
||||
|
||||
def test_provision_adfs_user_existing(auth_service, auth_repo):
|
||||
"""@POST: provision_adfs_user updates roles for existing user."""
|
||||
# Create existing user
|
||||
existing = User(
|
||||
username="existingadfs@domain.com",
|
||||
email="existingadfs@domain.com",
|
||||
auth_source="ADFS",
|
||||
is_active=True
|
||||
)
|
||||
auth_repo.db.add(existing)
|
||||
auth_repo.db.commit()
|
||||
|
||||
user_info = {
|
||||
"upn": "existingadfs@domain.com",
|
||||
"email": "existingadfs@domain.com",
|
||||
"groups": []
|
||||
}
|
||||
|
||||
user = auth_service.provision_adfs_user(user_info)
|
||||
assert user is not None
|
||||
assert user.username == "existingadfs@domain.com"
|
||||
assert len(user.roles) == 0 # No matching group mappings
|
||||
|
||||
|
||||
# [/DEF:test_auth:Module]
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
# @SEMANTICS: config, models, pydantic
|
||||
# @PURPOSE: Defines the data models for application configuration using Pydantic.
|
||||
# @LAYER: Core
|
||||
# @RELATION: READS_FROM -> app_configurations (database)
|
||||
# @RELATION: READS_FROM -> app_configurations (database)
|
||||
# @RELATION: USED_BY -> ConfigManager
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from ..models.storage import StorageConfig
|
||||
from ..services.llm_prompt_templates import (
|
||||
DEFAULT_LLM_ASSISTANT_SETTINGS,
|
||||
DEFAULT_LLM_PROMPTS,
|
||||
DEFAULT_LLM_PROVIDER_BINDINGS,
|
||||
)
|
||||
|
||||
# [DEF:Schedule:DataClass]
|
||||
# @PURPOSE: Represents a backup schedule configuration.
|
||||
@@ -19,24 +24,25 @@ class Schedule(BaseModel):
|
||||
|
||||
# [DEF:Environment:DataClass]
|
||||
# @PURPOSE: Represents a Superset environment configuration.
|
||||
class Environment(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
url: str
|
||||
username: str
|
||||
password: str # Will be masked in UI
|
||||
verify_ssl: bool = True
|
||||
timeout: int = 30
|
||||
is_default: bool = False
|
||||
backup_schedule: Schedule = Field(default_factory=Schedule)
|
||||
# [/DEF:Environment:DataClass]
|
||||
class Environment(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
url: str
|
||||
username: str
|
||||
password: str # Will be masked in UI
|
||||
verify_ssl: bool = True
|
||||
timeout: int = 30
|
||||
is_default: bool = False
|
||||
is_production: bool = False
|
||||
backup_schedule: Schedule = Field(default_factory=Schedule)
|
||||
# [/DEF:Environment:DataClass]
|
||||
|
||||
# [DEF:LoggingConfig:DataClass]
|
||||
# @PURPOSE: Defines the configuration for the application's logging system.
|
||||
class LoggingConfig(BaseModel):
|
||||
level: str = "INFO"
|
||||
task_log_level: str = "INFO" # Minimum level for task-specific logs (DEBUG, INFO, WARNING, ERROR)
|
||||
file_path: Optional[str] = None
|
||||
class LoggingConfig(BaseModel):
|
||||
level: str = "INFO"
|
||||
task_log_level: str = "INFO" # Minimum level for task-specific logs (DEBUG, INFO, WARNING, ERROR)
|
||||
file_path: Optional[str] = None
|
||||
max_bytes: int = 10 * 1024 * 1024
|
||||
backup_count: int = 5
|
||||
enable_belief_state: bool = True
|
||||
@@ -49,12 +55,23 @@ class GlobalSettings(BaseModel):
|
||||
default_environment_id: Optional[str] = None
|
||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||
connections: List[dict] = []
|
||||
llm: dict = Field(default_factory=lambda: {"providers": [], "default_provider": ""})
|
||||
llm: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"providers": [],
|
||||
"default_provider": "",
|
||||
"prompts": dict(DEFAULT_LLM_PROMPTS),
|
||||
"provider_bindings": dict(DEFAULT_LLM_PROVIDER_BINDINGS),
|
||||
**dict(DEFAULT_LLM_ASSISTANT_SETTINGS),
|
||||
}
|
||||
)
|
||||
|
||||
# Task retention settings
|
||||
task_retention_days: int = 30
|
||||
task_retention_limit: int = 100
|
||||
pagination_limit: int = 10
|
||||
|
||||
# Migration sync settings
|
||||
migration_sync_cron: str = "0 2 * * *"
|
||||
# [/DEF:GlobalSettings:DataClass]
|
||||
|
||||
# [DEF:AppConfig:DataClass]
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
# [DEF:backend.src.core.database:Module]
|
||||
#
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: database, postgresql, sqlalchemy, session, persistence
|
||||
# @PURPOSE: Configures database connection and session management (PostgreSQL-first).
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> sqlalchemy
|
||||
# @RELATION: USES -> backend.src.models.mapping
|
||||
# @RELATION: USES -> backend.src.core.auth.config
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.mapping
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.auth.config
|
||||
#
|
||||
# @INVARIANT: A single engine instance is used for the entire application.
|
||||
|
||||
@@ -18,6 +19,7 @@ from ..models import task as _task_models # noqa: F401
|
||||
from ..models import auth as _auth_models # noqa: F401
|
||||
from ..models import config as _config_models # noqa: F401
|
||||
from ..models import llm as _llm_models # noqa: F401
|
||||
from ..models import assistant as _assistant_models # noqa: F401
|
||||
from .logger import belief_scope
|
||||
from .auth.config import auth_config
|
||||
import os
|
||||
@@ -72,18 +74,21 @@ auth_engine = _build_engine(AUTH_DATABASE_URL)
|
||||
# [/DEF:auth_engine:Variable]
|
||||
|
||||
# [DEF:SessionLocal:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: A session factory for the main mappings database.
|
||||
# @PRE: engine is initialized.
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
# [/DEF:SessionLocal:Class]
|
||||
|
||||
# [DEF:TasksSessionLocal:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: A session factory for the tasks execution database.
|
||||
# @PRE: tasks_engine is initialized.
|
||||
TasksSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=tasks_engine)
|
||||
# [/DEF:TasksSessionLocal:Class]
|
||||
|
||||
# [DEF:AuthSessionLocal:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: A session factory for the authentication database.
|
||||
# @PRE: auth_engine is initialized.
|
||||
AuthSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=auth_engine)
|
||||
|
||||
@@ -35,7 +35,19 @@ class BeliefFormatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
anchor_id = getattr(_belief_state, 'anchor_id', None)
|
||||
if anchor_id:
|
||||
record.msg = f"[{anchor_id}][Action] {record.msg}"
|
||||
msg = str(record.msg)
|
||||
# Supported molecular topology markers
|
||||
markers = ("[EXPLORE]", "[REASON]", "[REFLECT]", "[COHERENCE:", "[Action]", "[Entry]", "[Exit]")
|
||||
|
||||
# Avoid duplicating anchor or overriding explicit markers
|
||||
if msg.startswith(f"[{anchor_id}]"):
|
||||
pass
|
||||
elif any(msg.startswith(m) for m in markers):
|
||||
record.msg = f"[{anchor_id}]{msg}"
|
||||
else:
|
||||
# Default covalent bond
|
||||
record.msg = f"[{anchor_id}][Action] {msg}"
|
||||
|
||||
return super().format(record)
|
||||
# [/DEF:format:Function]
|
||||
# [/DEF:BeliefFormatter:Class]
|
||||
@@ -75,12 +87,12 @@ def belief_scope(anchor_id: str, message: str = ""):
|
||||
try:
|
||||
yield
|
||||
# Log Coherence OK and Exit (DEBUG level to reduce noise)
|
||||
logger.debug(f"[{anchor_id}][Coherence:OK]")
|
||||
logger.debug("[COHERENCE:OK]")
|
||||
if _enable_belief_state:
|
||||
logger.debug(f"[{anchor_id}][Exit]")
|
||||
logger.debug("[Exit]")
|
||||
except Exception as e:
|
||||
# Log Coherence Failed (DEBUG level to reduce noise)
|
||||
logger.debug(f"[{anchor_id}][Coherence:Failed] {str(e)}")
|
||||
logger.debug(f"[COHERENCE:FAILED] {str(e)}")
|
||||
raise
|
||||
finally:
|
||||
# Restore old anchor
|
||||
@@ -275,5 +287,33 @@ logger.addHandler(websocket_log_handler)
|
||||
# Example usage:
|
||||
# logger.info("Application started", extra={"context_key": "context_value"})
|
||||
# logger.error("An error occurred", exc_info=True)
|
||||
|
||||
import types
|
||||
|
||||
# [DEF:explore:Function]
|
||||
# @PURPOSE: Logs an EXPLORE message (Van der Waals force) for searching, alternatives, and hypotheses.
|
||||
# @SEMANTICS: log, explore, molecule
|
||||
def explore(self, msg, *args, **kwargs):
|
||||
self.warning(f"[EXPLORE] {msg}", *args, **kwargs)
|
||||
# [/DEF:explore:Function]
|
||||
|
||||
# [DEF:reason:Function]
|
||||
# @PURPOSE: Logs a REASON message (Covalent bond) for strict deduction and core logic.
|
||||
# @SEMANTICS: log, reason, molecule
|
||||
def reason(self, msg, *args, **kwargs):
|
||||
self.info(f"[REASON] {msg}", *args, **kwargs)
|
||||
# [/DEF:reason:Function]
|
||||
|
||||
# [DEF:reflect:Function]
|
||||
# @PURPOSE: Logs a REFLECT message (Hydrogen bond) for self-check and structural validation.
|
||||
# @SEMANTICS: log, reflect, molecule
|
||||
def reflect(self, msg, *args, **kwargs):
|
||||
self.debug(f"[REFLECT] {msg}", *args, **kwargs)
|
||||
# [/DEF:reflect:Function]
|
||||
|
||||
logger.explore = types.MethodType(explore, logger)
|
||||
logger.reason = types.MethodType(reason, logger)
|
||||
logger.reflect = types.MethodType(reflect, logger)
|
||||
|
||||
# [/DEF:Logger:Global]
|
||||
# [/DEF:LoggerModule:Module]
|
||||
@@ -11,6 +11,7 @@ from pathlib import Path
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import pytest
|
||||
import logging
|
||||
from src.core.logger import (
|
||||
belief_scope,
|
||||
logger,
|
||||
@@ -21,6 +22,27 @@ from src.core.logger import (
|
||||
from src.core.config_models import LoggingConfig
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_logger_state():
|
||||
"""Reset logger state before each test to avoid cross-test contamination."""
|
||||
config = LoggingConfig(
|
||||
level="INFO",
|
||||
task_log_level="INFO",
|
||||
enable_belief_state=True
|
||||
)
|
||||
configure_logger(config)
|
||||
# Also reset the logger level for caplog to work correctly
|
||||
logging.getLogger("superset_tools_app").setLevel(logging.DEBUG)
|
||||
yield
|
||||
# Reset after test too
|
||||
config = LoggingConfig(
|
||||
level="INFO",
|
||||
task_log_level="INFO",
|
||||
enable_belief_state=True
|
||||
)
|
||||
configure_logger(config)
|
||||
|
||||
|
||||
# [DEF:test_belief_scope_logs_entry_action_exit_at_debug:Function]
|
||||
# @PURPOSE: Test that belief_scope generates [ID][Entry], [ID][Action], and [ID][Exit] logs at DEBUG level.
|
||||
# @PRE: belief_scope is available. caplog fixture is used. Logger configured to DEBUG.
|
||||
@@ -76,7 +98,7 @@ def test_belief_scope_error_handling(caplog):
|
||||
log_messages = [record.message for record in caplog.records]
|
||||
|
||||
assert any("[FailingFunction][Entry]" in msg for msg in log_messages), "Entry log not found"
|
||||
assert any("[FailingFunction][Coherence:Failed]" in msg for msg in log_messages), "Failed coherence log not found"
|
||||
assert any("[FailingFunction][COHERENCE:FAILED]" in msg for msg in log_messages), "Failed coherence log not found"
|
||||
# Exit should not be logged on failure
|
||||
|
||||
# Reset to INFO
|
||||
@@ -106,11 +128,9 @@ def test_belief_scope_success_coherence(caplog):
|
||||
|
||||
log_messages = [record.message for record in caplog.records]
|
||||
|
||||
assert any("[SuccessFunction][Coherence:OK]" in msg for msg in log_messages), "Success coherence log not found"
|
||||
assert any("[SuccessFunction][COHERENCE:OK]" in msg for msg in log_messages), "Success coherence log not found"
|
||||
|
||||
# Reset to INFO
|
||||
config = LoggingConfig(level="INFO", task_log_level="INFO", enable_belief_state=True)
|
||||
configure_logger(config)
|
||||
|
||||
# [/DEF:test_belief_scope_success_coherence:Function]
|
||||
|
||||
|
||||
@@ -132,7 +152,7 @@ def test_belief_scope_not_visible_at_info(caplog):
|
||||
# Entry/Exit/Coherence should NOT be visible at INFO level
|
||||
assert not any("[InfoLevelFunction][Entry]" in msg for msg in log_messages), "Entry log should not be visible at INFO"
|
||||
assert not any("[InfoLevelFunction][Exit]" in msg for msg in log_messages), "Exit log should not be visible at INFO"
|
||||
assert not any("[InfoLevelFunction][Coherence:OK]" in msg for msg in log_messages), "Coherence log should not be visible at INFO"
|
||||
assert not any("[InfoLevelFunction][COHERENCE:OK]" in msg for msg in log_messages), "Coherence log should not be visible at INFO"
|
||||
# [/DEF:test_belief_scope_not_visible_at_info:Function]
|
||||
|
||||
|
||||
@@ -141,7 +161,7 @@ def test_belief_scope_not_visible_at_info(caplog):
|
||||
# @PRE: None.
|
||||
# @POST: Default level is INFO.
|
||||
def test_task_log_level_default():
|
||||
"""Test that default task log level is INFO."""
|
||||
"""Test that default task log level is INFO (after reset fixture)."""
|
||||
level = get_task_log_level()
|
||||
assert level == "INFO"
|
||||
# [/DEF:test_task_log_level_default:Function]
|
||||
@@ -176,15 +196,6 @@ def test_configure_logger_task_log_level():
|
||||
|
||||
assert get_task_log_level() == "DEBUG", "task_log_level should be DEBUG"
|
||||
assert should_log_task_level("DEBUG") is True, "DEBUG should be logged at DEBUG threshold"
|
||||
|
||||
# Reset to INFO
|
||||
config = LoggingConfig(
|
||||
level="INFO",
|
||||
task_log_level="INFO",
|
||||
enable_belief_state=True
|
||||
)
|
||||
configure_logger(config)
|
||||
assert get_task_log_level() == "INFO", "task_log_level should be reset to INFO"
|
||||
# [/DEF:test_configure_logger_task_log_level:Function]
|
||||
|
||||
|
||||
@@ -213,16 +224,58 @@ def test_enable_belief_state_flag(caplog):
|
||||
assert not any("[DisabledFunction][Entry]" in msg for msg in log_messages), "Entry should not be logged when disabled"
|
||||
assert not any("[DisabledFunction][Exit]" in msg for msg in log_messages), "Exit should not be logged when disabled"
|
||||
# Coherence:OK should still be logged (internal tracking)
|
||||
assert any("[DisabledFunction][Coherence:OK]" in msg for msg in log_messages), "Coherence should still be logged"
|
||||
assert any("[DisabledFunction][COHERENCE:OK]" in msg for msg in log_messages), "Coherence should still be logged"
|
||||
|
||||
# Re-enable for other tests
|
||||
config = LoggingConfig(
|
||||
level="DEBUG",
|
||||
task_log_level="DEBUG",
|
||||
enable_belief_state=True
|
||||
)
|
||||
configure_logger(config)
|
||||
# [/DEF:test_enable_belief_state_flag:Function]
|
||||
|
||||
|
||||
# [DEF:test_belief_scope_missing_anchor:Function]
|
||||
# @PURPOSE: Test @PRE condition: anchor_id must be provided
|
||||
def test_belief_scope_missing_anchor():
|
||||
"""Test that belief_scope enforces anchor_id to be provided."""
|
||||
import pytest
|
||||
from src.core.logger import belief_scope
|
||||
with pytest.raises(TypeError):
|
||||
# Missing required positional argument 'anchor_id'
|
||||
with belief_scope():
|
||||
pass
|
||||
# [/DEF:test_belief_scope_missing_anchor:Function]
|
||||
|
||||
# [DEF:test_configure_logger_post_conditions:Function]
|
||||
# @PURPOSE: Test @POST condition: Logger level, handlers, belief state flag, and task log level are updated.
|
||||
def test_configure_logger_post_conditions(tmp_path):
|
||||
"""Test that configure_logger satisfies all @POST conditions."""
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from src.core.config_models import LoggingConfig
|
||||
from src.core.logger import configure_logger, logger, BeliefFormatter, get_task_log_level
|
||||
import src.core.logger as logger_module
|
||||
|
||||
log_file = tmp_path / "test.log"
|
||||
config = LoggingConfig(
|
||||
level="WARNING",
|
||||
task_log_level="DEBUG",
|
||||
enable_belief_state=False,
|
||||
file_path=str(log_file)
|
||||
)
|
||||
|
||||
configure_logger(config)
|
||||
|
||||
# 1. Logger level is updated
|
||||
assert logger.level == logging.WARNING
|
||||
|
||||
# 2. Handlers are updated (file handler removed old ones, added new one)
|
||||
file_handlers = [h for h in logger.handlers if isinstance(h, RotatingFileHandler)]
|
||||
assert len(file_handlers) == 1
|
||||
import pathlib
|
||||
assert pathlib.Path(file_handlers[0].baseFilename) == log_file.resolve()
|
||||
|
||||
# 3. Formatter is set to BeliefFormatter
|
||||
for handler in logger.handlers:
|
||||
assert isinstance(handler.formatter, BeliefFormatter)
|
||||
|
||||
# 4. Global states
|
||||
assert getattr(logger_module, '_enable_belief_state') is False
|
||||
assert get_task_log_level() == "DEBUG"
|
||||
# [/DEF:test_configure_logger_post_conditions:Function]
|
||||
|
||||
# [/DEF:test_logger:Module]
|
||||
|
||||
249
backend/src/core/mapping_service.py
Normal file
249
backend/src/core/mapping_service.py
Normal file
@@ -0,0 +1,249 @@
|
||||
# [DEF:backend.src.core.mapping_service:Module]
|
||||
#
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: mapping, ids, synchronization, environments, cross-filters
|
||||
# @PURPOSE: Service for tracking and synchronizing Superset Resource IDs (UUID <-> Integer ID)
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.mapping (ResourceMapping, ResourceType)
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.logger
|
||||
# @TEST_DATA: mock_superset_resources -> {'chart': [{'id': 42, 'uuid': '1234', 'slice_name': 'test'}], 'dataset': [{'id': 99, 'uuid': '5678', 'table_name': 'data'}]}
|
||||
#
|
||||
# @INVARIANT: sync_environment must handle remote API failures gracefully.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import Dict, List, Optional
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from src.models.mapping import ResourceMapping, ResourceType
|
||||
from src.core.logger import logger, belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:IdMappingService:Class]
|
||||
# @TIER: CRITICAL
|
||||
# @PURPOSE: Service handling the cataloging and retrieval of remote Superset Integer IDs.
|
||||
#
|
||||
# @TEST_CONTRACT: IdMappingServiceModel ->
|
||||
# {
|
||||
# required_fields: {db_session: Session},
|
||||
# invariants: [
|
||||
# "sync_environment correctly creates or updates ResourceMapping records",
|
||||
# "get_remote_id returns an integer or None",
|
||||
# "get_remote_ids_batch returns a dictionary of valid UUIDs to integers"
|
||||
# ]
|
||||
# }
|
||||
# @TEST_FIXTURE: valid_mapping_service -> {"db_session": "MockSession()"}
|
||||
# @TEST_EDGE: sync_api_failure -> handles exception gracefully
|
||||
# @TEST_EDGE: get_remote_id_not_found -> returns None
|
||||
# @TEST_EDGE: get_batch_empty_list -> returns empty dict
|
||||
# @TEST_INVARIANT: resilient_fetching -> verifies: [sync_api_failure]
|
||||
class IdMappingService:
|
||||
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initializes the mapping service.
|
||||
def __init__(self, db_session: Session):
|
||||
self.db = db_session
|
||||
self.scheduler = BackgroundScheduler()
|
||||
self._sync_job = None
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:start_scheduler:Function]
|
||||
# @PURPOSE: Starts the background scheduler with a given cron string.
|
||||
# @PARAM: cron_string (str) - Cron expression for the sync interval.
|
||||
# @PARAM: environments (List[str]) - List of environment IDs to sync.
|
||||
# @PARAM: superset_client_factory - Function to get a client for an environment.
|
||||
def start_scheduler(self, cron_string: str, environments: List[str], superset_client_factory):
|
||||
with belief_scope("IdMappingService.start_scheduler"):
|
||||
if self._sync_job:
|
||||
self.scheduler.remove_job(self._sync_job.id)
|
||||
logger.info("[IdMappingService.start_scheduler][Reflect] Removed existing sync job.")
|
||||
|
||||
def sync_all():
|
||||
for env_id in environments:
|
||||
client = superset_client_factory(env_id)
|
||||
if client:
|
||||
self.sync_environment(env_id, client)
|
||||
|
||||
self._sync_job = self.scheduler.add_job(
|
||||
sync_all,
|
||||
CronTrigger.from_crontab(cron_string),
|
||||
id='id_mapping_sync_job',
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
if not self.scheduler.running:
|
||||
self.scheduler.start()
|
||||
logger.info(f"[IdMappingService.start_scheduler][Coherence:OK] Started background scheduler with cron: {cron_string}")
|
||||
else:
|
||||
logger.info(f"[IdMappingService.start_scheduler][Coherence:OK] Updated background scheduler with cron: {cron_string}")
|
||||
# [/DEF:start_scheduler:Function]
|
||||
|
||||
# [DEF:sync_environment:Function]
|
||||
# @PURPOSE: Fully synchronizes mapping for a specific environment.
|
||||
# @PARAM: environment_id (str) - Target environment ID.
|
||||
# @PARAM: superset_client - Instance capable of hitting the Superset API.
|
||||
# @PRE: environment_id exists in the database.
|
||||
# @POST: ResourceMapping records for the environment are created or updated.
|
||||
def sync_environment(self, environment_id: str, superset_client, incremental: bool = False) -> None:
|
||||
"""
|
||||
Polls the Superset APIs for the target environment and updates the local mapping table.
|
||||
If incremental=True, only fetches items changed since the max last_synced_at date.
|
||||
"""
|
||||
with belief_scope("IdMappingService.sync_environment"):
|
||||
logger.info(f"[IdMappingService.sync_environment][Action] Starting sync for environment {environment_id} (incremental={incremental})")
|
||||
|
||||
# Implementation Note: In a real scenario, superset_client needs to be an instance
|
||||
# capable of auth & iteration over /api/v1/chart/, /api/v1/dataset/, /api/v1/dashboard/
|
||||
# Here we structure the logic according to the spec.
|
||||
|
||||
types_to_poll = [
|
||||
(ResourceType.CHART, "chart", "slice_name"),
|
||||
(ResourceType.DATASET, "dataset", "table_name"),
|
||||
(ResourceType.DASHBOARD, "dashboard", "slug") # Note: dashboard slug or dashboard_title
|
||||
]
|
||||
|
||||
total_synced = 0
|
||||
total_deleted = 0
|
||||
try:
|
||||
for res_enum, endpoint, name_field in types_to_poll:
|
||||
logger.debug(f"[IdMappingService.sync_environment][Explore] Polling {endpoint} endpoint")
|
||||
|
||||
# Simulated API Fetch (Would be: superset_client.get(f"/api/v1/{endpoint}/")... )
|
||||
# This relies on the superset API structure, e.g. { "result": [{"id": 1, "uuid": "...", name_field: "..."}] }
|
||||
# We assume superset_client provides a generic method to fetch all pages.
|
||||
|
||||
try:
|
||||
since_dttm = None
|
||||
if incremental:
|
||||
from sqlalchemy.sql import func
|
||||
max_date = self.db.query(func.max(ResourceMapping.last_synced_at)).filter(
|
||||
ResourceMapping.environment_id == environment_id,
|
||||
ResourceMapping.resource_type == res_enum
|
||||
).scalar()
|
||||
|
||||
if max_date:
|
||||
# We subtract a bit for safety overlap
|
||||
from datetime import timedelta
|
||||
since_dttm = max_date - timedelta(minutes=5)
|
||||
logger.debug(f"[IdMappingService.sync_environment] Incremental sync since {since_dttm}")
|
||||
|
||||
resources = superset_client.get_all_resources(endpoint, since_dttm=since_dttm)
|
||||
|
||||
# Track which UUIDs we see in this sync cycle
|
||||
synced_uuids = set()
|
||||
|
||||
for res in resources:
|
||||
res_uuid = res.get("uuid")
|
||||
raw_id = res.get("id")
|
||||
res_name = res.get(name_field)
|
||||
|
||||
if not res_uuid or raw_id is None:
|
||||
continue
|
||||
|
||||
synced_uuids.add(res_uuid)
|
||||
res_id = str(raw_id) # Store as string
|
||||
|
||||
# Upsert Logic
|
||||
mapping = self.db.query(ResourceMapping).filter_by(
|
||||
environment_id=environment_id,
|
||||
resource_type=res_enum,
|
||||
uuid=res_uuid
|
||||
).first()
|
||||
|
||||
if mapping:
|
||||
mapping.remote_integer_id = res_id
|
||||
mapping.resource_name = res_name
|
||||
mapping.last_synced_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
new_mapping = ResourceMapping(
|
||||
environment_id=environment_id,
|
||||
resource_type=res_enum,
|
||||
uuid=res_uuid,
|
||||
remote_integer_id=res_id,
|
||||
resource_name=res_name,
|
||||
last_synced_at=datetime.now(timezone.utc)
|
||||
)
|
||||
self.db.add(new_mapping)
|
||||
|
||||
total_synced += 1
|
||||
|
||||
# Delete stale mappings: rows for this env+type whose UUID
|
||||
# was NOT returned by the API (resource was deleted remotely)
|
||||
# We only do this on full syncs, because incremental syncs don't return all UUIDs
|
||||
if not incremental:
|
||||
stale_query = self.db.query(ResourceMapping).filter(
|
||||
ResourceMapping.environment_id == environment_id,
|
||||
ResourceMapping.resource_type == res_enum,
|
||||
)
|
||||
if synced_uuids:
|
||||
stale_query = stale_query.filter(
|
||||
ResourceMapping.uuid.notin_(synced_uuids)
|
||||
)
|
||||
deleted = stale_query.delete(synchronize_session="fetch")
|
||||
if deleted:
|
||||
total_deleted += deleted
|
||||
logger.info(f"[IdMappingService.sync_environment][Action] Removed {deleted} stale {endpoint} mapping(s) for {environment_id}")
|
||||
|
||||
except Exception as loop_e:
|
||||
logger.error(f"[IdMappingService.sync_environment][Reason] Error polling {endpoint}: {loop_e}")
|
||||
# Continue to next resource type instead of blowing up the whole sync
|
||||
|
||||
self.db.commit()
|
||||
logger.info(f"[IdMappingService.sync_environment][Coherence:OK] Successfully synced {total_synced} items and deleted {total_deleted} stale items.")
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"[IdMappingService.sync_environment][Coherence:Failed] Critical sync failure: {e}")
|
||||
raise
|
||||
# [/DEF:sync_environment:Function]
|
||||
|
||||
# [DEF:get_remote_id:Function]
|
||||
# @PURPOSE: Retrieves the remote integer ID for a given universal UUID.
|
||||
# @PARAM: environment_id (str)
|
||||
# @PARAM: resource_type (ResourceType)
|
||||
# @PARAM: uuid (str)
|
||||
# @RETURN: Optional[int]
|
||||
def get_remote_id(self, environment_id: str, resource_type: ResourceType, uuid: str) -> Optional[int]:
|
||||
mapping = self.db.query(ResourceMapping).filter_by(
|
||||
environment_id=environment_id,
|
||||
resource_type=resource_type,
|
||||
uuid=uuid
|
||||
).first()
|
||||
|
||||
if mapping:
|
||||
try:
|
||||
return int(mapping.remote_integer_id)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
# [/DEF:get_remote_id:Function]
|
||||
|
||||
# [DEF:get_remote_ids_batch:Function]
|
||||
# @PURPOSE: Retrieves remote integer IDs for a list of universal UUIDs efficiently.
|
||||
# @PARAM: environment_id (str)
|
||||
# @PARAM: resource_type (ResourceType)
|
||||
# @PARAM: uuids (List[str])
|
||||
# @RETURN: Dict[str, int] - Mapping of UUID -> Integer ID
|
||||
def get_remote_ids_batch(self, environment_id: str, resource_type: ResourceType, uuids: List[str]) -> Dict[str, int]:
|
||||
if not uuids:
|
||||
return {}
|
||||
|
||||
mappings = self.db.query(ResourceMapping).filter(
|
||||
ResourceMapping.environment_id == environment_id,
|
||||
ResourceMapping.resource_type == resource_type,
|
||||
ResourceMapping.uuid.in_(uuids)
|
||||
).all()
|
||||
|
||||
result = {}
|
||||
for m in mappings:
|
||||
try:
|
||||
result[m.uuid] = int(m.remote_integer_id)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return result
|
||||
# [/DEF:get_remote_ids_batch:Function]
|
||||
|
||||
# [/DEF:IdMappingService:Class]
|
||||
# [/DEF:backend.src.core.mapping_service:Module]
|
||||
12
backend/src/core/migration/__init__.py
Normal file
12
backend/src/core/migration/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# [DEF:backend.src.core.migration.__init__:Module]
|
||||
# @TIER: TRIVIAL
|
||||
# @SEMANTICS: migration, package, exports
|
||||
# @PURPOSE: Namespace package for migration pre-flight orchestration components.
|
||||
# @LAYER: Core
|
||||
|
||||
from .dry_run_orchestrator import MigrationDryRunService
|
||||
from .archive_parser import MigrationArchiveParser
|
||||
|
||||
__all__ = ["MigrationDryRunService", "MigrationArchiveParser"]
|
||||
|
||||
# [/DEF:backend.src.core.migration.__init__:Module]
|
||||
139
backend/src/core/migration/archive_parser.py
Normal file
139
backend/src/core/migration/archive_parser.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# [DEF:backend.src.core.migration.archive_parser:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: migration, zip, parser, yaml, metadata
|
||||
# @PURPOSE: Parse Superset export ZIP archives into normalized object catalogs for diffing.
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.logger
|
||||
# @INVARIANT: Parsing is read-only and never mutates archive files.
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from ..logger import logger, belief_scope
|
||||
|
||||
|
||||
# [DEF:MigrationArchiveParser:Class]
|
||||
# @PURPOSE: Extract normalized dashboards/charts/datasets metadata from ZIP archives.
|
||||
class MigrationArchiveParser:
|
||||
# [DEF:extract_objects_from_zip:Function]
|
||||
# @PURPOSE: Extract object catalogs from Superset archive.
|
||||
# @PRE: zip_path points to a valid readable ZIP.
|
||||
# @POST: Returns object lists grouped by resource type.
|
||||
# @RETURN: Dict[str, List[Dict[str, Any]]]
|
||||
def extract_objects_from_zip(self, zip_path: str) -> Dict[str, List[Dict[str, Any]]]:
|
||||
with belief_scope("MigrationArchiveParser.extract_objects_from_zip"):
|
||||
result: Dict[str, List[Dict[str, Any]]] = {
|
||||
"dashboards": [],
|
||||
"charts": [],
|
||||
"datasets": [],
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as temp_dir_str:
|
||||
temp_dir = Path(temp_dir_str)
|
||||
with zipfile.ZipFile(zip_path, "r") as zip_file:
|
||||
zip_file.extractall(temp_dir)
|
||||
|
||||
result["dashboards"] = self._collect_yaml_objects(temp_dir, "dashboards")
|
||||
result["charts"] = self._collect_yaml_objects(temp_dir, "charts")
|
||||
result["datasets"] = self._collect_yaml_objects(temp_dir, "datasets")
|
||||
|
||||
return result
|
||||
# [/DEF:extract_objects_from_zip:Function]
|
||||
|
||||
# [DEF:_collect_yaml_objects:Function]
|
||||
# @PURPOSE: Read and normalize YAML manifests for one object type.
|
||||
# @PRE: object_type is one of dashboards/charts/datasets.
|
||||
# @POST: Returns only valid normalized objects.
|
||||
def _collect_yaml_objects(self, root_dir: Path, object_type: str) -> List[Dict[str, Any]]:
|
||||
with belief_scope("MigrationArchiveParser._collect_yaml_objects"):
|
||||
files = list(root_dir.glob(f"**/{object_type}/**/*.yaml")) + list(root_dir.glob(f"**/{object_type}/*.yaml"))
|
||||
objects: List[Dict[str, Any]] = []
|
||||
for file_path in set(files):
|
||||
try:
|
||||
with open(file_path, "r") as file_obj:
|
||||
payload = yaml.safe_load(file_obj) or {}
|
||||
normalized = self._normalize_object_payload(payload, object_type)
|
||||
if normalized:
|
||||
objects.append(normalized)
|
||||
except Exception as exc:
|
||||
logger.reflect(
|
||||
"[MigrationArchiveParser._collect_yaml_objects][REFLECT] skip_invalid_yaml path=%s error=%s",
|
||||
file_path,
|
||||
exc,
|
||||
)
|
||||
return objects
|
||||
# [/DEF:_collect_yaml_objects:Function]
|
||||
|
||||
# [DEF:_normalize_object_payload:Function]
|
||||
# @PURPOSE: Convert raw YAML payload to stable diff signature shape.
|
||||
# @PRE: payload is parsed YAML mapping.
|
||||
# @POST: Returns normalized descriptor with `uuid`, `title`, and `signature`.
|
||||
def _normalize_object_payload(self, payload: Dict[str, Any], object_type: str) -> Optional[Dict[str, Any]]:
|
||||
with belief_scope("MigrationArchiveParser._normalize_object_payload"):
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
uuid = payload.get("uuid")
|
||||
if not uuid:
|
||||
return None
|
||||
|
||||
if object_type == "dashboards":
|
||||
title = payload.get("dashboard_title") or payload.get("title")
|
||||
signature = {
|
||||
"title": title,
|
||||
"slug": payload.get("slug"),
|
||||
"position_json": payload.get("position_json"),
|
||||
"json_metadata": payload.get("json_metadata"),
|
||||
"description": payload.get("description"),
|
||||
"owners": payload.get("owners"),
|
||||
}
|
||||
return {
|
||||
"uuid": str(uuid),
|
||||
"title": title or f"Dashboard {uuid}",
|
||||
"signature": json.dumps(signature, sort_keys=True, default=str),
|
||||
"owners": payload.get("owners") or [],
|
||||
}
|
||||
|
||||
if object_type == "charts":
|
||||
title = payload.get("slice_name") or payload.get("name")
|
||||
signature = {
|
||||
"title": title,
|
||||
"viz_type": payload.get("viz_type"),
|
||||
"params": payload.get("params"),
|
||||
"query_context": payload.get("query_context"),
|
||||
"datasource_uuid": payload.get("datasource_uuid"),
|
||||
"dataset_uuid": payload.get("dataset_uuid"),
|
||||
}
|
||||
return {
|
||||
"uuid": str(uuid),
|
||||
"title": title or f"Chart {uuid}",
|
||||
"signature": json.dumps(signature, sort_keys=True, default=str),
|
||||
"dataset_uuid": payload.get("datasource_uuid") or payload.get("dataset_uuid"),
|
||||
}
|
||||
|
||||
if object_type == "datasets":
|
||||
title = payload.get("table_name") or payload.get("name")
|
||||
signature = {
|
||||
"title": title,
|
||||
"schema": payload.get("schema"),
|
||||
"database_uuid": payload.get("database_uuid"),
|
||||
"sql": payload.get("sql"),
|
||||
"columns": payload.get("columns"),
|
||||
"metrics": payload.get("metrics"),
|
||||
}
|
||||
return {
|
||||
"uuid": str(uuid),
|
||||
"title": title or f"Dataset {uuid}",
|
||||
"signature": json.dumps(signature, sort_keys=True, default=str),
|
||||
"database_uuid": payload.get("database_uuid"),
|
||||
}
|
||||
|
||||
return None
|
||||
# [/DEF:_normalize_object_payload:Function]
|
||||
|
||||
|
||||
# [/DEF:MigrationArchiveParser:Class]
|
||||
# [/DEF:backend.src.core.migration.archive_parser:Module]
|
||||
235
backend/src/core/migration/dry_run_orchestrator.py
Normal file
235
backend/src/core/migration/dry_run_orchestrator.py
Normal file
@@ -0,0 +1,235 @@
|
||||
# [DEF:backend.src.core.migration.dry_run_orchestrator:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: migration, dry_run, diff, risk, superset
|
||||
# @PURPOSE: Compute pre-flight migration diff and risk scoring without apply.
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.superset_client
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.migration_engine
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.migration.archive_parser
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.migration.risk_assessor
|
||||
# @INVARIANT: Dry run is informative only and must not mutate target environment.
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...models.dashboard import DashboardSelection
|
||||
from ...models.mapping import DatabaseMapping
|
||||
from ..logger import logger, belief_scope
|
||||
from .archive_parser import MigrationArchiveParser
|
||||
from .risk_assessor import build_risks, score_risks
|
||||
from ..migration_engine import MigrationEngine
|
||||
from ..superset_client import SupersetClient
|
||||
from ..utils.fileio import create_temp_file
|
||||
|
||||
|
||||
# [DEF:MigrationDryRunService:Class]
|
||||
# @PURPOSE: Build deterministic diff/risk payload for migration pre-flight.
|
||||
class MigrationDryRunService:
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Wire parser dependency for archive object extraction.
|
||||
# @PRE: parser can be omitted to use default implementation.
|
||||
# @POST: Service is ready to calculate dry-run payload.
|
||||
def __init__(self, parser: MigrationArchiveParser | None = None):
|
||||
self.parser = parser or MigrationArchiveParser()
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:run:Function]
|
||||
# @PURPOSE: Execute full dry-run computation for selected dashboards.
|
||||
# @PRE: source/target clients are authenticated and selection validated by caller.
|
||||
# @POST: Returns JSON-serializable pre-flight payload with summary, diff and risk.
|
||||
# @SIDE_EFFECT: Reads source export archives and target metadata via network.
|
||||
def run(
|
||||
self,
|
||||
selection: DashboardSelection,
|
||||
source_client: SupersetClient,
|
||||
target_client: SupersetClient,
|
||||
db: Session,
|
||||
) -> Dict[str, Any]:
|
||||
with belief_scope("MigrationDryRunService.run"):
|
||||
logger.explore("[MigrationDryRunService.run][EXPLORE] starting dry-run pipeline")
|
||||
engine = MigrationEngine()
|
||||
db_mapping = self._load_db_mapping(db, selection) if selection.replace_db_config else {}
|
||||
transformed = {"dashboards": {}, "charts": {}, "datasets": {}}
|
||||
|
||||
dashboards_preview = source_client.get_dashboards_summary()
|
||||
selected_preview = {
|
||||
item["id"]: item
|
||||
for item in dashboards_preview
|
||||
if item.get("id") in selection.selected_ids
|
||||
}
|
||||
|
||||
for dashboard_id in selection.selected_ids:
|
||||
exported_content, _ = source_client.export_dashboard(int(dashboard_id))
|
||||
with create_temp_file(content=exported_content, suffix=".zip") as source_zip:
|
||||
with create_temp_file(suffix=".zip") as transformed_zip:
|
||||
success = engine.transform_zip(
|
||||
str(source_zip),
|
||||
str(transformed_zip),
|
||||
db_mapping,
|
||||
strip_databases=False,
|
||||
target_env_id=selection.target_env_id,
|
||||
fix_cross_filters=selection.fix_cross_filters,
|
||||
)
|
||||
if not success:
|
||||
raise ValueError(f"Failed to transform export archive for dashboard {dashboard_id}")
|
||||
extracted = self.parser.extract_objects_from_zip(str(transformed_zip))
|
||||
self._accumulate_objects(transformed, extracted)
|
||||
|
||||
source_objects = {key: list(value.values()) for key, value in transformed.items()}
|
||||
target_objects = self._build_target_signatures(target_client)
|
||||
diff = {
|
||||
"dashboards": self._build_object_diff(source_objects["dashboards"], target_objects["dashboards"]),
|
||||
"charts": self._build_object_diff(source_objects["charts"], target_objects["charts"]),
|
||||
"datasets": self._build_object_diff(source_objects["datasets"], target_objects["datasets"]),
|
||||
}
|
||||
risk = self._build_risks(source_objects, target_objects, diff, target_client)
|
||||
|
||||
summary = {
|
||||
"dashboards": {action: len(diff["dashboards"][action]) for action in ("create", "update", "delete")},
|
||||
"charts": {action: len(diff["charts"][action]) for action in ("create", "update", "delete")},
|
||||
"datasets": {action: len(diff["datasets"][action]) for action in ("create", "update", "delete")},
|
||||
"selected_dashboards": len(selection.selected_ids),
|
||||
}
|
||||
selected_titles = [
|
||||
selected_preview[dash_id]["title"]
|
||||
for dash_id in selection.selected_ids
|
||||
if dash_id in selected_preview
|
||||
]
|
||||
|
||||
logger.reason("[MigrationDryRunService.run][REASON] dry-run payload assembled")
|
||||
return {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"selection": selection.model_dump(),
|
||||
"selected_dashboard_titles": selected_titles,
|
||||
"diff": diff,
|
||||
"summary": summary,
|
||||
"risk": score_risks(risk),
|
||||
}
|
||||
# [/DEF:run:Function]
|
||||
|
||||
# [DEF:_load_db_mapping:Function]
|
||||
# @PURPOSE: Resolve UUID mapping for optional DB config replacement.
|
||||
def _load_db_mapping(self, db: Session, selection: DashboardSelection) -> Dict[str, str]:
|
||||
rows = db.query(DatabaseMapping).filter(
|
||||
DatabaseMapping.source_env_id == selection.source_env_id,
|
||||
DatabaseMapping.target_env_id == selection.target_env_id,
|
||||
).all()
|
||||
return {row.source_db_uuid: row.target_db_uuid for row in rows}
|
||||
# [/DEF:_load_db_mapping:Function]
|
||||
|
||||
# [DEF:_accumulate_objects:Function]
|
||||
# @PURPOSE: Merge extracted resources by UUID to avoid duplicates.
|
||||
def _accumulate_objects(self, target: Dict[str, Dict[str, Dict[str, Any]]], source: Dict[str, List[Dict[str, Any]]]) -> None:
|
||||
for object_type in ("dashboards", "charts", "datasets"):
|
||||
for item in source.get(object_type, []):
|
||||
uuid = item.get("uuid")
|
||||
if uuid:
|
||||
target[object_type][str(uuid)] = item
|
||||
# [/DEF:_accumulate_objects:Function]
|
||||
|
||||
# [DEF:_index_by_uuid:Function]
|
||||
# @PURPOSE: Build UUID-index map for normalized resources.
|
||||
def _index_by_uuid(self, objects: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
|
||||
indexed: Dict[str, Dict[str, Any]] = {}
|
||||
for obj in objects:
|
||||
uuid = obj.get("uuid")
|
||||
if uuid:
|
||||
indexed[str(uuid)] = obj
|
||||
return indexed
|
||||
# [/DEF:_index_by_uuid:Function]
|
||||
|
||||
# [DEF:_build_object_diff:Function]
|
||||
# @PURPOSE: Compute create/update/delete buckets by UUID+signature.
|
||||
def _build_object_diff(self, source_objects: List[Dict[str, Any]], target_objects: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
|
||||
target_index = self._index_by_uuid(target_objects)
|
||||
created: List[Dict[str, Any]] = []
|
||||
updated: List[Dict[str, Any]] = []
|
||||
deleted: List[Dict[str, Any]] = []
|
||||
for source_obj in source_objects:
|
||||
source_uuid = str(source_obj.get("uuid"))
|
||||
target_obj = target_index.get(source_uuid)
|
||||
if not target_obj:
|
||||
created.append({"uuid": source_uuid, "title": source_obj.get("title")})
|
||||
continue
|
||||
if source_obj.get("signature") != target_obj.get("signature"):
|
||||
updated.append({
|
||||
"uuid": source_uuid,
|
||||
"title": source_obj.get("title"),
|
||||
"target_title": target_obj.get("title"),
|
||||
})
|
||||
return {"create": created, "update": updated, "delete": deleted}
|
||||
# [/DEF:_build_object_diff:Function]
|
||||
|
||||
# [DEF:_build_target_signatures:Function]
|
||||
# @PURPOSE: Pull target metadata and normalize it into comparable signatures.
|
||||
def _build_target_signatures(self, client: SupersetClient) -> Dict[str, List[Dict[str, Any]]]:
|
||||
_, dashboards = client.get_dashboards(query={
|
||||
"columns": ["uuid", "dashboard_title", "slug", "position_json", "json_metadata", "description", "owners"],
|
||||
})
|
||||
_, datasets = client.get_datasets(query={
|
||||
"columns": ["uuid", "table_name", "schema", "database_uuid", "sql", "columns", "metrics"],
|
||||
})
|
||||
_, charts = client.get_charts(query={
|
||||
"columns": ["uuid", "slice_name", "viz_type", "params", "query_context", "datasource_uuid", "dataset_uuid"],
|
||||
})
|
||||
return {
|
||||
"dashboards": [{
|
||||
"uuid": str(item.get("uuid")),
|
||||
"title": item.get("dashboard_title"),
|
||||
"owners": item.get("owners") or [],
|
||||
"signature": json.dumps({
|
||||
"title": item.get("dashboard_title"),
|
||||
"slug": item.get("slug"),
|
||||
"position_json": item.get("position_json"),
|
||||
"json_metadata": item.get("json_metadata"),
|
||||
"description": item.get("description"),
|
||||
"owners": item.get("owners"),
|
||||
}, sort_keys=True, default=str),
|
||||
} for item in dashboards if item.get("uuid")],
|
||||
"datasets": [{
|
||||
"uuid": str(item.get("uuid")),
|
||||
"title": item.get("table_name"),
|
||||
"database_uuid": item.get("database_uuid"),
|
||||
"signature": json.dumps({
|
||||
"title": item.get("table_name"),
|
||||
"schema": item.get("schema"),
|
||||
"database_uuid": item.get("database_uuid"),
|
||||
"sql": item.get("sql"),
|
||||
"columns": item.get("columns"),
|
||||
"metrics": item.get("metrics"),
|
||||
}, sort_keys=True, default=str),
|
||||
} for item in datasets if item.get("uuid")],
|
||||
"charts": [{
|
||||
"uuid": str(item.get("uuid")),
|
||||
"title": item.get("slice_name") or item.get("name"),
|
||||
"dataset_uuid": item.get("datasource_uuid") or item.get("dataset_uuid"),
|
||||
"signature": json.dumps({
|
||||
"title": item.get("slice_name") or item.get("name"),
|
||||
"viz_type": item.get("viz_type"),
|
||||
"params": item.get("params"),
|
||||
"query_context": item.get("query_context"),
|
||||
"datasource_uuid": item.get("datasource_uuid"),
|
||||
"dataset_uuid": item.get("dataset_uuid"),
|
||||
}, sort_keys=True, default=str),
|
||||
} for item in charts if item.get("uuid")],
|
||||
}
|
||||
# [/DEF:_build_target_signatures:Function]
|
||||
|
||||
# [DEF:_build_risks:Function]
|
||||
# @PURPOSE: Build risk items for missing datasource, broken refs, overwrite, owner mismatch.
|
||||
def _build_risks(
|
||||
self,
|
||||
source_objects: Dict[str, List[Dict[str, Any]]],
|
||||
target_objects: Dict[str, List[Dict[str, Any]]],
|
||||
diff: Dict[str, Dict[str, List[Dict[str, Any]]]],
|
||||
target_client: SupersetClient,
|
||||
) -> List[Dict[str, Any]]:
|
||||
return build_risks(source_objects, target_objects, diff, target_client)
|
||||
# [/DEF:_build_risks:Function]
|
||||
|
||||
|
||||
# [/DEF:MigrationDryRunService:Class]
|
||||
# [/DEF:backend.src.core.migration.dry_run_orchestrator:Module]
|
||||
119
backend/src/core/migration/risk_assessor.py
Normal file
119
backend/src/core/migration/risk_assessor.py
Normal file
@@ -0,0 +1,119 @@
|
||||
# [DEF:backend.src.core.migration.risk_assessor:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: migration, dry_run, risk, scoring
|
||||
# @PURPOSE: Risk evaluation helpers for migration pre-flight reporting.
|
||||
# @LAYER: Core
|
||||
# @RELATION: USED_BY -> backend.src.core.migration.dry_run_orchestrator
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ..superset_client import SupersetClient
|
||||
|
||||
|
||||
# [DEF:index_by_uuid:Function]
|
||||
# @PURPOSE: Build UUID-index from normalized objects.
|
||||
def index_by_uuid(objects: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
|
||||
indexed: Dict[str, Dict[str, Any]] = {}
|
||||
for obj in objects:
|
||||
uuid = obj.get("uuid")
|
||||
if uuid:
|
||||
indexed[str(uuid)] = obj
|
||||
return indexed
|
||||
# [/DEF:index_by_uuid:Function]
|
||||
|
||||
|
||||
# [DEF:extract_owner_identifiers:Function]
|
||||
# @PURPOSE: Normalize owner payloads for stable comparison.
|
||||
def extract_owner_identifiers(owners: Any) -> List[str]:
|
||||
if not isinstance(owners, list):
|
||||
return []
|
||||
ids: List[str] = []
|
||||
for owner in owners:
|
||||
if isinstance(owner, dict):
|
||||
if owner.get("username"):
|
||||
ids.append(str(owner["username"]))
|
||||
elif owner.get("id") is not None:
|
||||
ids.append(str(owner["id"]))
|
||||
elif owner is not None:
|
||||
ids.append(str(owner))
|
||||
return sorted(set(ids))
|
||||
# [/DEF:extract_owner_identifiers:Function]
|
||||
|
||||
|
||||
# [DEF:build_risks:Function]
|
||||
# @PURPOSE: Build risk list from computed diffs and target catalog state.
|
||||
def build_risks(
|
||||
source_objects: Dict[str, List[Dict[str, Any]]],
|
||||
target_objects: Dict[str, List[Dict[str, Any]]],
|
||||
diff: Dict[str, Dict[str, List[Dict[str, Any]]]],
|
||||
target_client: SupersetClient,
|
||||
) -> List[Dict[str, Any]]:
|
||||
risks: List[Dict[str, Any]] = []
|
||||
for object_type in ("dashboards", "charts", "datasets"):
|
||||
for item in diff[object_type]["update"]:
|
||||
risks.append({
|
||||
"code": "overwrite_existing",
|
||||
"severity": "medium",
|
||||
"object_type": object_type[:-1],
|
||||
"object_uuid": item["uuid"],
|
||||
"message": f"Object will be updated in target: {item.get('title') or item['uuid']}",
|
||||
})
|
||||
|
||||
target_dataset_uuids = set(index_by_uuid(target_objects["datasets"]).keys())
|
||||
_, target_databases = target_client.get_databases(query={"columns": ["uuid"]})
|
||||
target_database_uuids = {str(item.get("uuid")) for item in target_databases if item.get("uuid")}
|
||||
|
||||
for dataset in source_objects["datasets"]:
|
||||
db_uuid = dataset.get("database_uuid")
|
||||
if db_uuid and str(db_uuid) not in target_database_uuids:
|
||||
risks.append({
|
||||
"code": "missing_datasource",
|
||||
"severity": "high",
|
||||
"object_type": "dataset",
|
||||
"object_uuid": dataset.get("uuid"),
|
||||
"message": f"Target datasource is missing for dataset {dataset.get('title') or dataset.get('uuid')}",
|
||||
})
|
||||
|
||||
for chart in source_objects["charts"]:
|
||||
ds_uuid = chart.get("dataset_uuid")
|
||||
if ds_uuid and str(ds_uuid) not in target_dataset_uuids:
|
||||
risks.append({
|
||||
"code": "breaking_reference",
|
||||
"severity": "high",
|
||||
"object_type": "chart",
|
||||
"object_uuid": chart.get("uuid"),
|
||||
"message": f"Chart references dataset not found on target: {ds_uuid}",
|
||||
})
|
||||
|
||||
source_dash = index_by_uuid(source_objects["dashboards"])
|
||||
target_dash = index_by_uuid(target_objects["dashboards"])
|
||||
for item in diff["dashboards"]["update"]:
|
||||
source_obj = source_dash.get(item["uuid"])
|
||||
target_obj = target_dash.get(item["uuid"])
|
||||
if not source_obj or not target_obj:
|
||||
continue
|
||||
source_owners = extract_owner_identifiers(source_obj.get("owners"))
|
||||
target_owners = extract_owner_identifiers(target_obj.get("owners"))
|
||||
if source_owners and target_owners and source_owners != target_owners:
|
||||
risks.append({
|
||||
"code": "owner_mismatch",
|
||||
"severity": "low",
|
||||
"object_type": "dashboard",
|
||||
"object_uuid": item["uuid"],
|
||||
"message": f"Owner mismatch for dashboard {item.get('title') or item['uuid']}",
|
||||
})
|
||||
return risks
|
||||
# [/DEF:build_risks:Function]
|
||||
|
||||
|
||||
# [DEF:score_risks:Function]
|
||||
# @PURPOSE: Aggregate risk list into score and level.
|
||||
def score_risks(risk_items: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
weights = {"high": 25, "medium": 10, "low": 5}
|
||||
score = min(100, sum(weights.get(item.get("severity", "low"), 5) for item in risk_items))
|
||||
level = "low" if score < 25 else "medium" if score < 60 else "high"
|
||||
return {"score": score, "level": level, "items": risk_items}
|
||||
# [/DEF:score_risks:Function]
|
||||
|
||||
|
||||
# [/DEF:backend.src.core.migration.risk_assessor:Module]
|
||||
@@ -11,28 +11,41 @@
|
||||
import zipfile
|
||||
import yaml
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
from typing import Dict, Optional, List
|
||||
from .logger import logger, belief_scope
|
||||
from src.core.mapping_service import IdMappingService
|
||||
from src.models.mapping import ResourceType
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:MigrationEngine:Class]
|
||||
# @PURPOSE: Engine for transforming Superset export ZIPs.
|
||||
class MigrationEngine:
|
||||
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initializes the migration engine with optional ID mapping service.
|
||||
# @PARAM: mapping_service (Optional[IdMappingService]) - Used for resolving target environment integer IDs.
|
||||
def __init__(self, mapping_service: Optional[IdMappingService] = None):
|
||||
self.mapping_service = mapping_service
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:transform_zip:Function]
|
||||
# @PURPOSE: Extracts ZIP, replaces database UUIDs in YAMLs, and re-packages.
|
||||
# @PURPOSE: Extracts ZIP, replaces database UUIDs in YAMLs, patches cross-filters, and re-packages.
|
||||
# @PARAM: zip_path (str) - Path to the source ZIP file.
|
||||
# @PARAM: output_path (str) - Path where the transformed ZIP will be saved.
|
||||
# @PARAM: db_mapping (Dict[str, str]) - Mapping of source UUID to target UUID.
|
||||
# @PARAM: strip_databases (bool) - Whether to remove the databases directory from the archive.
|
||||
# @PARAM: target_env_id (Optional[str]) - Used if fix_cross_filters is True to know which environment map to use.
|
||||
# @PARAM: fix_cross_filters (bool) - Whether to patch dashboard json_metadata.
|
||||
# @PRE: zip_path must point to a valid Superset export archive.
|
||||
# @POST: Transformed archive is saved to output_path.
|
||||
# @RETURN: bool - True if successful.
|
||||
def transform_zip(self, zip_path: str, output_path: str, db_mapping: Dict[str, str], strip_databases: bool = True) -> bool:
|
||||
def transform_zip(self, zip_path: str, output_path: str, db_mapping: Dict[str, str], strip_databases: bool = True, target_env_id: Optional[str] = None, fix_cross_filters: bool = False) -> bool:
|
||||
"""
|
||||
Transform a Superset export ZIP by replacing database UUIDs.
|
||||
Transform a Superset export ZIP by replacing database UUIDs and optionally fixing cross-filters.
|
||||
"""
|
||||
with belief_scope("MigrationEngine.transform_zip"):
|
||||
with tempfile.TemporaryDirectory() as temp_dir_str:
|
||||
@@ -44,8 +57,7 @@ class MigrationEngine:
|
||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||
zf.extractall(temp_dir)
|
||||
|
||||
# 2. Transform YAMLs
|
||||
# Datasets are usually in datasets/*.yaml
|
||||
# 2. Transform YAMLs (Databases)
|
||||
dataset_files = list(temp_dir.glob("**/datasets/**/*.yaml")) + list(temp_dir.glob("**/datasets/*.yaml"))
|
||||
dataset_files = list(set(dataset_files))
|
||||
|
||||
@@ -54,6 +66,20 @@ class MigrationEngine:
|
||||
logger.info(f"[MigrationEngine.transform_zip][Action] Transforming dataset: {ds_file}")
|
||||
self._transform_yaml(ds_file, db_mapping)
|
||||
|
||||
# 2.5 Patch Cross-Filters (Dashboards)
|
||||
if fix_cross_filters and self.mapping_service and target_env_id:
|
||||
dash_files = list(temp_dir.glob("**/dashboards/**/*.yaml")) + list(temp_dir.glob("**/dashboards/*.yaml"))
|
||||
dash_files = list(set(dash_files))
|
||||
|
||||
logger.info(f"[MigrationEngine.transform_zip][State] Found {len(dash_files)} dashboard files for patching.")
|
||||
|
||||
# Gather all source UUID-to-ID mappings from the archive first
|
||||
source_id_to_uuid_map = self._extract_chart_uuids_from_archive(temp_dir)
|
||||
|
||||
for dash_file in dash_files:
|
||||
logger.info(f"[MigrationEngine.transform_zip][Action] Patching dashboard: {dash_file}")
|
||||
self._patch_dashboard_metadata(dash_file, target_env_id, source_id_to_uuid_map)
|
||||
|
||||
# 3. Re-package
|
||||
logger.info(f"[MigrationEngine.transform_zip][Action] Re-packaging ZIP to: {output_path} (strip_databases={strip_databases})")
|
||||
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
@@ -97,6 +123,100 @@ class MigrationEngine:
|
||||
yaml.dump(data, f)
|
||||
# [/DEF:_transform_yaml:Function]
|
||||
|
||||
# [DEF:_extract_chart_uuids_from_archive:Function]
|
||||
# @PURPOSE: Scans the unpacked ZIP to map local exported integer IDs back to their UUIDs.
|
||||
# @PARAM: temp_dir (Path) - Root dir of unpacked archive
|
||||
# @RETURN: Dict[int, str] - Mapping of source Integer ID to UUID.
|
||||
def _extract_chart_uuids_from_archive(self, temp_dir: Path) -> Dict[int, str]:
|
||||
# Implementation Note: This is a placeholder for the logic that extracts
|
||||
# actual Source IDs. In a real scenario, this involves parsing chart YAMLs
|
||||
# or manifesting the export metadata structure where source IDs are stored.
|
||||
# For simplicity in US1 MVP, we assume it's read from chart files if present.
|
||||
mapping = {}
|
||||
chart_files = list(temp_dir.glob("**/charts/**/*.yaml")) + list(temp_dir.glob("**/charts/*.yaml"))
|
||||
for cf in set(chart_files):
|
||||
try:
|
||||
with open(cf, 'r') as f:
|
||||
cdata = yaml.safe_load(f)
|
||||
if cdata and 'id' in cdata and 'uuid' in cdata:
|
||||
mapping[cdata['id']] = cdata['uuid']
|
||||
except Exception:
|
||||
pass
|
||||
return mapping
|
||||
# [/DEF:_extract_chart_uuids_from_archive:Function]
|
||||
|
||||
# [DEF:_patch_dashboard_metadata:Function]
|
||||
# @PURPOSE: Replaces integer IDs in json_metadata.
|
||||
# @PARAM: file_path (Path)
|
||||
# @PARAM: target_env_id (str)
|
||||
# @PARAM: source_map (Dict[int, str])
|
||||
def _patch_dashboard_metadata(self, file_path: Path, target_env_id: str, source_map: Dict[int, str]):
|
||||
with belief_scope("MigrationEngine._patch_dashboard_metadata"):
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
if not data or 'json_metadata' not in data:
|
||||
return
|
||||
|
||||
metadata_str = data['json_metadata']
|
||||
if not metadata_str:
|
||||
return
|
||||
|
||||
metadata = json.loads(metadata_str)
|
||||
modified = False
|
||||
|
||||
# We need to deeply traverse and replace. For MVP, string replacement over the raw JSON is an option,
|
||||
# but careful dict traversal is safer.
|
||||
|
||||
# Fetch target UUIDs for everything we know:
|
||||
uuids_needed = list(source_map.values())
|
||||
target_ids = self.mapping_service.get_remote_ids_batch(target_env_id, ResourceType.CHART, uuids_needed)
|
||||
|
||||
if not target_ids:
|
||||
logger.info("[MigrationEngine._patch_dashboard_metadata][Reflect] No remote target IDs found in mapping database.")
|
||||
return
|
||||
|
||||
# Map Source Int -> Target Int
|
||||
source_to_target = {}
|
||||
missing_targets = []
|
||||
for s_id, s_uuid in source_map.items():
|
||||
if s_uuid in target_ids:
|
||||
source_to_target[s_id] = target_ids[s_uuid]
|
||||
else:
|
||||
missing_targets.append(s_id)
|
||||
|
||||
if missing_targets:
|
||||
logger.warning(f"[MigrationEngine._patch_dashboard_metadata][Coherence:Recoverable] Missing target IDs for source IDs: {missing_targets}. Cross-filters for these IDs might break.")
|
||||
|
||||
if not source_to_target:
|
||||
logger.info("[MigrationEngine._patch_dashboard_metadata][Reflect] No source IDs matched remotely. Skipping patch.")
|
||||
return
|
||||
|
||||
# Complex metadata traversal would go here (e.g. for native_filter_configuration)
|
||||
# We use regex replacement over the string for safety over unknown nested dicts.
|
||||
|
||||
new_metadata_str = metadata_str
|
||||
|
||||
# Replace chartId and datasetId assignments explicitly.
|
||||
# Pattern: "datasetId": 42 or "chartId": 42
|
||||
for s_id, t_id in source_to_target.items():
|
||||
# Replace in native_filter_configuration targets
|
||||
new_metadata_str = re.sub(r'("datasetId"\s*:\s*)' + str(s_id) + r'(\b)', r'\g<1>' + str(t_id) + r'\g<2>', new_metadata_str)
|
||||
new_metadata_str = re.sub(r'("chartId"\s*:\s*)' + str(s_id) + r'(\b)', r'\g<1>' + str(t_id) + r'\g<2>', new_metadata_str)
|
||||
|
||||
# Re-parse to validate valid JSON
|
||||
data['json_metadata'] = json.dumps(json.loads(new_metadata_str))
|
||||
|
||||
with open(file_path, 'w') as f:
|
||||
yaml.dump(data, f)
|
||||
logger.info(f"[MigrationEngine._patch_dashboard_metadata][Reason] Re-serialized modified JSON metadata for dashboard.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[MigrationEngine._patch_dashboard_metadata][Coherence:Failed] Metadata patch failed: {e}")
|
||||
|
||||
# [/DEF:_patch_dashboard_metadata:Function]
|
||||
|
||||
# [/DEF:MigrationEngine:Class]
|
||||
|
||||
# [/DEF:backend.src.core.migration_engine:Module]
|
||||
|
||||
@@ -14,8 +14,9 @@ import json
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Union, cast
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union, cast
|
||||
from requests import Response
|
||||
from datetime import datetime
|
||||
from .logger import logger as app_logger, belief_scope
|
||||
from .utils.network import APIClient, SupersetAPIError
|
||||
from .utils.fileio import get_filename_from_headers
|
||||
@@ -86,7 +87,17 @@ class SupersetClient:
|
||||
app_logger.info("[get_dashboards][Enter] Fetching dashboards.")
|
||||
validated_query = self._validate_query_params(query or {})
|
||||
if 'columns' not in validated_query:
|
||||
validated_query['columns'] = ["slug", "id", "changed_on_utc", "dashboard_title", "published"]
|
||||
validated_query['columns'] = [
|
||||
"slug",
|
||||
"id",
|
||||
"changed_on_utc",
|
||||
"dashboard_title",
|
||||
"published",
|
||||
"created_by",
|
||||
"changed_by",
|
||||
"changed_by_name",
|
||||
"owners",
|
||||
]
|
||||
|
||||
paginated_data = self._fetch_all_pages(
|
||||
endpoint="/dashboard/",
|
||||
@@ -97,6 +108,41 @@ class SupersetClient:
|
||||
return total_count, paginated_data
|
||||
# [/DEF:get_dashboards:Function]
|
||||
|
||||
# [DEF:get_dashboards_page:Function]
|
||||
# @PURPOSE: Fetches a single dashboards page from Superset without iterating all pages.
|
||||
# @PARAM: query (Optional[Dict]) - Query with page/page_size and optional columns.
|
||||
# @PRE: Client is authenticated.
|
||||
# @POST: Returns total count and one page of dashboards.
|
||||
# @RETURN: Tuple[int, List[Dict]]
|
||||
def get_dashboards_page(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
|
||||
with belief_scope("get_dashboards_page"):
|
||||
validated_query = self._validate_query_params(query or {})
|
||||
if "columns" not in validated_query:
|
||||
validated_query["columns"] = [
|
||||
"slug",
|
||||
"id",
|
||||
"changed_on_utc",
|
||||
"dashboard_title",
|
||||
"published",
|
||||
"created_by",
|
||||
"changed_by",
|
||||
"changed_by_name",
|
||||
"owners",
|
||||
]
|
||||
|
||||
response_json = cast(
|
||||
Dict[str, Any],
|
||||
self.network.request(
|
||||
method="GET",
|
||||
endpoint="/dashboard/",
|
||||
params={"q": json.dumps(validated_query)},
|
||||
),
|
||||
)
|
||||
result = response_json.get("result", [])
|
||||
total_count = response_json.get("count", len(result))
|
||||
return total_count, result
|
||||
# [/DEF:get_dashboards_page:Function]
|
||||
|
||||
# [DEF:get_dashboards_summary:Function]
|
||||
# @PURPOSE: Fetches dashboard metadata optimized for the grid.
|
||||
# @PRE: Client is authenticated.
|
||||
@@ -104,23 +150,167 @@ class SupersetClient:
|
||||
# @RETURN: List[Dict]
|
||||
def get_dashboards_summary(self) -> List[Dict]:
|
||||
with belief_scope("SupersetClient.get_dashboards_summary"):
|
||||
query = {
|
||||
"columns": ["id", "dashboard_title", "changed_on_utc", "published"]
|
||||
}
|
||||
# Rely on list endpoint default projection to stay compatible
|
||||
# across Superset versions and preserve owners in one request.
|
||||
query: Dict[str, Any] = {}
|
||||
_, dashboards = self.get_dashboards(query=query)
|
||||
|
||||
# Map fields to DashboardMetadata schema
|
||||
result = []
|
||||
for dash in dashboards:
|
||||
owners = self._extract_owner_labels(dash.get("owners"))
|
||||
# No per-dashboard detail requests here: keep list endpoint O(1).
|
||||
if not owners:
|
||||
owners = self._extract_owner_labels(
|
||||
[dash.get("created_by"), dash.get("changed_by")],
|
||||
)
|
||||
|
||||
result.append({
|
||||
"id": dash.get("id"),
|
||||
"title": dash.get("dashboard_title"),
|
||||
"last_modified": dash.get("changed_on_utc"),
|
||||
"status": "published" if dash.get("published") else "draft"
|
||||
"status": "published" if dash.get("published") else "draft",
|
||||
"created_by": self._extract_user_display(
|
||||
None,
|
||||
dash.get("created_by"),
|
||||
),
|
||||
"modified_by": self._extract_user_display(
|
||||
dash.get("changed_by_name"),
|
||||
dash.get("changed_by"),
|
||||
),
|
||||
"owners": owners,
|
||||
})
|
||||
return result
|
||||
# [/DEF:get_dashboards_summary:Function]
|
||||
|
||||
# [DEF:get_dashboards_summary_page:Function]
|
||||
# @PURPOSE: Fetches one page of dashboard metadata optimized for the grid.
|
||||
# @PARAM: page (int) - 1-based page number from API route contract.
|
||||
# @PARAM: page_size (int) - Number of items per page.
|
||||
# @PRE: page >= 1 and page_size > 0.
|
||||
# @POST: Returns mapped summaries and total dashboard count.
|
||||
# @RETURN: Tuple[int, List[Dict]]
|
||||
def get_dashboards_summary_page(
|
||||
self,
|
||||
page: int,
|
||||
page_size: int,
|
||||
search: Optional[str] = None,
|
||||
) -> Tuple[int, List[Dict]]:
|
||||
with belief_scope("SupersetClient.get_dashboards_summary_page"):
|
||||
query: Dict[str, Any] = {
|
||||
"page": max(page - 1, 0),
|
||||
"page_size": page_size,
|
||||
}
|
||||
normalized_search = (search or "").strip()
|
||||
if normalized_search:
|
||||
# Superset list API supports filter objects with `opr` operator.
|
||||
# `ct` -> contains (ILIKE on most Superset backends).
|
||||
query["filters"] = [
|
||||
{
|
||||
"col": "dashboard_title",
|
||||
"opr": "ct",
|
||||
"value": normalized_search,
|
||||
}
|
||||
]
|
||||
|
||||
total_count, dashboards = self.get_dashboards_page(query=query)
|
||||
|
||||
result = []
|
||||
for dash in dashboards:
|
||||
owners = self._extract_owner_labels(dash.get("owners"))
|
||||
if not owners:
|
||||
owners = self._extract_owner_labels(
|
||||
[dash.get("created_by"), dash.get("changed_by")],
|
||||
)
|
||||
|
||||
result.append({
|
||||
"id": dash.get("id"),
|
||||
"title": dash.get("dashboard_title"),
|
||||
"last_modified": dash.get("changed_on_utc"),
|
||||
"status": "published" if dash.get("published") else "draft",
|
||||
"created_by": self._extract_user_display(
|
||||
None,
|
||||
dash.get("created_by"),
|
||||
),
|
||||
"modified_by": self._extract_user_display(
|
||||
dash.get("changed_by_name"),
|
||||
dash.get("changed_by"),
|
||||
),
|
||||
"owners": owners,
|
||||
})
|
||||
|
||||
return total_count, result
|
||||
# [/DEF:get_dashboards_summary_page:Function]
|
||||
|
||||
# [DEF:_extract_owner_labels:Function]
|
||||
# @PURPOSE: Normalize dashboard owners payload to stable display labels.
|
||||
# @PRE: owners payload can be scalar, object or list.
|
||||
# @POST: Returns deduplicated non-empty owner labels preserving order.
|
||||
# @RETURN: List[str]
|
||||
def _extract_owner_labels(self, owners_payload: Any) -> List[str]:
|
||||
if owners_payload is None:
|
||||
return []
|
||||
|
||||
owners_list: List[Any]
|
||||
if isinstance(owners_payload, list):
|
||||
owners_list = owners_payload
|
||||
else:
|
||||
owners_list = [owners_payload]
|
||||
|
||||
normalized: List[str] = []
|
||||
for owner in owners_list:
|
||||
label: Optional[str] = None
|
||||
if isinstance(owner, dict):
|
||||
label = self._extract_user_display(None, owner)
|
||||
else:
|
||||
label = self._sanitize_user_text(owner)
|
||||
if label and label not in normalized:
|
||||
normalized.append(label)
|
||||
return normalized
|
||||
# [/DEF:_extract_owner_labels:Function]
|
||||
|
||||
# [DEF:_extract_user_display:Function]
|
||||
# @PURPOSE: Normalize user payload to a stable display name.
|
||||
# @PRE: user payload can be string, dict or None.
|
||||
# @POST: Returns compact non-empty display value or None.
|
||||
# @RETURN: Optional[str]
|
||||
def _extract_user_display(self, preferred_value: Optional[str], user_payload: Optional[Dict]) -> Optional[str]:
|
||||
preferred = self._sanitize_user_text(preferred_value)
|
||||
if preferred:
|
||||
return preferred
|
||||
|
||||
if isinstance(user_payload, dict):
|
||||
full_name = self._sanitize_user_text(user_payload.get("full_name"))
|
||||
if full_name:
|
||||
return full_name
|
||||
first_name = self._sanitize_user_text(user_payload.get("first_name")) or ""
|
||||
last_name = self._sanitize_user_text(user_payload.get("last_name")) or ""
|
||||
combined = " ".join(part for part in [first_name, last_name] if part).strip()
|
||||
if combined:
|
||||
return combined
|
||||
username = self._sanitize_user_text(user_payload.get("username"))
|
||||
if username:
|
||||
return username
|
||||
email = self._sanitize_user_text(user_payload.get("email"))
|
||||
if email:
|
||||
return email
|
||||
return None
|
||||
# [/DEF:_extract_user_display:Function]
|
||||
|
||||
# [DEF:_sanitize_user_text:Function]
|
||||
# @PURPOSE: Convert scalar value to non-empty user-facing text.
|
||||
# @PRE: value can be any scalar type.
|
||||
# @POST: Returns trimmed string or None.
|
||||
# @RETURN: Optional[str]
|
||||
def _sanitize_user_text(self, value: Optional[Union[str, int]]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = str(value).strip()
|
||||
if not normalized:
|
||||
return None
|
||||
return normalized
|
||||
# [/DEF:_sanitize_user_text:Function]
|
||||
|
||||
# [DEF:get_dashboard:Function]
|
||||
# @PURPOSE: Fetches a single dashboard by ID.
|
||||
# @PRE: Client is authenticated and dashboard_id exists.
|
||||
@@ -335,6 +525,25 @@ class SupersetClient:
|
||||
}
|
||||
# [/DEF:get_dashboard_detail:Function]
|
||||
|
||||
# [DEF:get_charts:Function]
|
||||
# @PURPOSE: Fetches all charts with pagination support.
|
||||
# @PARAM: query (Optional[Dict]) - Optional query params/columns/filters.
|
||||
# @PRE: Client is authenticated.
|
||||
# @POST: Returns total count and charts list.
|
||||
# @RETURN: Tuple[int, List[Dict]]
|
||||
def get_charts(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
|
||||
with belief_scope("get_charts"):
|
||||
validated_query = self._validate_query_params(query or {})
|
||||
if "columns" not in validated_query:
|
||||
validated_query["columns"] = ["id", "uuid", "slice_name", "viz_type"]
|
||||
|
||||
paginated_data = self._fetch_all_pages(
|
||||
endpoint="/chart/",
|
||||
pagination_options={"base_query": validated_query, "results_field": "result"},
|
||||
)
|
||||
return len(paginated_data), paginated_data
|
||||
# [/DEF:get_charts:Function]
|
||||
|
||||
# [DEF:_extract_chart_ids_from_layout:Function]
|
||||
# @PURPOSE: Traverses dashboard layout metadata and extracts chart IDs from common keys.
|
||||
# @PRE: payload can be dict/list/scalar.
|
||||
@@ -400,6 +609,8 @@ class SupersetClient:
|
||||
# @RETURN: Dict - Ответ API в случае успеха.
|
||||
def import_dashboard(self, file_name: Union[str, Path], dash_id: Optional[int] = None, dash_slug: Optional[str] = None) -> Dict:
|
||||
with belief_scope("import_dashboard"):
|
||||
if file_name is None:
|
||||
raise ValueError("file_name cannot be None")
|
||||
file_path = str(file_name)
|
||||
self._validate_import_file(file_path)
|
||||
try:
|
||||
@@ -785,7 +996,9 @@ class SupersetClient:
|
||||
# @POST: Returns a dictionary with at least page and page_size.
|
||||
def _validate_query_params(self, query: Optional[Dict]) -> Dict:
|
||||
with belief_scope("_validate_query_params"):
|
||||
base_query = {"page": 0, "page_size": 1000}
|
||||
# Superset list endpoints commonly cap page_size at 100.
|
||||
# Using 100 avoids partial fetches when larger values are silently truncated.
|
||||
base_query = {"page": 0, "page_size": 100}
|
||||
return {**base_query, **(query or {})}
|
||||
# [/DEF:_validate_query_params:Function]
|
||||
|
||||
@@ -827,6 +1040,53 @@ class SupersetClient:
|
||||
raise SupersetAPIError(f"Архив {zip_path} не содержит 'metadata.yaml'")
|
||||
# [/DEF:_validate_import_file:Function]
|
||||
|
||||
# [DEF:get_all_resources:Function]
|
||||
# @PURPOSE: Fetches all resources of a given type with id, uuid, and name columns.
|
||||
# @PARAM: resource_type (str) - One of "chart", "dataset", "dashboard".
|
||||
# @PRE: Client is authenticated. resource_type is valid.
|
||||
# @POST: Returns a list of resource dicts with at minimum id, uuid, and name fields.
|
||||
# @RETURN: List[Dict]
|
||||
def get_all_resources(self, resource_type: str, since_dttm: Optional[datetime] = None) -> List[Dict]:
|
||||
with belief_scope("SupersetClient.get_all_resources", f"type={resource_type}, since={since_dttm}"):
|
||||
column_map = {
|
||||
"chart": {"endpoint": "/chart/", "columns": ["id", "uuid", "slice_name"]},
|
||||
"dataset": {"endpoint": "/dataset/", "columns": ["id", "uuid", "table_name"]},
|
||||
"dashboard": {"endpoint": "/dashboard/", "columns": ["id", "uuid", "slug", "dashboard_title"]},
|
||||
}
|
||||
config = column_map.get(resource_type)
|
||||
if not config:
|
||||
app_logger.warning("[get_all_resources][Warning] Unknown resource type: %s", resource_type)
|
||||
return []
|
||||
|
||||
query = {"columns": config["columns"]}
|
||||
|
||||
if since_dttm:
|
||||
# Format to ISO 8601 string for Superset filter
|
||||
# e.g. "2026-02-25T13:24:32.186" or integer milliseconds.
|
||||
# Assuming standard ISO string works:
|
||||
# The user's example had value: 0 (which might imply ms or int) but often it accepts strings.
|
||||
import math
|
||||
# Use int milliseconds to be safe, as "0" was in the user example
|
||||
timestamp_ms = math.floor(since_dttm.timestamp() * 1000)
|
||||
|
||||
query["filters"] = [
|
||||
{
|
||||
"col": "changed_on_dttm",
|
||||
"opr": "gt",
|
||||
"value": timestamp_ms
|
||||
}
|
||||
]
|
||||
# Also we must request `changed_on_dttm` just in case, though API usually filters regardless of columns
|
||||
|
||||
validated = self._validate_query_params(query)
|
||||
data = self._fetch_all_pages(
|
||||
endpoint=config["endpoint"],
|
||||
pagination_options={"base_query": validated, "results_field": "result"},
|
||||
)
|
||||
app_logger.info("[get_all_resources][Exit] Fetched %d %s resources.", len(data), resource_type)
|
||||
return data
|
||||
# [/DEF:get_all_resources:Function]
|
||||
|
||||
# [/SECTION]
|
||||
|
||||
# [/DEF:SupersetClient:Class]
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
# @TIER: CRITICAL
|
||||
# @INVARIANT: Each TaskContext is bound to a single task execution.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import Dict, Any, Callable
|
||||
from .task_logger import TaskLogger
|
||||
from ..logger import belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:TaskContext:Class]
|
||||
@@ -17,6 +19,20 @@ from .task_logger import TaskLogger
|
||||
# @TIER: CRITICAL
|
||||
# @INVARIANT: logger is always a valid TaskLogger instance.
|
||||
# @UX_STATE: Idle -> Active -> Complete
|
||||
#
|
||||
# @TEST_CONTRACT: TaskContextInit ->
|
||||
# {
|
||||
# required_fields: {task_id: str, add_log_fn: Callable, params: dict},
|
||||
# optional_fields: {default_source: str},
|
||||
# invariants: [
|
||||
# "task_id matches initialized logger's task_id",
|
||||
# "logger is a valid TaskLogger instance"
|
||||
# ]
|
||||
# }
|
||||
# @TEST_FIXTURE: valid_context -> {"task_id": "123", "add_log_fn": lambda *args: None, "params": {"k": "v"}, "default_source": "plugin"}
|
||||
# @TEST_EDGE: missing_task_id -> raises TypeError
|
||||
# @TEST_EDGE: missing_add_log_fn -> raises TypeError
|
||||
# @TEST_INVARIANT: logger_initialized -> verifies: [valid_context]
|
||||
class TaskContext:
|
||||
"""
|
||||
Execution context provided to plugins during task execution.
|
||||
@@ -44,13 +60,14 @@ class TaskContext:
|
||||
params: Dict[str, Any],
|
||||
default_source: str = "plugin"
|
||||
):
|
||||
self._task_id = task_id
|
||||
self._params = params
|
||||
self._logger = TaskLogger(
|
||||
task_id=task_id,
|
||||
add_log_fn=add_log_fn,
|
||||
source=default_source
|
||||
)
|
||||
with belief_scope("__init__"):
|
||||
self._task_id = task_id
|
||||
self._params = params
|
||||
self._logger = TaskLogger(
|
||||
task_id=task_id,
|
||||
add_log_fn=add_log_fn,
|
||||
source=default_source
|
||||
)
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:task_id:Function]
|
||||
@@ -60,7 +77,8 @@ class TaskContext:
|
||||
# @RETURN: str - The task ID.
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
return self._task_id
|
||||
with belief_scope("task_id"):
|
||||
return self._task_id
|
||||
# [/DEF:task_id:Function]
|
||||
|
||||
# [DEF:logger:Function]
|
||||
@@ -70,7 +88,8 @@ class TaskContext:
|
||||
# @RETURN: TaskLogger - The logger instance.
|
||||
@property
|
||||
def logger(self) -> TaskLogger:
|
||||
return self._logger
|
||||
with belief_scope("logger"):
|
||||
return self._logger
|
||||
# [/DEF:logger:Function]
|
||||
|
||||
# [DEF:params:Function]
|
||||
@@ -80,7 +99,8 @@ class TaskContext:
|
||||
# @RETURN: Dict[str, Any] - The task parameters.
|
||||
@property
|
||||
def params(self) -> Dict[str, Any]:
|
||||
return self._params
|
||||
with belief_scope("params"):
|
||||
return self._params
|
||||
# [/DEF:params:Function]
|
||||
|
||||
# [DEF:get_param:Function]
|
||||
@@ -91,7 +111,8 @@ class TaskContext:
|
||||
# @PARAM: default (Any) - Default value if key not found.
|
||||
# @RETURN: Any - Parameter value or default.
|
||||
def get_param(self, key: str, default: Any = None) -> Any:
|
||||
return self._params.get(key, default)
|
||||
with belief_scope("get_param"):
|
||||
return self._params.get(key, default)
|
||||
# [/DEF:get_param:Function]
|
||||
|
||||
# [DEF:create_sub_context:Function]
|
||||
@@ -102,12 +123,13 @@ class TaskContext:
|
||||
# @RETURN: TaskContext - New context with different source.
|
||||
def create_sub_context(self, source: str) -> "TaskContext":
|
||||
"""Create a sub-context with a different default source for logging."""
|
||||
return TaskContext(
|
||||
task_id=self._task_id,
|
||||
add_log_fn=self._logger._add_log,
|
||||
params=self._params,
|
||||
default_source=source
|
||||
)
|
||||
with belief_scope("create_sub_context"):
|
||||
return TaskContext(
|
||||
task_id=self._task_id,
|
||||
add_log_fn=self._logger._add_log,
|
||||
params=self._params,
|
||||
default_source=source
|
||||
)
|
||||
# [/DEF:create_sub_context:Function]
|
||||
|
||||
# [/DEF:TaskContext:Class]
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
# [DEF:TaskManagerModule:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: task, manager, lifecycle, execution, state
|
||||
# @PURPOSE: Manages the lifecycle of tasks, including their creation, execution, and state tracking. It uses a thread pool to run plugins asynchronously.
|
||||
# @LAYER: Core
|
||||
# @RELATION: Depends on PluginLoader to get plugin instances. It is used by the API layer to create and query tasks.
|
||||
# @INVARIANT: Task IDs are unique.
|
||||
# @CONSTRAINT: Must use belief_scope for logging.
|
||||
# @TEST_CONTRACT: TaskManagerModule -> {
|
||||
# required_fields: {plugin_loader: PluginLoader},
|
||||
# optional_fields: {},
|
||||
# invariants: ["Must use belief_scope for logging"]
|
||||
# }
|
||||
# @TEST_FIXTURE: valid_module -> {"manager_initialized": true}
|
||||
# @TEST_EDGE: missing_required_field -> {"plugin_loader": null}
|
||||
# @TEST_EDGE: empty_response -> {"tasks": []}
|
||||
# @TEST_EDGE: invalid_type -> {"plugin_loader": "string_instead_of_object"}
|
||||
# @TEST_EDGE: external_failure -> {"db_unavailable": true}
|
||||
# @TEST_INVARIANT: logger_compliance -> verifies: [valid_module]
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import asyncio
|
||||
@@ -27,6 +39,20 @@ from ..logger import logger, belief_scope, should_log_task_level
|
||||
# @INVARIANT: Task IDs are unique within the registry.
|
||||
# @INVARIANT: Each task has exactly one status at any time.
|
||||
# @INVARIANT: Log entries are never deleted after being added to a task.
|
||||
#
|
||||
# @TEST_CONTRACT: TaskManagerModel ->
|
||||
# {
|
||||
# required_fields: {plugin_loader: PluginLoader},
|
||||
# invariants: [
|
||||
# "Tasks are persisted immediately upon creation",
|
||||
# "Running tasks use a thread pool or asyncio event loop based on executor type",
|
||||
# "Log flushing runs on a background thread"
|
||||
# ]
|
||||
# }
|
||||
# @TEST_FIXTURE: valid_manager -> {"plugin_loader": "MockPluginLoader()"}
|
||||
# @TEST_EDGE: create_task_invalid_plugin -> raises ValueError
|
||||
# @TEST_EDGE: create_task_invalid_params -> raises ValueError
|
||||
# @TEST_INVARIANT: lifecycle_management -> verifies: [valid_manager]
|
||||
class TaskManager:
|
||||
"""
|
||||
Manages the lifecycle of tasks, including their creation, execution, and state tracking.
|
||||
@@ -95,6 +121,7 @@ class TaskManager:
|
||||
if logs:
|
||||
try:
|
||||
self.log_persistence_service.add_logs(task_id, logs)
|
||||
logger.debug(f"Flushed {len(logs)} logs for task {task_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to flush logs for task {task_id}: {e}")
|
||||
# Re-add logs to buffer on failure
|
||||
@@ -111,14 +138,15 @@ class TaskManager:
|
||||
# @PARAM: task_id (str) - The task ID.
|
||||
def _flush_task_logs(self, task_id: str):
|
||||
"""Flush logs for a specific task immediately."""
|
||||
with self._log_buffer_lock:
|
||||
logs = self._log_buffer.pop(task_id, [])
|
||||
|
||||
if logs:
|
||||
try:
|
||||
self.log_persistence_service.add_logs(task_id, logs)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to flush logs for task {task_id}: {e}")
|
||||
with belief_scope("_flush_task_logs"):
|
||||
with self._log_buffer_lock:
|
||||
logs = self._log_buffer.pop(task_id, [])
|
||||
|
||||
if logs:
|
||||
try:
|
||||
self.log_persistence_service.add_logs(task_id, logs)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to flush logs for task {task_id}: {e}")
|
||||
# [/DEF:_flush_task_logs:Function]
|
||||
|
||||
# [DEF:create_task:Function]
|
||||
|
||||
@@ -45,6 +45,14 @@ class LogLevel(str, Enum):
|
||||
# @PURPOSE: A Pydantic model representing a single, structured log entry associated with a task.
|
||||
# @TIER: CRITICAL
|
||||
# @INVARIANT: Each log entry has a unique timestamp and source.
|
||||
#
|
||||
# @TEST_CONTRACT: LogEntryModel ->
|
||||
# {
|
||||
# required_fields: {message: str},
|
||||
# optional_fields: {timestamp: datetime, level: str, source: str, context: dict, metadata: dict}
|
||||
# }
|
||||
# @TEST_FIXTURE: valid_log_entry -> {"message": "Plugin initialized"}
|
||||
# @TEST_EDGE: empty_message -> {"message": ""}
|
||||
class LogEntry(BaseModel):
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||
level: str = Field(default="INFO")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# [DEF:TaskPersistenceModule:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: persistence, sqlite, sqlalchemy, task, storage
|
||||
# @PURPOSE: Handles the persistence of tasks using SQLAlchemy and the tasks.db database.
|
||||
# @LAYER: Core
|
||||
@@ -19,42 +20,79 @@ from ..logger import logger, belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:TaskPersistenceService:Class]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: persistence, service, database, sqlalchemy
|
||||
# @PURPOSE: Provides methods to save and load tasks from the tasks.db database using SQLAlchemy.
|
||||
# @INVARIANT: Persistence must handle potentially missing task fields natively.
|
||||
#
|
||||
# @TEST_CONTRACT: TaskPersistenceService ->
|
||||
# {
|
||||
# required_fields: {},
|
||||
# invariants: [
|
||||
# "persist_task creates or updates a record",
|
||||
# "load_tasks retrieves valid Task instances",
|
||||
# "delete_tasks correctly removes records from the database"
|
||||
# ]
|
||||
# }
|
||||
# @TEST_FIXTURE: valid_task_persistence -> {"task_id": "123", "status": "PENDING"}
|
||||
# @TEST_EDGE: persist_invalid_task_type -> raises Exception
|
||||
# @TEST_EDGE: load_corrupt_json_params -> handled gracefully
|
||||
# @TEST_INVARIANT: accurate_round_trip -> verifies: [valid_task_persistence, load_corrupt_json_params]
|
||||
class TaskPersistenceService:
|
||||
# [DEF:_json_load_if_needed:Function]
|
||||
# @PURPOSE: Safely load JSON strings from DB if necessary
|
||||
# @PRE: value is an arbitrary database value
|
||||
# @POST: Returns parsed JSON object, list, string, or primitive
|
||||
@staticmethod
|
||||
def _json_load_if_needed(value):
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (dict, list)):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if stripped == "" or stripped.lower() == "null":
|
||||
with belief_scope("TaskPersistenceService._json_load_if_needed"):
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
if isinstance(value, (dict, list)):
|
||||
return value
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if stripped == "" or stripped.lower() == "null":
|
||||
return None
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
return value
|
||||
# [/DEF:_json_load_if_needed:Function]
|
||||
|
||||
# [DEF:_parse_datetime:Function]
|
||||
# @PURPOSE: Safely parse a datetime string from the database
|
||||
# @PRE: value is an ISO string or datetime object
|
||||
# @POST: Returns datetime object or None
|
||||
@staticmethod
|
||||
def _parse_datetime(value):
|
||||
if value is None or isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_environment_id(session: Session, env_id: Optional[str]) -> Optional[str]:
|
||||
if not env_id:
|
||||
with belief_scope("TaskPersistenceService._parse_datetime"):
|
||||
if value is None or isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
exists = session.query(Environment.id).filter(Environment.id == env_id).first()
|
||||
return env_id if exists else None
|
||||
# [/DEF:_parse_datetime:Function]
|
||||
|
||||
# [DEF:_resolve_environment_id:Function]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Resolve environment id based on provided value or fallback to default
|
||||
# @PRE: Session is active
|
||||
# @POST: Environment ID is returned
|
||||
@staticmethod
|
||||
def _resolve_environment_id(session: Session, env_id: Optional[str]) -> str:
|
||||
with belief_scope("_resolve_environment_id"):
|
||||
if env_id:
|
||||
return env_id
|
||||
repo_env = session.query(Environment).filter_by(name="default").first()
|
||||
if repo_env:
|
||||
return str(repo_env.id)
|
||||
return "default"
|
||||
# [/DEF:_resolve_environment_id:Function]
|
||||
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initializes the persistence service.
|
||||
@@ -90,13 +128,14 @@ class TaskPersistenceService:
|
||||
|
||||
# Ensure params and result are JSON serializable
|
||||
def json_serializable(obj):
|
||||
if isinstance(obj, dict):
|
||||
return {k: json_serializable(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [json_serializable(v) for v in obj]
|
||||
elif isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
return obj
|
||||
with belief_scope("TaskPersistenceService.json_serializable"):
|
||||
if isinstance(obj, dict):
|
||||
return {k: json_serializable(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [json_serializable(v) for v in obj]
|
||||
elif isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
return obj
|
||||
|
||||
record.params = json_serializable(task.params)
|
||||
record.result = json_serializable(task.result)
|
||||
@@ -220,6 +259,19 @@ class TaskPersistenceService:
|
||||
# @TIER: CRITICAL
|
||||
# @RELATION: DEPENDS_ON -> TaskLogRecord
|
||||
# @INVARIANT: Log entries are batch-inserted for performance.
|
||||
#
|
||||
# @TEST_CONTRACT: TaskLogPersistenceService ->
|
||||
# {
|
||||
# required_fields: {},
|
||||
# invariants: [
|
||||
# "add_logs efficiently saves logs to the database",
|
||||
# "get_logs retrieves properly filtered LogEntry objects"
|
||||
# ]
|
||||
# }
|
||||
# @TEST_FIXTURE: valid_log_batch -> {"task_id": "123", "logs": [{"level": "INFO", "message": "msg"}]}
|
||||
# @TEST_EDGE: empty_log_list -> no-op behavior
|
||||
# @TEST_EDGE: add_logs_db_error -> rollback and log error
|
||||
# @TEST_INVARIANT: accurate_log_aggregation -> verifies: [valid_log_batch]
|
||||
class TaskLogPersistenceService:
|
||||
"""
|
||||
Service for persisting and querying task logs.
|
||||
@@ -227,9 +279,11 @@ class TaskLogPersistenceService:
|
||||
"""
|
||||
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initialize the log persistence service.
|
||||
# @POST: Service is ready.
|
||||
def __init__(self):
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Initializes the TaskLogPersistenceService
|
||||
# @PRE: config is provided or defaults are used
|
||||
# @POST: Service is ready for log persistence
|
||||
def __init__(self, config=None):
|
||||
pass
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
|
||||
@@ -16,6 +16,20 @@ from typing import Dict, Any, Optional, Callable
|
||||
# @TIER: CRITICAL
|
||||
# @INVARIANT: All log calls include the task_id and source.
|
||||
# @UX_STATE: Idle -> Logging -> (system records log)
|
||||
#
|
||||
# @TEST_CONTRACT: TaskLoggerModel ->
|
||||
# {
|
||||
# required_fields: {task_id: str, add_log_fn: Callable},
|
||||
# optional_fields: {source: str},
|
||||
# invariants: [
|
||||
# "All specific log methods (info, error) delegate to _log",
|
||||
# "with_source creates a new logger with the same task_id"
|
||||
# ]
|
||||
# }
|
||||
# @TEST_FIXTURE: valid_task_logger -> {"task_id": "test_123", "add_log_fn": lambda *args: None, "source": "test_plugin"}
|
||||
# @TEST_EDGE: missing_task_id -> raises TypeError
|
||||
# @TEST_EDGE: invalid_add_log_fn -> raises TypeError
|
||||
# @TEST_INVARIANT: consistent_delegation -> verifies: [valid_task_logger]
|
||||
class TaskLogger:
|
||||
"""
|
||||
A dedicated logger for tasks that automatically tags logs with source attribution.
|
||||
@@ -71,6 +85,7 @@ class TaskLogger:
|
||||
# @PARAM: message (str) - Log message.
|
||||
# @PARAM: source (Optional[str]) - Override source for this log entry.
|
||||
# @PARAM: metadata (Optional[Dict]) - Additional structured data.
|
||||
# @UX_STATE: Logging -> (writing internal log)
|
||||
def _log(
|
||||
self,
|
||||
level: str,
|
||||
@@ -90,6 +105,8 @@ class TaskLogger:
|
||||
|
||||
# [DEF:debug:Function]
|
||||
# @PURPOSE: Log a DEBUG level message.
|
||||
# @PRE: message is a string.
|
||||
# @POST: Log entry added via internally with DEBUG level.
|
||||
# @PARAM: message (str) - Log message.
|
||||
# @PARAM: source (Optional[str]) - Override source.
|
||||
# @PARAM: metadata (Optional[Dict]) - Additional data.
|
||||
@@ -104,6 +121,8 @@ class TaskLogger:
|
||||
|
||||
# [DEF:info:Function]
|
||||
# @PURPOSE: Log an INFO level message.
|
||||
# @PRE: message is a string.
|
||||
# @POST: Log entry added internally with INFO level.
|
||||
# @PARAM: message (str) - Log message.
|
||||
# @PARAM: source (Optional[str]) - Override source.
|
||||
# @PARAM: metadata (Optional[Dict]) - Additional data.
|
||||
@@ -118,6 +137,8 @@ class TaskLogger:
|
||||
|
||||
# [DEF:warning:Function]
|
||||
# @PURPOSE: Log a WARNING level message.
|
||||
# @PRE: message is a string.
|
||||
# @POST: Log entry added internally with WARNING level.
|
||||
# @PARAM: message (str) - Log message.
|
||||
# @PARAM: source (Optional[str]) - Override source.
|
||||
# @PARAM: metadata (Optional[Dict]) - Additional data.
|
||||
@@ -132,6 +153,8 @@ class TaskLogger:
|
||||
|
||||
# [DEF:error:Function]
|
||||
# @PURPOSE: Log an ERROR level message.
|
||||
# @PRE: message is a string.
|
||||
# @POST: Log entry added internally with ERROR level.
|
||||
# @PARAM: message (str) - Log message.
|
||||
# @PARAM: source (Optional[str]) - Override source.
|
||||
# @PARAM: metadata (Optional[Dict]) - Additional data.
|
||||
|
||||
@@ -203,10 +203,9 @@ class APIClient:
|
||||
# @PRE: APIClient is initialized and authenticated or can be authenticated.
|
||||
# @POST: Returns headers including auth tokens.
|
||||
def headers(self) -> Dict[str, str]:
|
||||
with belief_scope("headers"):
|
||||
if not self._authenticated:
|
||||
self.authenticate()
|
||||
return {
|
||||
if not self._authenticated:
|
||||
self.authenticate()
|
||||
return {
|
||||
"Authorization": f"Bearer {self._tokens['access_token']}",
|
||||
"X-CSRFToken": self._tokens.get("csrf_token", ""),
|
||||
"Referer": self.base_url,
|
||||
@@ -225,8 +224,7 @@ class APIClient:
|
||||
# @RETURN: `requests.Response` если `raw_response=True`, иначе `dict`.
|
||||
# @THROW: SupersetAPIError, NetworkError и их подклассы.
|
||||
def request(self, method: str, endpoint: str, headers: Optional[Dict] = None, raw_response: bool = False, **kwargs) -> Union[requests.Response, Dict[str, Any]]:
|
||||
with belief_scope("request"):
|
||||
full_url = f"{self.base_url}{endpoint}"
|
||||
full_url = f"{self.base_url}{endpoint}"
|
||||
_headers = self.headers.copy()
|
||||
if headers:
|
||||
_headers.update(headers)
|
||||
@@ -394,4 +392,4 @@ class APIClient:
|
||||
|
||||
# [/DEF:APIClient:Class]
|
||||
|
||||
# [/DEF:backend.core.utils.network:Module]
|
||||
# [/DEF:backend.core.utils.network:Module]
|
||||
|
||||
235
backend/src/models/__tests__/test_report_models.py
Normal file
235
backend/src/models/__tests__/test_report_models.py
Normal file
@@ -0,0 +1,235 @@
|
||||
# [DEF:test_report_models:Module]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Unit tests for report Pydantic models and their validators
|
||||
# @LAYER: Domain
|
||||
# @RELATION: TESTS -> backend.src.models.report
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
class TestTaskType:
|
||||
"""Tests for the TaskType enum."""
|
||||
|
||||
def test_enum_values(self):
|
||||
from src.models.report import TaskType
|
||||
assert TaskType.LLM_VERIFICATION == "llm_verification"
|
||||
assert TaskType.BACKUP == "backup"
|
||||
assert TaskType.MIGRATION == "migration"
|
||||
assert TaskType.DOCUMENTATION == "documentation"
|
||||
assert TaskType.UNKNOWN == "unknown"
|
||||
|
||||
|
||||
class TestReportStatus:
|
||||
"""Tests for the ReportStatus enum."""
|
||||
|
||||
def test_enum_values(self):
|
||||
from src.models.report import ReportStatus
|
||||
assert ReportStatus.SUCCESS == "success"
|
||||
assert ReportStatus.FAILED == "failed"
|
||||
assert ReportStatus.IN_PROGRESS == "in_progress"
|
||||
assert ReportStatus.PARTIAL == "partial"
|
||||
|
||||
|
||||
class TestErrorContext:
|
||||
"""Tests for ErrorContext model."""
|
||||
|
||||
def test_valid_creation(self):
|
||||
from src.models.report import ErrorContext
|
||||
ctx = ErrorContext(message="Something failed", code="ERR_001", next_actions=["Retry"])
|
||||
assert ctx.message == "Something failed"
|
||||
assert ctx.code == "ERR_001"
|
||||
assert ctx.next_actions == ["Retry"]
|
||||
|
||||
def test_minimal_creation(self):
|
||||
from src.models.report import ErrorContext
|
||||
ctx = ErrorContext(message="Error occurred")
|
||||
assert ctx.code is None
|
||||
assert ctx.next_actions == []
|
||||
|
||||
|
||||
class TestTaskReport:
|
||||
"""Tests for TaskReport model and its validators."""
|
||||
|
||||
def _make_report(self, **overrides):
|
||||
from src.models.report import TaskReport, TaskType, ReportStatus
|
||||
defaults = {
|
||||
"report_id": "rpt-001",
|
||||
"task_id": "task-001",
|
||||
"task_type": TaskType.BACKUP,
|
||||
"status": ReportStatus.SUCCESS,
|
||||
"updated_at": datetime(2024, 1, 15, 12, 0, 0),
|
||||
"summary": "Backup completed",
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return TaskReport(**defaults)
|
||||
|
||||
def test_valid_creation(self):
|
||||
report = self._make_report()
|
||||
assert report.report_id == "rpt-001"
|
||||
assert report.task_id == "task-001"
|
||||
assert report.summary == "Backup completed"
|
||||
|
||||
def test_empty_report_id_raises(self):
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
self._make_report(report_id="")
|
||||
|
||||
def test_whitespace_report_id_raises(self):
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
self._make_report(report_id=" ")
|
||||
|
||||
def test_empty_task_id_raises(self):
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
self._make_report(task_id="")
|
||||
|
||||
def test_empty_summary_raises(self):
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
self._make_report(summary="")
|
||||
|
||||
def test_summary_whitespace_trimmed(self):
|
||||
report = self._make_report(summary=" Trimmed ")
|
||||
assert report.summary == "Trimmed"
|
||||
|
||||
def test_optional_fields(self):
|
||||
report = self._make_report()
|
||||
assert report.started_at is None
|
||||
assert report.details is None
|
||||
assert report.error_context is None
|
||||
assert report.source_ref is None
|
||||
|
||||
def test_with_error_context(self):
|
||||
from src.models.report import ErrorContext
|
||||
ctx = ErrorContext(message="Connection failed")
|
||||
report = self._make_report(error_context=ctx)
|
||||
assert report.error_context.message == "Connection failed"
|
||||
|
||||
|
||||
class TestReportQuery:
|
||||
"""Tests for ReportQuery model and its validators."""
|
||||
|
||||
def test_defaults(self):
|
||||
from src.models.report import ReportQuery
|
||||
q = ReportQuery()
|
||||
assert q.page == 1
|
||||
assert q.page_size == 20
|
||||
assert q.task_types == []
|
||||
assert q.statuses == []
|
||||
assert q.sort_by == "updated_at"
|
||||
assert q.sort_order == "desc"
|
||||
|
||||
def test_invalid_sort_by_raises(self):
|
||||
from src.models.report import ReportQuery
|
||||
with pytest.raises(ValueError, match="sort_by"):
|
||||
ReportQuery(sort_by="invalid_field")
|
||||
|
||||
def test_valid_sort_by_values(self):
|
||||
from src.models.report import ReportQuery
|
||||
for field in ["updated_at", "status", "task_type"]:
|
||||
q = ReportQuery(sort_by=field)
|
||||
assert q.sort_by == field
|
||||
|
||||
def test_invalid_sort_order_raises(self):
|
||||
from src.models.report import ReportQuery
|
||||
with pytest.raises(ValueError, match="sort_order"):
|
||||
ReportQuery(sort_order="invalid")
|
||||
|
||||
def test_valid_sort_order_values(self):
|
||||
from src.models.report import ReportQuery
|
||||
for order in ["asc", "desc"]:
|
||||
q = ReportQuery(sort_order=order)
|
||||
assert q.sort_order == order
|
||||
|
||||
def test_time_range_validation_valid(self):
|
||||
from src.models.report import ReportQuery
|
||||
now = datetime.utcnow()
|
||||
q = ReportQuery(time_from=now - timedelta(days=1), time_to=now)
|
||||
assert q.time_from < q.time_to
|
||||
|
||||
def test_time_range_validation_invalid(self):
|
||||
from src.models.report import ReportQuery
|
||||
now = datetime.utcnow()
|
||||
with pytest.raises(ValueError, match="time_from"):
|
||||
ReportQuery(time_from=now, time_to=now - timedelta(days=1))
|
||||
|
||||
def test_page_ge_1(self):
|
||||
from src.models.report import ReportQuery
|
||||
with pytest.raises(ValueError):
|
||||
ReportQuery(page=0)
|
||||
|
||||
def test_page_size_bounds(self):
|
||||
from src.models.report import ReportQuery
|
||||
with pytest.raises(ValueError):
|
||||
ReportQuery(page_size=0)
|
||||
with pytest.raises(ValueError):
|
||||
ReportQuery(page_size=101)
|
||||
|
||||
|
||||
class TestReportCollection:
|
||||
"""Tests for ReportCollection model."""
|
||||
|
||||
def test_valid_creation(self):
|
||||
from src.models.report import ReportCollection, ReportQuery
|
||||
col = ReportCollection(
|
||||
items=[],
|
||||
total=0,
|
||||
page=1,
|
||||
page_size=20,
|
||||
has_next=False,
|
||||
applied_filters=ReportQuery(),
|
||||
)
|
||||
assert col.total == 0
|
||||
assert col.has_next is False
|
||||
|
||||
def test_with_items(self):
|
||||
from src.models.report import ReportCollection, ReportQuery, TaskReport, TaskType, ReportStatus
|
||||
report = TaskReport(
|
||||
report_id="r1", task_id="t1", task_type=TaskType.BACKUP,
|
||||
status=ReportStatus.SUCCESS, updated_at=datetime.utcnow(),
|
||||
summary="OK"
|
||||
)
|
||||
col = ReportCollection(
|
||||
items=[report], total=1, page=1, page_size=20,
|
||||
has_next=False, applied_filters=ReportQuery()
|
||||
)
|
||||
assert len(col.items) == 1
|
||||
assert col.items[0].report_id == "r1"
|
||||
|
||||
|
||||
class TestReportDetailView:
|
||||
"""Tests for ReportDetailView model."""
|
||||
|
||||
def test_valid_creation(self):
|
||||
from src.models.report import ReportDetailView, TaskReport, TaskType, ReportStatus
|
||||
report = TaskReport(
|
||||
report_id="r1", task_id="t1", task_type=TaskType.BACKUP,
|
||||
status=ReportStatus.SUCCESS, updated_at=datetime.utcnow(),
|
||||
summary="Backup OK"
|
||||
)
|
||||
detail = ReportDetailView(report=report)
|
||||
assert detail.report.report_id == "r1"
|
||||
assert detail.timeline == []
|
||||
assert detail.diagnostics is None
|
||||
assert detail.next_actions == []
|
||||
|
||||
def test_with_all_fields(self):
|
||||
from src.models.report import ReportDetailView, TaskReport, TaskType, ReportStatus
|
||||
report = TaskReport(
|
||||
report_id="r1", task_id="t1", task_type=TaskType.MIGRATION,
|
||||
status=ReportStatus.FAILED, updated_at=datetime.utcnow(),
|
||||
summary="Migration failed"
|
||||
)
|
||||
detail = ReportDetailView(
|
||||
report=report,
|
||||
timeline=[{"event": "started", "at": "2024-01-01T00:00:00"}],
|
||||
diagnostics={"cause": "timeout"},
|
||||
next_actions=["Retry", "Check connection"],
|
||||
)
|
||||
assert len(detail.timeline) == 1
|
||||
assert detail.diagnostics["cause"] == "timeout"
|
||||
assert "Retry" in detail.next_actions
|
||||
|
||||
# [/DEF:test_report_models:Module]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user