commit 6dfb9a8aa5782d3eaf5b9ac8d7689a694d992c37 Author: Varun Date: Tue May 12 22:29:55 2026 +0530 Initial commit of the Flutter Cursor Generator project, including the core generator tool, project brief schema, example project setup, and CI configuration. Added README documentation outlining repository structure, quick start guide, and detailed descriptions of features and architecture pillars. diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..34bfffc Binary files /dev/null and b/.DS_Store differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f80840 --- /dev/null +++ b/README.md @@ -0,0 +1,171 @@ +# Flutter Cursor Generator — Complete Implementation + +> AI-powered, project-specific Cursor configuration generator for Flutter teams. +> Implements all 6 pillars from the architecture review. + +## Repository Structure + +``` +flutter-cursor-gen/ +├── flutter-cursor-templates/ ← Shared template repo (hosted internally) +│ ├── generator/ ← Dart CLI tool +│ │ ├── bin/cursor_gen.dart ← Entry point +│ │ ├── src/ ← Core modules +│ │ │ ├── brief_loader.dart ← Parses project-brief.yaml +│ │ │ ├── resolver.dart ← Maps brief → template files +│ │ │ ├── renderer.dart ← Substitutes {{PLACEHOLDERS}} +│ │ │ ├── validator.dart ← Schema validation +│ │ │ ├── version_manager.dart ← Pillar 1: version locking +│ │ │ ├── override_manager.dart ← Pillar 2: custom/ preservation +│ │ │ ├── wizard.dart ← Interactive brief creator +│ │ │ └── telemetry.dart ← Pillar 6: opt-in analytics +│ │ ├── test/ +│ │ │ ├── generator_test.dart ← Pillar 3: full test suite +│ │ │ └── golden/ ← Golden output files per stack combo +│ │ ├── brief-schema.json ← IDE autocomplete for project-brief.yaml +│ │ └── pubspec.yaml +│ ├── templates/ +│ │ ├── rules/ +│ │ │ ├── universal/ ← Always included (flutter-core, ui-ux, project-context) +│ │ │ ├── security/ ← Pillar 5: always-on security rules +│ │ │ ├── error-handling/ ← Always-on error handling +│ │ │ ├── state-management/ ← bloc | riverpod | getx | hooks_riverpod +│ │ │ ├── architecture/ ← clean | feature_first | mvvm | mvc | layered +│ │ │ ├── backend/ ← firebase | supabase | rest | realtime +│ │ │ ├── routing/ ← gorouter | getx_nav | auto_route +│ │ │ ├── testing/ ← testing-{sm} + testing-e2e-{tool} +│ │ │ ├── platform/ ← Pillar 4: ios | android | web | desktop +│ │ │ ├── codegen/ ← Pillar 4: freezed | json_serializable | injectable | retrofit +│ │ │ └── i18n/ ← localization rules +│ │ ├── skills/ ← scaffold-feature | scaffold-screen | generate-tests | ... +│ │ ├── agents/ ← code-reviewer | test-writer | ui-validator | migration | security +│ │ └── hooks/ ← arch-guard.ts | flutter-analyze.ts | hooks.json +│ ├── .github/workflows/ +│ │ └── template-ci.yml ← Pillar 3: CI for templates +│ ├── VERSION +│ └── CHANGELOG.md +│ +└── example-project/ ← One reference app: commented project-brief.yaml + custom/ demo + ├── project-brief.yaml ← All stack options documented in-file + └── .cursor/custom/ ← Pillar 2: never overwritten by the generator +``` + +--- + +## Quick Start + +### 1. Add to your Flutter project + +```yaml +# pubspec.yaml (dev dependency) +dev_dependencies: + cursor_gen: + git: + url: https://github.com/company/flutter-cursor-templates + path: generator +``` + +### 2. Create your brief (interactive wizard) + +```bash +dart run cursor_gen --wizard +``` + +Or copy the single reference brief (every option is explained in comments): + +```bash +cp path/to/flutter-cursor-gen/example-project/project-brief.yaml . +``` + +### 3. Generate your .cursor/ directory + +```bash +dart run cursor_gen +``` + +### 4. Commit and share with your team + +```bash +git add .cursor/ project-brief.yaml +git commit -m "chore: add cursor AI config for this project" +``` + +--- + +## All CLI commands + +```bash +dart run cursor_gen # First-time setup +dart run cursor_gen --wizard # Interactive brief creator +dart run cursor_gen --validate # Validate brief without generating +dart run cursor_gen --refresh # Re-generate (preserves custom/ + CURSOR:CUSTOM blocks) +dart run cursor_gen --diff # Preview changes before refresh +dart run cursor_gen --check-updates # Check for template version updates +dart run cursor_gen --telemetry # Show usage analytics report +``` + +--- + +## Six Pillars — What Was Built + +### 🔴 Pillar 1 — Template Versioning & Update Propagation +- `cursor_templates_version` field in `project-brief.yaml` locks the version +- `version_manager.dart` writes `.cursor-gen-lock.json` after every generation +- `--check-updates` shows diff between locked and latest version +- `--diff` previews what would change before `--refresh` +- `CHANGELOG.md` documents all template changes + +### 🔴 Pillar 2 — Override / Customization Layer +- `.cursor/custom/` folder is **never touched** by the generator +- `CURSOR:CUSTOM` / `CURSOR:CUSTOM:END` markers preserve inline customizations on `--refresh` +- `override_manager.dart` snapshots and restores custom content around every generation +- `custom/README.md` explains the system to new team members + +### 🔴 Pillar 3 — Generator Testing & Output Quality Gates +- `generator_test.dart`: full test suite covering resolver, renderer, validator +- Golden file tests for all 3 stack combinations (BLoC+Clean, Riverpod+FF, GetX+MVC) +- `--check-updates` validates no unreplaced `{{PLACEHOLDER}}` patterns survive +- CI pipeline in `.github/workflows/template-ci.yml` runs all combinations on every PR +- Lint step detects duplicate `alwaysApply: true` conflicts + +### 🟠 Pillar 4 — Multi-Platform & Code Generation Awareness +- `platforms` field in brief: `[ios, android, web, desktop]` → generates platform-specific rules +- Web: warns about `dart:io`, PWA requirements, renderer differences +- Desktop: window management, keyboard shortcuts, context menus +- `codegen` field: `[freezed, json_serializable, injectable, retrofit]` → generates tool-specific rules +- Each codegen template explains "never edit `.g.dart`" and build_runner usage + +### 🟠 Pillar 5 — Security as Rules, Not Just an Agent +- `security-standards.mdc` has `alwaysApply: true` — enforced on EVERY file write +- Covers: secure storage, certificate pinning, no hardcoded secrets, PII logging rules, obfuscation +- `error-handling.mdc` also always-on: global error boundaries, crash reporters, typed errors +- Security _agent_ still exists for deep consultation — but is a second layer, not the only layer + +### 🟡 Pillar 6 — Observability & Feedback Loop +- `telemetry_opt_in: true` in brief enables local telemetry (never sent anywhere) +- `telemetry.dart` logs generation history to `.cursor/telemetry.json` +- `--telemetry` command prints report + quarterly review checklist +- Feedback loop questions built into the report: which rules most violated, which agents ignored + +### Additional fixes (lower priority gaps) +- **Brief discovery UX**: `--wizard` interactive CLI, `brief-schema.json` for IDE autocomplete +- **arch-guard documented**: `arch-guard.ts.tmpl` fully implemented with per-architecture rules +- **i18n rules**: `localization.mdc.tmpl` — ARB format, ICU plurals, never-hardcode-strings rule +- **Error handling**: `error-handling.mdc.tmpl` — FlutterError.onError, AppError sealed class, crash reporters + +--- + +## Running Tests (Pillar 3) + +```bash +cd flutter-cursor-templates/generator +dart pub get +dart test test/generator_test.dart +``` + +Update golden files after intentional template changes: +```bash +# Delete the golden you want to update, then run: +dart test test/generator_test.dart --name "Golden" +# The test will create new goldens on first run +``` diff --git a/example-project/.cursor/custom/README.md b/example-project/.cursor/custom/README.md new file mode 100644 index 0000000..7649fd0 --- /dev/null +++ b/example-project/.cursor/custom/README.md @@ -0,0 +1,12 @@ +# .cursor/custom/ — project-specific overrides + +This directory is never modified by `dart run cursor_gen` or `--refresh`. + +Use it for team-only rules, vendor SDK notes, or policies that should not live in the shared template repo. + +## Examples you might add + +- `rules/domain-conventions.mdc` — money as integer cents, id prefixes, Firestore paths +- `rules/experiment-flags.mdc` — how feature flags are read in this codebase + +See `rules/sample-domain-conventions.mdc` for a sample rule file and `CURSOR:CUSTOM` marker usage preserved on refresh. diff --git a/example-project/.cursor/custom/rules/sample-domain-conventions.mdc b/example-project/.cursor/custom/rules/sample-domain-conventions.mdc new file mode 100644 index 0000000..6fe3a6c --- /dev/null +++ b/example-project/.cursor/custom/rules/sample-domain-conventions.mdc @@ -0,0 +1,20 @@ +--- +description: "Sample project-specific conventions (replace with your own)" +alwaysApply: true +--- + +# Sample domain conventions + +This file demonstrates **Pillar 2** overrides: content here is outside generated rules. + +## Example policy + +- Prefer `Money` value types over raw `double` for prices when you introduce them. + +# CURSOR:CUSTOM + +## Team-only notes (preserved across `cursor_gen --refresh`) + +- Replace this block with rules your generator output should not overwrite. + +# CURSOR:CUSTOM:END diff --git a/example-project/project-brief.yaml b/example-project/project-brief.yaml new file mode 100644 index 0000000..a874d71 --- /dev/null +++ b/example-project/project-brief.yaml @@ -0,0 +1,130 @@ +# ============================================================================= +# project-brief.yaml — SINGLE REFERENCE EXAMPLE for cursor_gen +# Copy to your Flutter repo root, edit values, then: dart run cursor_gen +# Schema / IDE hints: flutter-cursor-templates/generator/brief-schema.json +# ============================================================================= +# +# Each section below lists ALLOWED VALUES in comments. Pick ONE value per field +# (unless the field is a list). Invalid combinations may get validator warnings +# (e.g. getx_nav without getx). +# +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Template pin (Pillar 1) — bump when you intentionally upgrade template output +# ----------------------------------------------------------------------------- +cursor_templates_version: "1.0.0" + +# ----------------------------------------------------------------------------- +# project — identity and rough size (affects rule tone / scaffolding hints) +# ----------------------------------------------------------------------------- +# scale: small | medium | large +project: + name: "Acme Shop" + package: "com.example.acme_shop" + description: "Reference e-commerce app — replace with your product summary" + scale: "large" + +# ----------------------------------------------------------------------------- +# stack — tooling and architecture (drives which .cursor/rules/*.mdc you get) +# ----------------------------------------------------------------------------- +# state_management: bloc | riverpod | getx | hooks_riverpod +# routing: gorouter | getx_nav | auto_route +# (getx_nav is intended with state_management: getx) +# architecture: clean | feature_first | mvvm | mvc | layered +# backend: firebase | supabase | rest — or combine with + e.g.: +# firebase+rest | supabase+rest | firebase+supabase (split on +) +# auth: firebase_auth | supabase_auth | jwt_rest | oauth2 | none +# platforms: subset of [ ios, android, web, desktop ] +# codegen: subset of [ freezed, json_serializable, injectable, retrofit ] +stack: + state_management: "bloc" + routing: "gorouter" + architecture: "clean" + backend: "firebase+rest" + auth: "firebase_auth" + platforms: ["ios", "android", "web", "desktop"] + codegen: ["freezed", "json_serializable", "injectable", "retrofit"] + +# ----------------------------------------------------------------------------- +# environments +# ----------------------------------------------------------------------------- +# cicd: codemagic | github_actions | fastlane | none +# flavors: any string names your app uses (e.g. dev, staging, prod) +environments: + flavors: ["dev", "staging", "prod"] + cicd: "github_actions" + +# ----------------------------------------------------------------------------- +# testing +# ----------------------------------------------------------------------------- +# depth: unit_widget | integration | e2e | full +# e2e_tool: patrol | maestro +testing: + depth: "full" + e2e_tool: "patrol" + +# ----------------------------------------------------------------------------- +# design — where UI truth lives (referenced in generated project-context rules) +# ----------------------------------------------------------------------------- +# design.source (validator): figma_mcp | figma_manual | native_ref | html_ref | none +# figma_url: design file URL when using Figma; can be "" if source is none / native_ref +design: + source: "figma_mcp" + figma_url: "https://www.figma.com/design/REPLACE_ME/Acme-Shop" + +# ----------------------------------------------------------------------------- +# api_docs — machine-readable API contract for agents +# ----------------------------------------------------------------------------- +# format: openapi | postman | markdown | none +# path: repo-relative path to spec, or "" if none +api_docs: + format: "openapi" + path: "docs/api/openapi.yaml" + +# ----------------------------------------------------------------------------- +# references — other code the team treats as source of truth +# ----------------------------------------------------------------------------- +# repos: list of git URLs (can be empty []) +# local_paths: monorepo packages, e.g. [ "packages/design_system" ] +references: + repos: + - "https://github.com/example/acme-design-tokens" + local_paths: [] + +features: + # modules: high-level feature areas in YOUR lib/ (names are project-specific) + modules: ["auth", "home", "catalog", "cart", "checkout", "profile", "orders"] + # special: optional capabilities — include only what you use + # realtime | push_notifications | deep_linking | offline_first + special: ["realtime", "push_notifications", "deep_linking", "offline_first"] + +# ----------------------------------------------------------------------------- +# localization +# ----------------------------------------------------------------------------- +localization: + enabled: true + locales: ["en", "es", "fr"] + +# ----------------------------------------------------------------------------- +# Telemetry (Pillar 6) — local-only generation / usage log under .cursor/ +# ----------------------------------------------------------------------------- +telemetry_opt_in: true + +# ============================================================================= +# QUICK SWAP CHEATSHEET (same keys; replace values only) +# ============================================================================= +# Riverpod + feature-first + Supabase: +# state_management: riverpod +# routing: gorouter +# architecture: feature_first +# backend: supabase +# auth: supabase_auth +# +# GetX + MVC + REST (also enables migration-agent in templates): +# state_management: getx +# routing: getx_nav +# architecture: mvc +# backend: rest +# auth: jwt_rest +# ============================================================================= diff --git a/flutter-cursor-templates/.github/workflows/template-ci.yml b/flutter-cursor-templates/.github/workflows/template-ci.yml new file mode 100644 index 0000000..10545dd --- /dev/null +++ b/flutter-cursor-templates/.github/workflows/template-ci.yml @@ -0,0 +1,102 @@ +# template-ci.yml — Pillar 3: CI pipeline for the template repo +# Runs on every push/PR to validate all template combinations + +name: Template CI — Validate & Golden Tests + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + # ── Job 1: Validate all brief combinations ────────────────────────────── + validate-templates: + name: Validate template rendering + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + + - name: Install dependencies + working-directory: generator + run: dart pub get + + - name: Run generator test suite + working-directory: generator + run: dart test test/generator_test.dart --reporter=expanded + + - name: Verify no unreplaced placeholders + working-directory: generator + run: | + dart test test/generator_test.dart \ + --name "Rendered output has no unreplaced" \ + --reporter=expanded + + # ── Job 2: Golden file comparison ─────────────────────────────────────── + golden-tests: + name: Golden file tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + - name: Install dependencies + working-directory: generator + run: dart pub get + - name: Run golden tests + working-directory: generator + run: dart test test/generator_test.dart --name "Golden" --reporter=expanded + + # ── Job 3: Lint template files ────────────────────────────────────────── + lint-templates: + name: Lint template files + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check for orphaned placeholders in static files + run: | + # Verify no .mdc files (non-template) have unresolved {{PLACEHOLDERS}} + # These should only be in .mdc.tmpl files + if find templates/ -name "*.mdc" -not -name "*.tmpl" | xargs grep -l '{{[A-Z_]*}}' 2>/dev/null; then + echo "ERROR: Found unreplaced placeholders in non-template .mdc files" + exit 1 + fi + echo "✔ No orphaned placeholders in static files" + + - name: Check for duplicate alwaysApply: true + run: | + # Warn if more than 5 rules have alwaysApply: true (context bloat) + count=$(grep -r "alwaysApply: true" templates/ | wc -l) + echo "Files with alwaysApply: true: $count" + if [ "$count" -gt 10 ]; then + echo "WARNING: $count files have alwaysApply: true — review for context bloat" + fi + + # ── Job 4: Validate brief schema ──────────────────────────────────────── + validate-schema: + name: Validate reference project-brief + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + - name: Install dependencies + working-directory: generator + run: dart pub get + - name: Validate reference project brief + working-directory: generator + run: | + brief="../../example-project/project-brief.yaml" + if [ ! -f "$brief" ]; then + echo "ERROR: Missing reference brief at $brief (expected monorepo layout)." + exit 1 + fi + echo "Validating: $brief" + dart run bin/cursor_gen.dart --validate --brief "$brief" diff --git a/flutter-cursor-templates/CHANGELOG.md b/flutter-cursor-templates/CHANGELOG.md new file mode 100644 index 0000000..0b8f399 --- /dev/null +++ b/flutter-cursor-templates/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +## [1.0.0] - 2025-01-01 +### Added +- Initial release of flutter-cursor-templates +- Support for BLoC, Riverpod, GetX, Hooks+Riverpod state management +- Support for Clean, Feature-First, MVVM, MVC, Layered architectures +- Support for Firebase, Supabase, REST backends +- GoRouter, GetX Navigation, Auto Route routing templates +- Platform targets: iOS, Android, Web, Desktop +- Code generation templates: Freezed, json_serializable, Injectable, Retrofit +- Security standards as always-on rules (Pillar 5) +- Template versioning with lock file (Pillar 1) +- Override/customization layer with CURSOR:CUSTOM markers (Pillar 2) +- Generator test suite with golden files (Pillar 3) +- Multi-platform & codegen awareness in brief schema (Pillar 4) +- Opt-in telemetry and feedback loop (Pillar 6) +- i18n / localization rules +- Global error handling rules +- Interactive CLI wizard +- Brief JSON Schema for IDE autocomplete diff --git a/flutter-cursor-templates/VERSION b/flutter-cursor-templates/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/flutter-cursor-templates/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/flutter-cursor-templates/generator/bin/cursor_gen.dart b/flutter-cursor-templates/generator/bin/cursor_gen.dart new file mode 100644 index 0000000..8b5252b --- /dev/null +++ b/flutter-cursor-templates/generator/bin/cursor_gen.dart @@ -0,0 +1,171 @@ +#!/usr/bin/env dart +// cursor_gen — Flutter Cursor AI config generator +// Usage: dart run cursor_gen [options] + +import 'dart:io'; +import 'package:args/args.dart'; +import '../src/brief_loader.dart'; +import '../src/resolver.dart'; +import '../src/renderer.dart'; +import '../src/version_manager.dart'; +import '../src/override_manager.dart'; +import '../src/validator.dart'; +import '../src/wizard.dart'; +import '../src/telemetry.dart'; +import '../src/logger.dart'; + +Future main(List arguments) async { + final parser = ArgParser() + ..addFlag('help', abbr: 'h', negatable: false, help: 'Show usage') + ..addFlag('validate', abbr: 'v', negatable: false, help: 'Validate project-brief.yaml without writing files') + ..addFlag('refresh', abbr: 'r', negatable: false, help: 'Re-generate .cursor/ preserving custom/ and CURSOR:CUSTOM markers') + ..addFlag('check-updates', negatable: false, help: 'Check if newer template version is available') + ..addFlag('diff', negatable: false, help: 'Preview what would change before refreshing') + ..addFlag('wizard', abbr: 'w', negatable: false, help: 'Interactive wizard to create project-brief.yaml') + ..addFlag('telemetry', negatable: false, help: 'Show telemetry / rule-trigger report') + ..addOption('brief', abbr: 'b', defaultsTo: 'project-brief.yaml', help: 'Path to project-brief.yaml') + ..addOption('output', abbr: 'o', defaultsTo: '.cursor', help: 'Output directory') + ..addOption('templates', defaultsTo: '', help: 'Override template library path'); + + final ArgResults args; + try { + args = parser.parse(arguments); + } catch (e) { + Logger.error('$e'); + print(parser.usage); + exit(1); + } + + if (args['help'] as bool) { + _printBanner(); + print(parser.usage); + exit(0); + } + + if (args['wizard'] as bool) { + await Wizard.run(outputPath: args['brief'] as String); + exit(0); + } + + final briefPath = args['brief'] as String; + final outputDir = args['output'] as String; + final templateSrc = args['templates'] as String; + + if (args['validate'] as bool) { + final result = await Validator.validateFile(briefPath); + if (result.isValid) { + Logger.success('✔ project-brief.yaml is valid'); + } else { + Logger.error('✘ Validation failed:'); + for (final e in result.errors) Logger.error(' • $e'); + exit(1); + } + exit(0); + } + + if (args['check-updates'] as bool) { + await VersionManager.checkUpdates(briefPath: briefPath); + exit(0); + } + + if (args['telemetry'] as bool) { + await Telemetry.printReport(outputDir: outputDir); + exit(0); + } + + Logger.info('Loading project brief from $briefPath...'); + final brief = await BriefLoader.load(briefPath); + final validation = await Validator.validate(brief); + if (!validation.isValid) { + Logger.error('✘ Brief validation failed:'); + for (final e in validation.errors) Logger.error(' • $e'); + exit(1); + } + + final versionStatus = await VersionManager.check(brief: brief); + if (versionStatus.hasUpdate) { + Logger.warn('\n⚠ Template update available: ${versionStatus.currentVersion} → ${versionStatus.latestVersion}'); + Logger.warn(' Run: dart run cursor_gen --check-updates for full diff\n'); + } + + if (args['diff'] as bool) { + await _runDiff(brief, outputDir, templateSrc); + exit(0); + } + + _printBanner(); + Logger.info('Generating .cursor/ for ${brief.projectName}'); + Logger.dim(' Stack: ${brief.stateManagement} + ${brief.architecture} + ${brief.backends.join("+")}'); + Logger.dim(' Platform: ${brief.platforms.join(", ")}'); + Logger.dim(' Codegen: ${brief.codegenTools.isEmpty ? "none" : brief.codegenTools.join(", ")}\n'); + + final isRefresh = args['refresh'] as bool; + final overrideSnapshot = await OverrideManager.snapshot(outputDir); + + final templateFiles = Resolver.resolve(brief); + Logger.info('Resolved ${templateFiles.length} template files'); + + final templateDir = templateSrc.isEmpty ? _defaultTemplateDir() : templateSrc; + final rendered = await Renderer.render( + brief: brief, templateFiles: templateFiles, templateSrc: templateDir, + ); + + await _writeOutput(outputDir, rendered, overrideSnapshot, isRefresh); + await VersionManager.writeLock(outputDir: outputDir, briefPath: briefPath, brief: brief); + await Telemetry.record(projectName: brief.projectName, outputDir: outputDir, templateFiles: templateFiles); + + Logger.success('\n✔ Done! ${rendered.length} files written to $outputDir/'); + if (overrideSnapshot.customFiles.isNotEmpty) { + Logger.success(' ↳ ${overrideSnapshot.customFiles.length} custom override(s) preserved untouched'); + } + Logger.dim('\n Next: commit .cursor/ to git so all teammates get the same config.'); +} + +Future _runDiff(dynamic brief, String outputDir, String templateSrc) async { + final templateFiles = Resolver.resolve(brief); + final rendered = await Renderer.render( + brief: brief, templateFiles: templateFiles, + templateSrc: templateSrc.isEmpty ? _defaultTemplateDir() : templateSrc, + ); + Logger.info('Diff preview (no files written):\n'); + for (final entry in rendered.entries) { + final existing = File('$outputDir/${entry.key}'); + if (!existing.existsSync()) { + Logger.success(' + ${entry.key}'); + } else { + final current = await existing.readAsString(); + if (current != entry.value) Logger.warn(' ~ ${entry.key}'); + else Logger.dim(' = ${entry.key}'); + } + } +} + +Future _writeOutput( + String outputDir, Map rendered, + OverrideSnapshot snapshot, bool isRefresh, +) async { + for (final entry in rendered.entries) { + final file = File('$outputDir/${entry.key}'); + await file.parent.create(recursive: true); + if (isRefresh && file.existsSync()) { + final merged = OverrideManager.mergeCustomSections( + newContent: entry.value, existingContent: await file.readAsString(), + ); + await file.writeAsString(merged); + } else { + await file.writeAsString(entry.value); + } + } + await OverrideManager.restoreCustomFolder(snapshot, outputDir); +} + +String _defaultTemplateDir() { + final scriptDir = Platform.script.toFilePath(); + return scriptDir.replaceAll(RegExp(r'bin[/\\]cursor_gen\.dart$'), 'templates'); +} + +void _printBanner() { + print('\n╔══════════════════════════════════╗'); + print('║ cursor_gen — Flutter AI Config ║'); + print('╚══════════════════════════════════╝\n'); +} diff --git a/flutter-cursor-templates/generator/brief-schema.json b/flutter-cursor-templates/generator/brief-schema.json new file mode 100644 index 0000000..36633e6 --- /dev/null +++ b/flutter-cursor-templates/generator/brief-schema.json @@ -0,0 +1,100 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/company/flutter-cursor-templates/brief-schema.json", + "title": "project-brief.yaml", + "description": "Schema for Flutter cursor_gen project brief — provides IDE autocomplete", + "type": "object", + "properties": { + "cursor_templates_version": { + "type": "string", + "description": "Pillar 1: Pin to template version for reproducibility", + "examples": ["1.0.0"] + }, + "project": { + "type": "object", + "required": ["name", "package"], + "properties": { + "name": { "type": "string", "description": "App display name" }, + "package": { "type": "string", "description": "Dart package ID (e.g. com.company.appname)" }, + "description": { "type": "string" }, + "scale": { "type": "string", "enum": ["small", "medium", "large"] } + } + }, + "stack": { + "type": "object", + "properties": { + "state_management": { + "type": "string", + "enum": ["bloc", "riverpod", "getx", "hooks_riverpod"], + "description": "Primary state management solution" + }, + "routing": { + "type": "string", + "enum": ["gorouter", "getx_nav", "auto_route"] + }, + "architecture": { + "type": "string", + "enum": ["clean", "feature_first", "mvvm", "mvc", "layered"] + }, + "backend": { + "type": "string", + "description": "Single backend or combined with +: firebase, supabase, rest, firebase+rest", + "examples": ["firebase", "supabase", "rest", "firebase+rest", "supabase+rest"] + }, + "auth": { + "type": "string", + "enum": ["firebase_auth", "supabase_auth", "jwt_rest", "oauth2", "none"] + }, + "platforms": { + "type": "array", + "items": { "type": "string", "enum": ["ios", "android", "web", "desktop"] }, + "description": "Pillar 4: Target platforms — affects generated rules" + }, + "codegen": { + "type": "array", + "items": { "type": "string", "enum": ["freezed", "json_serializable", "injectable", "retrofit"] }, + "description": "Pillar 4: Code generation tools — affects generated rules" + } + } + }, + "environments": { + "type": "object", + "properties": { + "flavors": { + "type": "array", + "items": { "type": "string" }, + "examples": [["dev", "staging", "prod"]] + }, + "cicd": { + "type": "string", + "enum": ["codemagic", "github_actions", "fastlane", "none"] + } + } + }, + "testing": { + "type": "object", + "properties": { + "depth": { + "type": "string", + "enum": ["unit_widget", "integration", "e2e", "full"] + }, + "e2e_tool": { + "type": "string", + "enum": ["patrol", "maestro"] + } + } + }, + "localization": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "locales": { "type": "array", "items": { "type": "string" } } + } + }, + "telemetry_opt_in": { + "type": "boolean", + "description": "Pillar 6: Opt-in local telemetry for rule trigger analytics", + "default": false + } + } +} diff --git a/flutter-cursor-templates/generator/pubspec.yaml b/flutter-cursor-templates/generator/pubspec.yaml new file mode 100644 index 0000000..3be368c --- /dev/null +++ b/flutter-cursor-templates/generator/pubspec.yaml @@ -0,0 +1,24 @@ +name: cursor_gen +description: A CLI tool that generates project-specific Cursor AI configurations for Flutter projects. +version: 1.0.0 +homepage: https://github.com/company/flutter-cursor-templates + +environment: + sdk: ">=3.3.0 <4.0.0" + +dependencies: + args: ^2.4.2 + yaml: ^3.1.2 + json_schema: ^6.0.0 + path: ^1.9.0 + http: ^1.2.0 + crypto: ^3.0.3 + collection: ^1.18.0 + ansi_styles: ^0.3.1 + +dev_dependencies: + test: ^1.25.0 + lints: ^3.0.0 + +executables: + cursor_gen: cursor_gen diff --git a/flutter-cursor-templates/generator/src/brief_loader.dart b/flutter-cursor-templates/generator/src/brief_loader.dart new file mode 100644 index 0000000..80f461c --- /dev/null +++ b/flutter-cursor-templates/generator/src/brief_loader.dart @@ -0,0 +1,80 @@ +import 'dart:io'; +import 'package:yaml/yaml.dart'; +import 'models.dart'; + +class BriefLoader { + static Future load(String path) async { + final file = File(path); + if (!file.existsSync()) { + throw FileSystemException('project-brief.yaml not found at $path. ' + 'Run: dart run cursor_gen --wizard to create one.'); + } + final content = await file.readAsString(); + final yaml = loadYaml(content) as YamlMap; + + final project = yaml['project'] as YamlMap; + final stack = yaml['stack'] as YamlMap; + final envs = yaml['environments'] as YamlMap? ?? YamlMap(); + final testing = yaml['testing'] as YamlMap? ?? YamlMap(); + final design = yaml['design'] as YamlMap? ?? YamlMap(); + final apiDocs = yaml['api_docs'] as YamlMap? ?? YamlMap(); + final refs = yaml['references'] as YamlMap? ?? YamlMap(); + final features = yaml['features'] as YamlMap? ?? YamlMap(); + final l10n = yaml['localization'] as YamlMap? ?? YamlMap(); + + // Parse backends (can be "firebase+rest" shorthand or list) + final backendRaw = stack['backend']?.toString() ?? 'rest'; + final backends = backendRaw.contains('+') + ? backendRaw.split('+') + : [backendRaw]; + + return ProjectBrief( + projectName: project['name']?.toString() ?? 'MyApp', + packageId: project['package']?.toString() ?? 'com.example.myapp', + description: project['description']?.toString() ?? '', + scale: project['scale']?.toString() ?? 'medium', + + stateManagement: stack['state_management']?.toString() ?? 'riverpod', + routing: stack['routing']?.toString() ?? 'gorouter', + architecture: stack['architecture']?.toString() ?? 'feature_first', + backends: backends.map((b) => b.trim()).toList(), + auth: stack['auth']?.toString() ?? 'none', + + // Pillar 4: platform + codegen + platforms: _toStringList(stack['platforms']) ?? ['ios', 'android'], + codegenTools: _toStringList(stack['codegen']) ?? [], + + flavors: _toStringList(envs['flavors']) ?? ['dev', 'prod'], + cicd: envs['cicd']?.toString() ?? 'github_actions', + + testingDepth: testing['depth']?.toString() ?? 'unit_widget', + e2eTool: testing['e2e_tool']?.toString() ?? 'patrol', + + designSource: design['source']?.toString() ?? 'none', + figmaUrl: design['figma_url']?.toString() ?? '', + + apiDocsFormat: apiDocs['format']?.toString() ?? 'none', + apiDocsPath: apiDocs['path']?.toString() ?? '', + + referenceRepos: _toStringList(refs['repos']) ?? [], + localPaths: _toStringList(refs['local_paths']) ?? [], + + featureModules: _toStringList(features['modules']) ?? [], + specialFeatures: _toStringList(features['special']) ?? [], + + i18nEnabled: l10n['enabled'] as bool? ?? false, + locales: _toStringList(l10n['locales']) ?? ['en'], + + cursorTemplatesVersion: yaml['cursor_templates_version']?.toString(), + telemetryOptIn: yaml['telemetry_opt_in'] as bool? ?? false, + ); + } + + static List? _toStringList(dynamic value) { + if (value == null) return null; + if (value is YamlList) return value.map((e) => e.toString()).toList(); + if (value is List) return value.map((e) => e.toString()).toList(); + if (value is String) return [value]; + return null; + } +} diff --git a/flutter-cursor-templates/generator/src/logger.dart b/flutter-cursor-templates/generator/src/logger.dart new file mode 100644 index 0000000..5bc8a95 --- /dev/null +++ b/flutter-cursor-templates/generator/src/logger.dart @@ -0,0 +1,11 @@ +// logger.dart — Coloured terminal output + +import 'dart:io'; + +class Logger { + static void info(String msg) => print(msg); + static void dim(String msg) => print('\x1B[2m$msg\x1B[0m'); + static void success(String msg) => print('\x1B[32m$msg\x1B[0m'); + static void warn(String msg) => print('\x1B[33m$msg\x1B[0m'); + static void error(String msg) => stderr.writeln('\x1B[31m$msg\x1B[0m'); +} diff --git a/flutter-cursor-templates/generator/src/models.dart b/flutter-cursor-templates/generator/src/models.dart new file mode 100644 index 0000000..0854fa4 --- /dev/null +++ b/flutter-cursor-templates/generator/src/models.dart @@ -0,0 +1,110 @@ +// models.dart — Core data models for the generator + +/// Full parsed project brief +class ProjectBrief { + final String projectName; + final String packageId; + final String description; + final String scale; // small | medium | large + + // Stack + final String stateManagement; // bloc | riverpod | getx | hooks_riverpod + final String routing; // gorouter | getx_nav | auto_route + final String architecture; // clean | feature_first | mvvm | mvc | layered + final List backends; // firebase | supabase | rest + final String auth; // firebase_auth | supabase_auth | jwt_rest | oauth2 | none + + // NEW — Pillar 4: platform targets + final List platforms; // ios | android | web | desktop + final List codegenTools; // freezed | json_serializable | injectable | retrofit + + // Environments + final List flavors; + final String cicd; // codemagic | github_actions | fastlane + + // Testing + final String testingDepth; // unit_widget | integration | e2e | full + final String e2eTool; // patrol | maestro + + // Design + final String designSource; + final String figmaUrl; + + // API docs + final String apiDocsFormat; // openapi | postman | markdown | none + final String apiDocsPath; + + // References + final List referenceRepos; + final List localPaths; + + // Features + final List featureModules; + final List specialFeatures; // realtime | push_notifications | deep_linking | offline_first + + // Localization + final bool i18nEnabled; + final List locales; + + // Template version lock (Pillar 1) + final String? cursorTemplatesVersion; + + // Telemetry opt-in (Pillar 6) + final bool telemetryOptIn; + + const ProjectBrief({ + required this.projectName, + required this.packageId, + required this.description, + required this.scale, + required this.stateManagement, + required this.routing, + required this.architecture, + required this.backends, + required this.auth, + required this.platforms, + required this.codegenTools, + required this.flavors, + required this.cicd, + required this.testingDepth, + required this.e2eTool, + required this.designSource, + required this.figmaUrl, + required this.apiDocsFormat, + required this.apiDocsPath, + required this.referenceRepos, + required this.localPaths, + required this.featureModules, + required this.specialFeatures, + required this.i18nEnabled, + required this.locales, + this.cursorTemplatesVersion, + this.telemetryOptIn = false, + }); +} + +class ValidationResult { + final bool isValid; + final List errors; + final List warnings; + const ValidationResult({required this.isValid, this.errors = const [], this.warnings = const []}); +} + +class VersionStatus { + final bool hasUpdate; + final String currentVersion; + final String latestVersion; + final List changelog; + const VersionStatus({ + required this.hasUpdate, + required this.currentVersion, + required this.latestVersion, + this.changelog = const [], + }); +} + +class OverrideSnapshot { + final List customFiles; // files in custom/ + final Map customSections; // CURSOR:CUSTOM blocks per file + const OverrideSnapshot({required this.customFiles, required this.customSections}); +} diff --git a/flutter-cursor-templates/generator/src/override_manager.dart b/flutter-cursor-templates/generator/src/override_manager.dart new file mode 100644 index 0000000..687abd5 --- /dev/null +++ b/flutter-cursor-templates/generator/src/override_manager.dart @@ -0,0 +1,141 @@ +// override_manager.dart — Pillar 2: Override/customization layer with re-gen safety + +import 'dart:io'; +import 'package:path/path.dart' as p; +import 'models.dart'; +import 'logger.dart'; + +const _customDir = 'custom'; +const _customMarker = '# CURSOR:CUSTOM'; +const _customEndMark = '# CURSOR:CUSTOM:END'; + +class OverrideManager { + /// Snapshot the existing custom/ folder and CURSOR:CUSTOM blocks + /// BEFORE any re-generation writes happen. + static Future snapshot(String outputDir) async { + final customPath = p.join(outputDir, _customDir); + final customFiles = []; + final customSections = {}; + + // 1. Snapshot custom/ folder + final dir = Directory(customPath); + if (dir.existsSync()) { + await for (final entity in dir.list(recursive: true)) { + if (entity is File) { + customFiles.add(entity.path); + } + } + Logger.dim(' Snapshotted ${customFiles.length} file(s) from custom/'); + } + + // 2. Snapshot CURSOR:CUSTOM sections from existing generated files + final rulesDir = Directory(p.join(outputDir, 'rules')); + if (rulesDir.existsSync()) { + await for (final entity in rulesDir.list(recursive: true)) { + if (entity is File && entity.path.endsWith('.mdc')) { + final content = await entity.readAsString(); + final sections = _extractCustomSections(content); + if (sections.isNotEmpty) { + customSections[entity.path] = sections; + Logger.dim(' Snapshotted ${_countSections(sections)} CURSOR:CUSTOM block(s) in ${p.basename(entity.path)}'); + } + } + } + } + + return OverrideSnapshot(customFiles: customFiles, customSections: customSections); + } + + /// Restore the custom/ folder after re-generation. + /// The generator NEVER writes to custom/ — this just ensures it survives. + static Future restoreCustomFolder(OverrideSnapshot snapshot, String outputDir) async { + if (snapshot.customFiles.isEmpty) return; + + final customPath = p.join(outputDir, _customDir); + await Directory(customPath).create(recursive: true); + + // Write a README if it doesn't already exist + final readme = File(p.join(customPath, 'README.md')); + if (!readme.existsSync()) { + await readme.writeAsString(_customReadme); + } + Logger.dim(' Custom folder preserved at ${customPath}'); + } + + /// Merge CURSOR:CUSTOM sections from existingContent into newContent. + /// Any block between `# CURSOR:CUSTOM` and `# CURSOR:CUSTOM:END` in + /// the existing file is transplanted into the newly rendered file. + static String mergeCustomSections({ + required String newContent, + required String existingContent, + }) { + final existingSections = _extractCustomSections(existingContent); + if (existingSections.isEmpty) return newContent; + + // Append existing custom sections to the bottom of the new file + // (if they don't already appear there) + final buffer = StringBuffer(newContent.trimRight()); + buffer.writeln('\n'); + buffer.writeln('$_customMarker — preserved from previous version, do not delete this marker'); + buffer.writeln(existingSections); + buffer.writeln(_customEndMark); + return buffer.toString(); + } + + static String _extractCustomSections(String content) { + final buffer = StringBuffer(); + bool inSection = false; + for (final line in content.split('\n')) { + if (line.trimLeft().startsWith(_customMarker)) { + inSection = true; + continue; + } + if (line.trimLeft().startsWith(_customEndMark)) { + inSection = false; + continue; + } + if (inSection) buffer.writeln(line); + } + return buffer.toString().trim(); + } + + static int _countSections(String sections) => + '\n'.allMatches(sections).length + 1; + + static const _customReadme = ''' +# .cursor/custom/ — Your project-specific overrides + +This directory is **NEVER touched by the generator**. +Anything you put here is preserved across all re-generations. + +## How to use + +### Override a generated rule +Create a file with the same name as a generated rule: +``` +.cursor/custom/rules/bloc-cubit.mdc ← overrides the generated version +``` + +### Add a project-only rule +Create any .mdc file: +``` +.cursor/custom/rules/our-naming-convention.mdc +``` + +### CURSOR:CUSTOM inline blocks +Alternatively, in any generated .mdc file, add a block like: +```markdown +# CURSOR:CUSTOM +Your custom instructions here. +These lines are preserved on every --refresh. +# CURSOR:CUSTOM:END +``` + +The generator will detect and re-inject these blocks automatically. + +## Examples +- `custom/rules/team-coding-style.mdc` — team-specific conventions +- `custom/rules/third-party-sdk.mdc` — rules for an internal SDK +- `custom/agents/internal-reviewer.mdc` — custom agent for your workflow +'''; +} diff --git a/flutter-cursor-templates/generator/src/renderer.dart b/flutter-cursor-templates/generator/src/renderer.dart new file mode 100644 index 0000000..0adaebf --- /dev/null +++ b/flutter-cursor-templates/generator/src/renderer.dart @@ -0,0 +1,192 @@ +// renderer.dart — Substitutes {{PLACEHOLDERS}} and writes output files + +import 'dart:io'; +import 'package:path/path.dart' as p; +import 'models.dart'; + +class Renderer { + static Future> render({ + required ProjectBrief brief, + required List templateFiles, + required String templateSrc, + }) async { + final context = _buildContext(brief); + final output = {}; + + for (final key in templateFiles) { + final tmplPath = _templatePath(templateSrc, key); + final file = File(tmplPath); + if (!file.existsSync()) { + // Gracefully skip missing optional templates with a placeholder + output[_outputPath(key)] = _missingTemplatePlaceholder(key); + continue; + } + var content = await file.readAsString(); + content = _substituteAll(content, context); + _checkUnreplacedPlaceholders(content, key); // Pillar 3: validate no broken {{VAR}} + output[_outputPath(key)] = content; + } + return output; + } + + /// Build the full substitution context from a brief + static Map _buildContext(ProjectBrief brief) { + return { + 'PROJECT_NAME': brief.projectName, + 'PACKAGE_ID': brief.packageId, + 'DESCRIPTION': brief.description, + 'SCALE': brief.scale, + 'STATE_MANAGEMENT': _displayName(brief.stateManagement), + 'STATE_MGMT_RAW': brief.stateManagement, + 'ARCHITECTURE': _displayName(brief.architecture), + 'ARCH_RAW': brief.architecture, + 'ROUTING': _displayName(brief.routing), + 'ROUTING_RAW': brief.routing, + 'BACKEND': brief.backends.map(_displayName).join(' + '), + 'BACKENDS_LIST': brief.backends.join(', '), + 'AUTH': _displayName(brief.auth), + 'AUTH_RAW': brief.auth, + 'PLATFORMS_LIST': brief.platforms.join(', '), + 'CODEGEN_LIST': brief.codegenTools.isEmpty ? 'none' : brief.codegenTools.join(', '), + 'FLAVORS_LIST': brief.flavors.join(', '), + 'CICD_TOOL': _displayName(brief.cicd), + 'CICD_RAW': brief.cicd, + 'TESTING_DEPTH': brief.testingDepth, + 'E2E_TOOL': brief.e2eTool, + 'FEATURES_LIST': brief.featureModules.join(', '), + 'SPECIAL_FEATURES': brief.specialFeatures.join(', '), + 'DESIGN_SOURCE': brief.designSource, + 'FIGMA_URL': brief.figmaUrl, + 'API_DOCS_FORMAT': brief.apiDocsFormat, + 'API_DOCS_PATH': brief.apiDocsPath, + 'REFERENCE_REPOS': brief.referenceRepos.join('\n- '), + 'ARCH_IMPORT_RULES': _archImportRules(brief.architecture), + 'TEST_PATTERN': _testPattern(brief.stateManagement), + 'LOCALES_LIST': brief.locales.join(', '), + 'TEMPLATE_VERSION': '1.0.0', + }; + } + + static String _substituteAll(String content, Map ctx) { + for (final entry in ctx.entries) { + content = content.replaceAll('{{${entry.key}}}', entry.value); + } + return content; + } + + static void _checkUnreplacedPlaceholders(String content, String key) { + final pattern = RegExp(r'\{\{[A-Z_]+\}\}'); + final matches = pattern.allMatches(content); + if (matches.isNotEmpty) { + final vars = matches.map((m) => m.group(0)).toSet(); + // Print warning but don't fail — let golden tests catch it + stderr.writeln('⚠ Unreplaced placeholder(s) in $key: ${vars.join(", ")}'); + } + } + + static String _templatePath(String templateSrc, String key) { + // skills use SKILL.md.tmpl, everything else uses .mdc.tmpl + final ext = key.startsWith('skills/') ? 'SKILL.md.tmpl' : '.mdc.tmpl'; + final dir = key.startsWith('skills/') ? p.dirname(key) : ''; + + if (key.startsWith('skills/')) { + final skillName = p.basename(key); + return p.join(templateSrc, 'skills', skillName, 'SKILL.md.tmpl'); + } + if (key.startsWith('hooks/')) { + final hookFile = p.basename(key).replaceAll('-', '.'); + // special: hooks-json → hooks.json.tmpl + if (key.endsWith('hooks-json')) { + return p.join(templateSrc, 'hooks', 'hooks.json.tmpl'); + } + return p.join(templateSrc, 'hooks', '${p.basename(key)}.ts.tmpl'); + } + return p.join(templateSrc, '$key.mdc.tmpl'); + } + + static String _outputPath(String key) { + if (key.startsWith('skills/')) { + final name = p.basename(key); + return 'skills/$name/SKILL.md'; + } + if (key.startsWith('hooks/')) { + if (key.endsWith('hooks-json')) return 'hooks/hooks.json'; + return 'hooks/${p.basename(key)}.ts'; + } + return '${key.replaceAll('rules/', 'rules/')}.mdc'; + } + + static String _missingTemplatePlaceholder(String key) { + return '---\n# Template not found: $key\n# This file will be generated once the template is created.\n---\n'; + } + + static String _displayName(String raw) { + const names = { + 'bloc': 'BLoC / Cubit', + 'riverpod': 'Riverpod', + 'getx': 'GetX', + 'hooks_riverpod': 'Hooks + Riverpod', + 'clean': 'Clean Architecture', + 'feature_first': 'Feature-First', + 'mvvm': 'MVVM', + 'mvc': 'MVC', + 'layered': 'Layered', + 'gorouter': 'GoRouter', + 'getx_nav': 'GetX Navigation', + 'auto_route': 'Auto Route', + 'firebase': 'Firebase', + 'supabase': 'Supabase', + 'rest': 'REST API', + 'firebase_auth': 'Firebase Auth', + 'supabase_auth': 'Supabase Auth', + 'jwt_rest': 'JWT / REST Auth', + 'oauth2': 'OAuth 2.0', + 'none': 'None', + 'codemagic': 'Codemagic', + 'github_actions': 'GitHub Actions', + 'fastlane': 'Fastlane', + }; + return names[raw] ?? raw; + } + + static String _archImportRules(String arch) { + switch (arch) { + case 'clean': + return ''' +- presentation/ MUST NOT import from data/ +- domain/ MUST NOT import from data/ or presentation/ +- data/ CAN import from domain/ (implements interfaces) +- Use dependency injection to invert data → domain dependency'''; + case 'feature_first': + return ''' +- features/auth/ MUST NOT import from features/home/ etc. +- Shared code lives in core/ or shared/ only +- Each feature is self-contained: screen + provider + model + repo'''; + case 'mvvm': + return ''' +- View MUST NOT contain business logic +- ViewModel MUST NOT import Flutter widgets +- Model MUST NOT import ViewModel or View'''; + case 'mvc': + return ''' +- View (Widget) MUST NOT contain business logic +- Controller MUST NOT import Flutter widgets directly +- Model MUST be plain Dart, no framework dependencies'''; + default: + return '- Follow layer separation appropriate for $arch architecture'; + } + } + + static String _testPattern(String sm) { + switch (sm) { + case 'bloc': + return 'blocTest(description, build: () => MyCubit(), act: (c) => c.method(), expect: () => [MyState()])'; + case 'riverpod': + return 'final container = ProviderContainer(overrides: [myProvider.overrideWithValue(mockValue)]); addTearDown(container.dispose);'; + case 'getx': + return 'Get.put(MyController()); final ctrl = Get.find(); expect(ctrl.value, expected);'; + default: + return '// Write tests appropriate for $sm state management'; + } + } +} diff --git a/flutter-cursor-templates/generator/src/resolver.dart b/flutter-cursor-templates/generator/src/resolver.dart new file mode 100644 index 0000000..3e2041d --- /dev/null +++ b/flutter-cursor-templates/generator/src/resolver.dart @@ -0,0 +1,125 @@ +// resolver.dart — Maps brief values → template file paths (Pillar 3, 4 improvements) + +import 'models.dart'; + +class Resolver { + /// Returns an ordered list of template file keys to render. + /// Each key maps to: templates/.mdc.tmpl (or SKILL.md.tmpl for skills) + static List resolve(ProjectBrief brief) { + final files = []; + + // ── Universal (every project) ────────────────────────────────────── + files.addAll([ + 'rules/universal/flutter-core', + 'rules/universal/ui-ux-standards', + 'rules/universal/project-context', + ]); + + // ── Security (Pillar 5: always-on, not just for large/auth projects) ── + files.add('rules/security/security-standards'); + + // ── Error handling (always-on) ──────────────────────────────────── + files.add('rules/error-handling/error-handling'); + + // ── State management — exactly one ─────────────────────────────── + files.add('rules/state-management/${brief.stateManagement}'); + + // ── Architecture — exactly one ──────────────────────────────────── + files.add('rules/architecture/${brief.architecture}'); + + // ── Backend — one or more ───────────────────────────────────────── + for (final b in brief.backends) files.add('rules/backend/$b'); + if (brief.specialFeatures.contains('realtime')) { + files.add('rules/backend/realtime'); + } + + // ── Routing — exactly one ───────────────────────────────────────── + files.add('rules/routing/${brief.routing}'); + + // ── Testing — SM-matched ────────────────────────────────────────── + files.add('rules/testing/testing-${brief.stateManagement}'); + if (brief.testingDepth == 'full' || brief.testingDepth == 'e2e') { + files.add('rules/testing/testing-e2e-${brief.e2eTool}'); + } + + // ── Platform targets (Pillar 4) ─────────────────────────────────── + for (final platform in brief.platforms) { + files.add('rules/platform/platform-$platform'); + } + + // ── Code generation tools (Pillar 4) ───────────────────────────── + for (final tool in brief.codegenTools) { + files.add('rules/codegen/codegen-$tool'); + } + + // ── Localization ────────────────────────────────────────────────── + if (brief.i18nEnabled) { + files.add('rules/i18n/localization'); + } + + // ── Skills ──────────────────────────────────────────────────────── + files.addAll([ + 'skills/scaffold-feature', + 'skills/scaffold-screen', + 'skills/generate-tests', + ]); + if (brief.apiDocsFormat != 'none') files.add('skills/generate-api-client'); + if (brief.flavors.length > 1) files.add('skills/create-flavor'); + if (brief.cicd.isNotEmpty) files.add('skills/deploy'); + + // ── Agents ──────────────────────────────────────────────────────── + files.addAll([ + 'agents/code-reviewer', + 'agents/test-writer', + 'agents/ui-validator', + ]); + if (brief.backends.contains('rest')) files.add('agents/api-client-gen'); + + // Pillar 5: Security agent as ADDITIONAL layer (rules are primary) + if (brief.scale == 'large' || brief.auth != 'none') { + files.add('agents/security-agent'); + } + if (brief.stateManagement == 'getx') { + files.add('agents/migration-agent'); + } + + // ── Hooks ───────────────────────────────────────────────────────── + files.addAll([ + 'hooks/hooks-json', + 'hooks/flutter-analyze', + 'hooks/grind-tests', + 'hooks/arch-guard', + ]); + + return files; + } + + /// Returns human-readable description of why each file was included + static Map resolveWithReasons(ProjectBrief brief) { + final resolved = resolve(brief); + final reasons = {}; + for (final f in resolved) { + reasons[f] = _reason(f, brief); + } + return reasons; + } + + static String _reason(String key, ProjectBrief brief) { + if (key.contains('universal')) return 'Always included'; + if (key.contains('security')) return 'Always included — Pillar 5'; + if (key.contains('error-handling')) return 'Always included'; + if (key.contains('state-management')) return 'Matches stack.state_management: ${brief.stateManagement}'; + if (key.contains('architecture')) return 'Matches stack.architecture: ${brief.architecture}'; + if (key.contains('routing')) return 'Matches stack.routing: ${brief.routing}'; + if (key.contains('testing-e2e')) return 'testing.depth includes e2e'; + if (key.contains('testing')) return 'Matches state_management testing patterns'; + if (key.contains('platform')) return 'Matches stack.platforms'; + if (key.contains('codegen')) return 'Matches stack.codegen'; + if (key.contains('i18n')) return 'localization.enabled: true'; + if (key.contains('migration')) return 'state_management is GetX — migration guidance included'; + if (key.contains('security-agent')) return 'scale: ${brief.scale} or auth is configured'; + if (key.contains('api-client')) return 'api_docs.format is set'; + if (key.contains('realtime')) return 'features.special contains realtime'; + return 'Included'; + } +} diff --git a/flutter-cursor-templates/generator/src/telemetry.dart b/flutter-cursor-templates/generator/src/telemetry.dart new file mode 100644 index 0000000..def5e9e --- /dev/null +++ b/flutter-cursor-templates/generator/src/telemetry.dart @@ -0,0 +1,86 @@ +// telemetry.dart — Pillar 6: Opt-in local telemetry & feedback loop + +import 'dart:convert'; +import 'dart:io'; +import 'package:path/path.dart' as p; +import 'logger.dart'; + +const _telemetryFile = '.cursor/telemetry.json'; + +class Telemetry { + /// Record a generation event (local-only, opt-in) + static Future record({ + required String projectName, + required String outputDir, + required List templateFiles, + }) async { + final telemetryPath = p.join(outputDir, 'telemetry.json'); + Map data; + try { + final file = File(telemetryPath); + data = file.existsSync() + ? jsonDecode(await file.readAsString()) as Map + : _emptyData(projectName); + } catch (_) { + data = _emptyData(projectName); + } + + final generations = data['generations'] as List; + generations.add({ + 'timestamp': DateTime.now().toIso8601String(), + 'templateCount': templateFiles.length, + 'templates': templateFiles, + }); + + // Keep only last 100 events + if (generations.length > 100) { + data['generations'] = generations.sublist(generations.length - 100); + } + data['lastGeneratedAt'] = DateTime.now().toIso8601String(); + data['totalGenerations'] = (data['totalGenerations'] as int? ?? 0) + 1; + + final file = File(telemetryPath); + await file.parent.create(recursive: true); + await file.writeAsString(const JsonEncoder.withIndent(' ').convert(data)); + } + + /// Print the telemetry report + static Future printReport({required String outputDir}) async { + final file = File(p.join(outputDir, 'telemetry.json')); + if (!file.existsSync()) { + Logger.warn('No telemetry data found. Ensure telemetry_opt_in: true in project-brief.yaml'); + return; + } + final data = jsonDecode(await file.readAsString()) as Map; + Logger.info('\n📊 Telemetry Report — ${data['projectName']}'); + Logger.info('─' * 42); + Logger.info('Total generations: ${data['totalGenerations']}'); + Logger.info('Last generated: ${data['lastGeneratedAt']}'); + + final generations = data['generations'] as List; + if (generations.isNotEmpty) { + final last = generations.last as Map; + final templates = (last['templates'] as List).cast(); + Logger.info('\nLast generation used ${templates.length} templates:'); + final ruleCount = templates.where((t) => t.startsWith('rules/')).length; + final agentCount = templates.where((t) => t.startsWith('agents/')).length; + final skillCount = templates.where((t) => t.startsWith('skills/')).length; + Logger.dim(' Rules: $ruleCount | Agents: $agentCount | Skills: $skillCount'); + } + + Logger.info('\n💡 Quarterly template quality review checklist:'); + Logger.dim(' □ Review AI code review comments — which rules are most violated?'); + Logger.dim(' □ Ask teams: which agents are actually consulted vs. ignored?'); + Logger.dim(' □ Check if generated rules reduced hallucinations vs. last quarter'); + Logger.dim(' □ Identify brief combinations that produce the most AI errors'); + Logger.dim(' □ Update templates based on feedback, bump version in CHANGELOG.md'); + } + + static Map _emptyData(String projectName) => { + 'projectName': projectName, + 'totalGenerations': 0, + 'lastGeneratedAt': '', + 'generations': [], + 'note': 'Local-only telemetry. Never sent anywhere. opt-in via telemetry_opt_in: true.', + }; +} diff --git a/flutter-cursor-templates/generator/src/validator.dart b/flutter-cursor-templates/generator/src/validator.dart new file mode 100644 index 0000000..30eb164 --- /dev/null +++ b/flutter-cursor-templates/generator/src/validator.dart @@ -0,0 +1,128 @@ +// validator.dart — Validates project-brief.yaml schema (Pillar 3) + +import 'dart:io'; +import 'package:yaml/yaml.dart'; +import 'models.dart'; + +class Validator { + static const _validStateManagement = {'bloc', 'riverpod', 'getx', 'hooks_riverpod'}; + static const _validArchitectures = {'clean', 'feature_first', 'mvvm', 'mvc', 'layered'}; + static const _validRouting = {'gorouter', 'getx_nav', 'auto_route'}; + static const _validBackends = {'firebase', 'supabase', 'rest'}; + static const _validAuth = {'firebase_auth', 'supabase_auth', 'jwt_rest', 'oauth2', 'none'}; + static const _validPlatforms = {'ios', 'android', 'web', 'desktop'}; + static const _validCodegen = {'freezed', 'json_serializable', 'injectable', 'retrofit'}; + static const _validScale = {'small', 'medium', 'large'}; + static const _validTestingDepth = {'unit_widget', 'integration', 'e2e', 'full'}; + static const _validCicd = {'codemagic', 'github_actions', 'fastlane', 'none'}; + static const _validE2eTools = {'patrol', 'maestro'}; + static const _validDesignSource = {'figma_mcp', 'figma_manual', 'native_ref', 'html_ref', 'none'}; + static const _validApiFormats = {'openapi', 'postman', 'markdown', 'none'}; + + static Future validateFile(String path) async { + final file = File(path); + if (!file.existsSync()) { + return ValidationResult(isValid: false, errors: ['File not found: $path']); + } + final content = await file.readAsString(); + final yaml = loadYaml(content) as YamlMap; + return _validateYaml(yaml); + } + + static Future validate(ProjectBrief brief) async { + return _validateBrief(brief); + } + + static ValidationResult _validateYaml(YamlMap yaml) { + final errors = []; + final warnings = []; + + final project = yaml['project'] as YamlMap?; + final stack = yaml['stack'] as YamlMap?; + + if (project == null) { errors.add('Missing required section: project'); } + else { + if (project['name'] == null) errors.add('project.name is required'); + if (project['package'] == null) errors.add('project.package is required'); + if (project['scale'] != null && !_validScale.contains(project['scale'])) { + errors.add('project.scale must be one of: ${_validScale.join(", ")}'); + } + } + + if (stack == null) { errors.add('Missing required section: stack'); } + else { + _validateField(stack, 'state_management', _validStateManagement, errors); + _validateField(stack, 'architecture', _validArchitectures, errors); + _validateField(stack, 'routing', _validRouting, errors); + _validateField(stack, 'auth', _validAuth, warnings); // warning only + + // Validate platforms list + final platforms = stack['platforms']; + if (platforms != null && platforms is YamlList) { + for (final p in platforms) { + if (!_validPlatforms.contains(p.toString())) { + errors.add('stack.platforms contains unknown value: $p. Valid: ${_validPlatforms.join(", ")}'); + } + } + } + + // Validate codegen list + final codegen = stack['codegen']; + if (codegen != null && codegen is YamlList) { + for (final c in codegen) { + if (!_validCodegen.contains(c.toString())) { + warnings.add('stack.codegen contains unknown value: $c. Known: ${_validCodegen.join(", ")}'); + } + } + } + + // Cross-validation: GetX nav requires GetX SM + if (stack['routing']?.toString() == 'getx_nav' && + stack['state_management']?.toString() != 'getx') { + warnings.add('stack.routing is getx_nav but state_management is not getx. This is unusual.'); + } + + // Cross-validation: web platform warnings + final platformsList = (stack['platforms'] as YamlList?)?.map((e) => e.toString()).toList() ?? []; + if (platformsList.contains('web')) { + warnings.add('Web platform detected: ensure you avoid dart:io imports (use dart:html or flutter_web_plugins)'); + } + } + + return ValidationResult( + isValid: errors.isEmpty, + errors: errors, + warnings: warnings, + ); + } + + static ValidationResult _validateBrief(ProjectBrief brief) { + final errors = []; + final warnings = []; + + if (brief.projectName.isEmpty) errors.add('project.name is required'); + if (brief.packageId.isEmpty) errors.add('project.package is required'); + if (!_validStateManagement.contains(brief.stateManagement)) { + errors.add('stack.state_management "${brief.stateManagement}" is not valid. Use: ${_validStateManagement.join(", ")}'); + } + if (!_validArchitectures.contains(brief.architecture)) { + errors.add('stack.architecture "${brief.architecture}" is not valid. Use: ${_validArchitectures.join(", ")}'); + } + for (final b in brief.backends) { + if (!_validBackends.contains(b)) { + errors.add('stack.backend contains "$b". Valid values: ${_validBackends.join(", ")}'); + } + } + + return ValidationResult(isValid: errors.isEmpty, errors: errors, warnings: warnings); + } + + static void _validateField( + YamlMap map, String field, Set valid, List output, + ) { + final val = map[field]?.toString(); + if (val != null && !valid.contains(val)) { + output.add('stack.$field "$val" is not valid. Use: ${valid.join(", ")}'); + } + } +} diff --git a/flutter-cursor-templates/generator/src/version_manager.dart b/flutter-cursor-templates/generator/src/version_manager.dart new file mode 100644 index 0000000..582eaac --- /dev/null +++ b/flutter-cursor-templates/generator/src/version_manager.dart @@ -0,0 +1,95 @@ +// version_manager.dart — Pillar 1: Template versioning & update propagation + +import 'dart:convert'; +import 'dart:io'; +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; +import 'models.dart'; +import 'logger.dart'; + +const _lockFileName = '.cursor-gen-lock.json'; +const _currentVersion = '1.0.0'; + +class VersionManager { + /// Check if the project's locked version differs from the current template version + static Future check({required ProjectBrief brief}) async { + final locked = brief.cursorTemplatesVersion ?? 'unset'; + final hasUpdate = locked != _currentVersion && locked != 'unset'; + return VersionStatus( + hasUpdate: hasUpdate, + currentVersion: locked, + latestVersion: _currentVersion, + ); + } + + /// Write the version lock file after successful generation + static Future writeLock({ + required String outputDir, + required String briefPath, + required ProjectBrief brief, + }) async { + final briefHash = await _fileHash(briefPath); + final lock = { + 'templateVersion': _currentVersion, + 'generatedAt': DateTime.now().toIso8601String(), + 'briefHash': briefHash, + 'projectName': brief.projectName, + 'stack': { + 'stateManagement': brief.stateManagement, + 'architecture': brief.architecture, + 'routing': brief.routing, + 'backends': brief.backends, + 'platforms': brief.platforms, + 'codegenTools': brief.codegenTools, + }, + 'note': 'Auto-generated by cursor_gen. Do not edit manually. ' + 'Run: dart run cursor_gen --check-updates to see available updates.', + }; + final file = File(p.join(outputDir, _lockFileName)); + await file.parent.create(recursive: true); + await file.writeAsString(const JsonEncoder.withIndent(' ').convert(lock)); + } + + /// Read the existing lock file + static Future?> readLock(String outputDir) async { + final file = File(p.join(outputDir, _lockFileName)); + if (!file.existsSync()) return null; + return jsonDecode(await file.readAsString()) as Map; + } + + /// --check-updates: show detailed diff between locked and current version + static Future checkUpdates({required String briefPath}) async { + final file = File(briefPath); + if (!file.existsSync()) { + Logger.error('project-brief.yaml not found at $briefPath'); + return; + } + final lock = await readLock('.cursor'); + if (lock == null) { + Logger.warn('No lock file found. Run: dart run cursor_gen first.'); + return; + } + final lockedVersion = lock['templateVersion'] as String? ?? 'unknown'; + Logger.info('Template version check:'); + Logger.info(' Locked: $lockedVersion'); + Logger.info(' Latest: $_currentVersion'); + + if (lockedVersion == _currentVersion) { + Logger.success(' ✔ You are on the latest template version.'); + } else { + Logger.warn(' ⚠ Update available!'); + Logger.info('\nTo update:'); + Logger.info(' 1. Update cursor_templates_version in project-brief.yaml to "$_currentVersion"'); + Logger.info(' 2. Run: dart run cursor_gen --diff (preview changes)'); + Logger.info(' 3. Run: dart run cursor_gen --refresh (apply updates)'); + Logger.info('\nChangelog: see CHANGELOG.md in flutter-cursor-templates repo'); + } + } + + static Future _fileHash(String path) async { + final file = File(path); + if (!file.existsSync()) return 'file-not-found'; + final bytes = await file.readAsBytes(); + return sha256.convert(bytes).toString().substring(0, 12); + } +} diff --git a/flutter-cursor-templates/generator/src/wizard.dart b/flutter-cursor-templates/generator/src/wizard.dart new file mode 100644 index 0000000..f4cdb18 --- /dev/null +++ b/flutter-cursor-templates/generator/src/wizard.dart @@ -0,0 +1,180 @@ +// wizard.dart — Interactive CLI wizard (brief discovery UX gap fix) + +import 'dart:io'; +import 'logger.dart'; + +class Wizard { + static Future run({required String outputPath}) async { + Logger.info('\n🧙 cursor_gen Interactive Wizard'); + Logger.info('─' * 42); + Logger.info('Answer the questions below to generate your project-brief.yaml.\n'); + + final answers = {}; + + // Project basics + answers['name'] = _ask('Project name', hint: 'ShopEasy'); + answers['package'] = _ask('Package ID', hint: 'com.company.shopeasy'); + answers['description'] = _ask('Short description', hint: 'E-commerce app with real-time inventory'); + answers['scale'] = _askChoice('Project scale', ['small', 'medium', 'large'], defaultIdx: 1); + + Logger.info(''); + + // Stack + answers['state_management'] = _askChoice('State management', + ['bloc', 'riverpod', 'getx', 'hooks_riverpod'], defaultIdx: 1); + answers['architecture'] = _askChoice('Architecture', + ['clean', 'feature_first', 'mvvm', 'mvc', 'layered'], defaultIdx: 0); + answers['routing'] = _askChoice('Routing', + ['gorouter', 'getx_nav', 'auto_route'], defaultIdx: 0); + answers['backend'] = _askChoice('Backend(s)', + ['firebase', 'supabase', 'rest', 'firebase+rest', 'supabase+rest'], defaultIdx: 2); + answers['auth'] = _askChoice('Auth method', + ['firebase_auth', 'supabase_auth', 'jwt_rest', 'oauth2', 'none'], defaultIdx: 4); + + Logger.info(''); + + // Platforms (Pillar 4) + answers['platforms'] = _askMultiChoice('Target platforms', + ['ios', 'android', 'web', 'desktop'], defaults: [0, 1]); + + // Codegen (Pillar 4) + answers['codegen'] = _askMultiChoice('Code generation tools (optional)', + ['freezed', 'json_serializable', 'injectable', 'retrofit'], defaults: []); + + Logger.info(''); + + // Environments + final flavorsInput = _ask('Build flavors (comma-separated)', hint: 'dev,staging,prod'); + answers['flavors'] = flavorsInput.split(',').map((e) => e.trim()).toList(); + answers['cicd'] = _askChoice('CI/CD', ['github_actions', 'codemagic', 'fastlane', 'none'], defaultIdx: 0); + + // Testing + answers['testing_depth'] = _askChoice('Testing depth', + ['unit_widget', 'integration', 'e2e', 'full'], defaultIdx: 0); + + // Localization + final l10n = _askBool('Enable localization / i18n?', defaultYes: false); + answers['i18n'] = l10n; + + // Telemetry opt-in (Pillar 6) + final telemetry = _askBool('\nOpt in to local telemetry (rule trigger logging, stored locally)?', defaultYes: false); + answers['telemetry'] = telemetry; + + Logger.info(''); + final yaml = _buildYaml(answers); + final file = File(outputPath); + await file.writeAsString(yaml); + Logger.success('✔ project-brief.yaml written to $outputPath'); + Logger.info('\nNext: dart run cursor_gen --validate → dart run cursor_gen'); + } + + static String _ask(String label, {String hint = ''}) { + final display = hint.isNotEmpty ? '$label [$hint]: ' : '$label: '; + stdout.write(' $display'); + final input = stdin.readLineSync()?.trim() ?? ''; + return input.isEmpty ? hint : input; + } + + static String _askChoice(String label, List options, {int defaultIdx = 0}) { + Logger.info(' $label:'); + for (var i = 0; i < options.length; i++) { + final marker = i == defaultIdx ? ' ◀' : ''; + Logger.dim(' ${i + 1}) ${options[i]}$marker'); + } + stdout.write(' Choice [${defaultIdx + 1}]: '); + final input = stdin.readLineSync()?.trim() ?? ''; + if (input.isEmpty) return options[defaultIdx]; + final idx = int.tryParse(input); + if (idx != null && idx >= 1 && idx <= options.length) return options[idx - 1]; + return options[defaultIdx]; + } + + static List _askMultiChoice(String label, List options, {List defaults = const []}) { + Logger.info(' $label (comma-separated numbers, or leave blank):'); + for (var i = 0; i < options.length; i++) { + final marker = defaults.contains(i) ? ' ◀' : ''; + Logger.dim(' ${i + 1}) ${options[i]}$marker'); + } + final defaultStr = defaults.map((d) => '${d + 1}').join(','); + stdout.write(' Choices [$defaultStr]: '); + final input = stdin.readLineSync()?.trim() ?? ''; + if (input.isEmpty) return defaults.map((d) => options[d]).toList(); + return input.split(',') + .map((s) => int.tryParse(s.trim())) + .where((i) => i != null && i >= 1 && i <= options.length) + .map((i) => options[i! - 1]) + .toList(); + } + + static bool _askBool(String label, {bool defaultYes = false}) { + final hint = defaultYes ? 'Y/n' : 'y/N'; + stdout.write(' $label ($hint): '); + final input = stdin.readLineSync()?.trim().toLowerCase() ?? ''; + if (input.isEmpty) return defaultYes; + return input == 'y' || input == 'yes'; + } + + static String _buildYaml(Map a) { + final platforms = (a['platforms'] as List).map((p) => '"$p"').join(', '); + final codegen = (a['codegen'] as List).map((c) => '"$c"').join(', '); + final flavors = (a['flavors'] as List).map((f) => '"$f"').join(', '); + return '''# project-brief.yaml — cursor_gen configuration +# Generated by cursor_gen --wizard +# Run: dart run cursor_gen to generate .cursor/ +# Run: dart run cursor_gen --refresh to update after changes + +# Pillar 1: Pin to template version for reproducibility +cursor_templates_version: "1.0.0" + +project: + name: "${a['name']}" + package: "${a['package']}" + description: "${a['description']}" + scale: "${a['scale']}" + +stack: + state_management: "${a['state_management']}" + routing: "${a['routing']}" + architecture: "${a['architecture']}" + backend: "${a['backend']}" + auth: "${a['auth']}" + + # Pillar 4: Platform targets + platforms: [$platforms] + + # Pillar 4: Code generation tools + codegen: [$codegen] + +environments: + flavors: [$flavors] + cicd: "${a['cicd']}" + +testing: + depth: "${a['testing_depth']}" + e2e_tool: "patrol" + +design: + source: "none" + figma_url: "" + +api_docs: + format: "none" + path: "" + +references: + repos: [] + local_paths: [] + +features: + modules: [] + special: [] + +localization: + enabled: ${a['i18n']} + locales: ["en"] + +# Pillar 6: Opt-in local telemetry (logs rule trigger frequency locally) +telemetry_opt_in: ${a['telemetry']} +'''; + } +} diff --git a/flutter-cursor-templates/generator/test/generator_test.dart b/flutter-cursor-templates/generator/test/generator_test.dart new file mode 100644 index 0000000..2f636f6 --- /dev/null +++ b/flutter-cursor-templates/generator/test/generator_test.dart @@ -0,0 +1,372 @@ +// generator_test.dart — Pillar 3: Generator test suite with golden files +// Run: dart test test/generator_test.dart + +import 'dart:io'; +import 'package:test/test.dart'; +import '../src/brief_loader.dart'; +import '../src/resolver.dart'; +import '../src/renderer.dart'; +import '../src/validator.dart'; +import '../src/models.dart'; + +// ─── Test fixtures ───────────────────────────────────────────────────────── + +final _blocCleanBrief = ProjectBrief( + projectName: 'TestApp', + packageId: 'com.test.testapp', + description: 'Test app for golden tests', + scale: 'medium', + stateManagement: 'bloc', + routing: 'gorouter', + architecture: 'clean', + backends: ['firebase'], + auth: 'firebase_auth', + platforms: ['ios', 'android'], + codegenTools: ['freezed'], + flavors: ['dev', 'prod'], + cicd: 'github_actions', + testingDepth: 'unit_widget', + e2eTool: 'patrol', + designSource: 'none', + figmaUrl: '', + apiDocsFormat: 'none', + apiDocsPath: '', + referenceRepos: [], + localPaths: [], + featureModules: ['auth', 'home', 'products'], + specialFeatures: [], + i18nEnabled: false, + locales: ['en'], +); + +final _riverpodFFBrief = ProjectBrief( + projectName: 'TaskFlow', + packageId: 'com.test.taskflow', + description: 'Task management app', + scale: 'small', + stateManagement: 'riverpod', + routing: 'gorouter', + architecture: 'feature_first', + backends: ['supabase'], + auth: 'supabase_auth', + platforms: ['ios', 'android', 'web'], + codegenTools: ['freezed', 'json_serializable'], + flavors: ['dev', 'prod'], + cicd: 'github_actions', + testingDepth: 'unit_widget', + e2eTool: 'patrol', + designSource: 'none', + figmaUrl: '', + apiDocsFormat: 'none', + apiDocsPath: '', + referenceRepos: [], + localPaths: [], + featureModules: ['auth', 'tasks', 'profile'], + specialFeatures: [], + i18nEnabled: true, + locales: ['en', 'fr'], +); + +final _getxMvcBrief = ProjectBrief( + projectName: 'LegacyApp', + packageId: 'com.test.legacy', + description: 'Legacy GetX app', + scale: 'medium', + stateManagement: 'getx', + routing: 'getx_nav', + architecture: 'mvc', + backends: ['rest'], + auth: 'jwt_rest', + platforms: ['ios', 'android'], + codegenTools: [], + flavors: ['dev', 'prod'], + cicd: 'codemagic', + testingDepth: 'unit_widget', + e2eTool: 'patrol', + designSource: 'none', + figmaUrl: '', + apiDocsFormat: 'openapi', + apiDocsPath: 'docs/api.yaml', + referenceRepos: [], + localPaths: [], + featureModules: ['auth', 'dashboard'], + specialFeatures: [], + i18nEnabled: false, + locales: ['en'], +); + +// ─── Resolver tests ───────────────────────────────────────────────────────── + +group('Resolver', () { + test('BLoC + Clean resolves correct files', () { + final files = Resolver.resolve(_blocCleanBrief); + + // Universal — always present + expect(files, contains('rules/universal/flutter-core')); + expect(files, contains('rules/universal/ui-ux-standards')); + expect(files, contains('rules/universal/project-context')); + + // Security — always present (Pillar 5) + expect(files, contains('rules/security/security-standards')); + + // Error handling — always present + expect(files, contains('rules/error-handling/error-handling')); + + // Stack-specific + expect(files, contains('rules/state-management/bloc')); + expect(files, contains('rules/architecture/clean')); + expect(files, contains('rules/routing/gorouter')); + expect(files, contains('rules/backend/firebase')); + + // Testing — SM-matched + expect(files, contains('rules/testing/testing-bloc')); + + // Platforms (Pillar 4) + expect(files, contains('rules/platform/platform-ios')); + expect(files, contains('rules/platform/platform-android')); + expect(files, containsNot('rules/platform/platform-web')); + + // Codegen (Pillar 4) + expect(files, contains('rules/codegen/codegen-freezed')); + expect(files, containsNot('rules/codegen/codegen-injectable')); + + // Agents + expect(files, contains('agents/code-reviewer')); + expect(files, containsNot('agents/migration-agent')); // not GetX + expect(files, contains('agents/security-agent')); // firebase_auth triggers it + + // NOT included + expect(files, containsNot('rules/state-management/riverpod')); + expect(files, containsNot('rules/state-management/getx')); + expect(files, containsNot('rules/architecture/feature_first')); + expect(files, containsNot('rules/routing/getx_nav')); + }); + + test('Riverpod + Feature-First + Web resolves web platform template', () { + final files = Resolver.resolve(_riverpodFFBrief); + expect(files, contains('rules/platform/platform-web')); + expect(files, contains('rules/state-management/riverpod')); + expect(files, contains('rules/architecture/feature_first')); + expect(files, contains('rules/codegen/codegen-freezed')); + expect(files, contains('rules/codegen/codegen-json_serializable')); + expect(files, contains('rules/i18n/localization')); // i18nEnabled: true + expect(files, containsNot('agents/migration-agent')); + }); + + test('GetX + MVC includes migration-agent', () { + final files = Resolver.resolve(_getxMvcBrief); + expect(files, contains('agents/migration-agent')); + expect(files, contains('rules/state-management/getx')); + expect(files, contains('rules/architecture/mvc')); + expect(files, contains('agents/api-client-gen')); // rest backend + expect(files, contains('skills/generate-api-client')); // apiDocsFormat != none + }); + + test('Realtime special feature includes realtime template', () { + final brief = ProjectBrief( + projectName: 'RealtimeApp', packageId: 'com.test.rt', description: '', + scale: 'medium', stateManagement: 'riverpod', routing: 'gorouter', + architecture: 'feature_first', backends: ['supabase'], auth: 'supabase_auth', + platforms: ['ios'], codegenTools: [], flavors: ['dev'], cicd: 'github_actions', + testingDepth: 'unit_widget', e2eTool: 'patrol', designSource: 'none', + figmaUrl: '', apiDocsFormat: 'none', apiDocsPath: '', + referenceRepos: [], localPaths: [], featureModules: [], + specialFeatures: ['realtime'], i18nEnabled: false, locales: ['en'], + ); + final files = Resolver.resolve(brief); + expect(files, contains('rules/backend/realtime')); + }); + + test('E2E testing depth includes e2e template', () { + final brief = ProjectBrief( + projectName: 'E2EApp', packageId: 'com.test.e2e', description: '', + scale: 'small', stateManagement: 'bloc', routing: 'gorouter', + architecture: 'clean', backends: ['rest'], auth: 'none', + platforms: ['ios'], codegenTools: [], flavors: ['dev'], cicd: 'github_actions', + testingDepth: 'full', e2eTool: 'patrol', designSource: 'none', + figmaUrl: '', apiDocsFormat: 'none', apiDocsPath: '', + referenceRepos: [], localPaths: [], featureModules: [], + specialFeatures: [], i18nEnabled: false, locales: ['en'], + ); + final files = Resolver.resolve(brief); + expect(files, contains('rules/testing/testing-e2e-patrol')); + }); + + test('No duplicate files in resolved list', () { + final files = Resolver.resolve(_blocCleanBrief); + final unique = files.toSet(); + expect(files.length, equals(unique.length), reason: 'Duplicate template files detected'); + }); +}); + +// ─── Renderer / placeholder tests ─────────────────────────────────────────── + +group('Renderer — placeholder substitution', () { + test('buildContext produces all required keys', () { + // Access via Renderer._buildContext (white-box test) + // Instead, verify rendered output has no unreplaced {{VAR}} patterns + // by checking a known template snippet + final context = { + 'PROJECT_NAME': 'TestApp', + 'PACKAGE_ID': 'com.test.testapp', + }; + final template = 'Project: {{PROJECT_NAME}} ({{PACKAGE_ID}})'; + final result = template + .replaceAll('{{PROJECT_NAME}}', 'TestApp') + .replaceAll('{{PACKAGE_ID}}', 'com.test.testapp'); + expect(result, equals('Project: TestApp (com.test.testapp)')); + expect(result, isNot(contains('{{'))); + }); + + test('Rendered output has no unreplaced {{PLACEHOLDER}} patterns', () async { + final templateDir = _templateDir(); + if (!Directory(templateDir).existsSync()) { + markTestSkipped('Template directory not found at $templateDir — run from generator/'); + return; + } + + final rendered = await Renderer.render( + brief: _blocCleanBrief, + templateFiles: Resolver.resolve(_blocCleanBrief), + templateSrc: templateDir, + ); + + final unresolved = []; + for (final entry in rendered.entries) { + final matches = RegExp(r'\{\{[A-Z_]+\}\}').allMatches(entry.value); + for (final m in matches) { + unresolved.add('${entry.key}: ${m.group(0)}'); + } + } + expect(unresolved, isEmpty, reason: 'Unreplaced placeholders:\n${unresolved.join("\n")}'); + }); +}); + +// ─── Validator tests ───────────────────────────────────────────────────────── + +group('Validator', () { + test('Valid brief passes validation', () async { + final result = await Validator.validate(_blocCleanBrief); + expect(result.isValid, isTrue); + expect(result.errors, isEmpty); + }); + + test('Invalid state_management fails validation', () async { + final brief = ProjectBrief( + projectName: 'X', packageId: 'com.x.x', description: '', + scale: 'small', stateManagement: 'invalid_sm', routing: 'gorouter', + architecture: 'clean', backends: ['rest'], auth: 'none', + platforms: ['ios'], codegenTools: [], flavors: ['dev'], cicd: 'github_actions', + testingDepth: 'unit_widget', e2eTool: 'patrol', designSource: 'none', + figmaUrl: '', apiDocsFormat: 'none', apiDocsPath: '', + referenceRepos: [], localPaths: [], featureModules: [], + specialFeatures: [], i18nEnabled: false, locales: ['en'], + ); + final result = await Validator.validate(brief); + expect(result.isValid, isFalse); + expect(result.errors, anyElement(contains('invalid_sm'))); + }); + + test('Missing project name fails validation', () async { + final brief = ProjectBrief( + projectName: '', packageId: 'com.x.x', description: '', + scale: 'small', stateManagement: 'riverpod', routing: 'gorouter', + architecture: 'clean', backends: ['rest'], auth: 'none', + platforms: ['ios'], codegenTools: [], flavors: ['dev'], cicd: 'github_actions', + testingDepth: 'unit_widget', e2eTool: 'patrol', designSource: 'none', + figmaUrl: '', apiDocsFormat: 'none', apiDocsPath: '', + referenceRepos: [], localPaths: [], featureModules: [], + specialFeatures: [], i18nEnabled: false, locales: ['en'], + ); + final result = await Validator.validate(brief); + expect(result.isValid, isFalse); + expect(result.errors, anyElement(contains('project.name'))); + }); +}); + +// ─── Golden file tests (Pillar 3) ─────────────────────────────────────────── + +group('Golden file tests', () { + test('BLoC + Clean renders match golden files', () async { + final templateDir = _templateDir(); + if (!Directory(templateDir).existsSync()) { + markTestSkipped('Template dir not found'); return; + } + + final rendered = await Renderer.render( + brief: _blocCleanBrief, + templateFiles: Resolver.resolve(_blocCleanBrief), + templateSrc: templateDir, + ); + + for (final entry in rendered.entries) { + final goldenPath = 'test/golden/bloc-clean-firebase/${entry.key}'; + final goldenFile = File(goldenPath); + if (!goldenFile.existsSync()) { + // Create golden file on first run + await goldenFile.parent.create(recursive: true); + await goldenFile.writeAsString(entry.value); + print('Created golden: $goldenPath'); + } else { + final golden = await goldenFile.readAsString(); + expect(entry.value, equals(golden), + reason: 'Golden mismatch for ${entry.key}.\n' + 'Update goldens: dart test --update-goldens'); + } + } + }); + + test('Riverpod + FF renders match golden files', () async { + final templateDir = _templateDir(); + if (!Directory(templateDir).existsSync()) { + markTestSkipped('Template dir not found'); return; + } + final rendered = await Renderer.render( + brief: _riverpodFFBrief, + templateFiles: Resolver.resolve(_riverpodFFBrief), + templateSrc: templateDir, + ); + _compareGoldens('test/golden/riverpod-ff-supabase', rendered); + }); + + test('GetX + MVC renders match golden files', () async { + final templateDir = _templateDir(); + if (!Directory(templateDir).existsSync()) { + markTestSkipped('Template dir not found'); return; + } + final rendered = await Renderer.render( + brief: _getxMvcBrief, + templateFiles: Resolver.resolve(_getxMvcBrief), + templateSrc: templateDir, + ); + _compareGoldens('test/golden/getx-mvc-rest', rendered); + }); +}); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +Future _compareGoldens(String goldenDir, Map rendered) async { + for (final entry in rendered.entries) { + final goldenFile = File('$goldenDir/${entry.key}'); + if (!goldenFile.existsSync()) { + await goldenFile.parent.create(recursive: true); + await goldenFile.writeAsString(entry.value); + } else { + expect(entry.value, await goldenFile.readAsString(), + reason: 'Golden mismatch: $goldenDir/${entry.key}'); + } + } +} + +String _templateDir() { + // When running from generator/, go up one level to find templates/ + final script = Platform.script.toFilePath(); + return script.replaceAll(RegExp(r'test[/\\][^/\\]+$'), '../templates'); +} + +// Custom matcher +Matcher get containsNot => isNot(contains); + +extension on Matcher { + Matcher call(dynamic value) => isNot(contains(value)); +} diff --git a/flutter-cursor-templates/templates/agents/api-client-gen.mdc.tmpl b/flutter-cursor-templates/templates/agents/api-client-gen.mdc.tmpl new file mode 100644 index 0000000..7c50fc3 --- /dev/null +++ b/flutter-cursor-templates/templates/agents/api-client-gen.mdc.tmpl @@ -0,0 +1,34 @@ +--- +name: api-client-gen +description: "Generates type-safe API clients for {{PROJECT_NAME}} from {{API_DOCS_FORMAT}} spec. Ask: '@api-client-gen generate client for /products endpoint'" +model: claude-sonnet-4-20250514 +context: auto +allowed-tools: [read_file, write_file, list_files] +--- + +You are an API client generator for **{{PROJECT_NAME}}**. +API docs: `{{API_DOCS_PATH}}` (format: {{API_DOCS_FORMAT}}) + +## Generation steps +1. Read the API spec at `{{API_DOCS_PATH}}` +2. For the requested endpoint(s), generate: + - Request DTO (`@JsonSerializable` or Freezed) + - Response DTO (`@JsonSerializable` or Freezed) + - Repository method with error handling + - Dio/Retrofit client method (if Retrofit in codegen) + +## Output structure +```dart +// data/models/product_dto.dart +@freezed +class ProductDto with _$ProductDto { + factory ProductDto({...}) = _ProductDto; + factory ProductDto.fromJson(Map json) => _$ProductDtoFromJson(json); +} + +// data/datasources/product_remote_datasource.dart +class ProductRemoteDataSource { + final Dio _dio; + Future> getProducts() async { ... } +} +``` diff --git a/flutter-cursor-templates/templates/agents/code-reviewer.mdc.tmpl b/flutter-cursor-templates/templates/agents/code-reviewer.mdc.tmpl new file mode 100644 index 0000000..8598efd --- /dev/null +++ b/flutter-cursor-templates/templates/agents/code-reviewer.mdc.tmpl @@ -0,0 +1,51 @@ +--- +name: code-reviewer +description: "Reviews {{PROJECT_NAME}} code for {{STATE_MANAGEMENT}} patterns, {{ARCHITECTURE}} boundaries, and {{BACKEND}} usage. Ask: 'Review this code' or '@code-reviewer check PR'" +model: claude-opus-4-5 +context: auto +allowed-tools: [read_file, list_files] +--- + +You are a senior Flutter engineer reviewing code for **{{PROJECT_NAME}}**. + +## Your review checklist + +### {{STATE_MANAGEMENT}} patterns +- Are state classes immutable and sealed? +- Is state management correctly separated from UI logic? +- Are streams/subscriptions properly cancelled in dispose()? +- Check for anti-patterns specific to {{STATE_MANAGEMENT}} + +### {{ARCHITECTURE}} boundaries +{{ARCH_IMPORT_RULES}} +- Flag any violation of these import rules immediately + +### {{BACKEND}} usage +- Are all exceptions caught and mapped to domain errors? +- Are streams vs futures used appropriately? +- Are connections/subscriptions disposed correctly? + +### Security (always check) +- No hardcoded API keys or secrets +- No PII logged to console or crash reporters +- Sensitive data using flutter_secure_storage, not SharedPreferences +- All user inputs validated before sending to backend + +### General Flutter +- `const` used where possible +- `dispose()` overridden for all controllers/subscriptions +- No `print()` in production paths +- Loading/empty/error states all handled + +## Output format +For each issue found: +``` +[SEVERITY: critical/major/minor] File:line — Issue description +WHY: Why this matters +FIX: Specific fix recommendation +``` + +Severity guide: +- **critical**: Security issue, data loss risk, crash potential +- **major**: Incorrect pattern, boundary violation, missing error handling +- **minor**: Style, naming, optimization opportunity diff --git a/flutter-cursor-templates/templates/agents/migration-agent.mdc.tmpl b/flutter-cursor-templates/templates/agents/migration-agent.mdc.tmpl new file mode 100644 index 0000000..d1e92bc --- /dev/null +++ b/flutter-cursor-templates/templates/agents/migration-agent.mdc.tmpl @@ -0,0 +1,42 @@ +--- +name: migration-agent +description: "Migrates GetX controllers to Riverpod Notifiers for {{PROJECT_NAME}}. Consult before adding any new GetX code — suggest Riverpod equivalent. Ask: '@migration-agent migrate [feature]'" +model: claude-opus-4-5 +context: fork +allowed-tools: [read_file, write_file, list_files] +--- + +You are a Flutter migration specialist for **{{PROJECT_NAME}}**. +This project currently uses GetX ({{ARCHITECTURE}}). Your goal is incremental, +safe migration to Riverpod without breaking existing features. + +## Migration mapping +| GetX | Riverpod equivalent | +|------|---------------------| +| `GetxController` with `.obs` | `AsyncNotifier` or `Notifier` | +| `Obx()` | `ref.watch()` in `ConsumerWidget` | +| `Get.find()` | `ref.read(provider.notifier)` | +| `Get.toNamed()` | `context.go()` (after GoRouter migration) | +| `GetxController.onInit()` | `build()` method in `AsyncNotifier` | +| `GetxController.onClose()` | `ref.onDispose()` | + +## Migration process (feature by feature) +1. **Read** the existing `GetxController` in full +2. **Write tests** for the existing GetX version first (if none exist) +3. **Map** `.obs` variables → state class fields +4. **Write** the new `AsyncNotifier` with equivalent logic +5. **Write tests** for the Riverpod version using `ProviderContainer` +6. **Migrate** the View: `GetView` → `ConsumerWidget`, `Obx()` → `ref.watch()` +7. **Verify** all tests pass for both old and new +8. **Remove** GetX code from that feature + +## When NOT to migrate (mark with TODO: MIGRATE-LATER) +- Controller shared across 5+ screens (high blast radius — plan separately) +- Feature ships in the next sprint (postpone — don't hold up a release) +- No tests exist AND you can't write them first (write GetX tests first) + +## Output per migration +1. New Riverpod provider file +2. Updated ConsumerWidget screen file +3. Test file for the new provider +4. Diff showing what GetX code is removed diff --git a/flutter-cursor-templates/templates/agents/security-agent.mdc.tmpl b/flutter-cursor-templates/templates/agents/security-agent.mdc.tmpl new file mode 100644 index 0000000..7dc76be --- /dev/null +++ b/flutter-cursor-templates/templates/agents/security-agent.mdc.tmpl @@ -0,0 +1,47 @@ +--- +name: security-agent +description: "Deep security review for {{PROJECT_NAME}}. Consult for auth flows, payment screens, and sensitive data handling. Ask: '@security-agent review auth flow'" +model: claude-opus-4-5 +context: fork +allowed-tools: [read_file, list_files] +--- + +You are a mobile security expert conducting a deep review for **{{PROJECT_NAME}}**. + +> Note: This agent provides deep security analysis. +> The `security-standards.mdc` rule provides always-on enforcement. +> This agent is for detailed consultations on specific security concerns. + +## Deep review focus areas + +### Auth flow ({{AUTH}}) +- Token storage: is `flutter_secure_storage` used for ALL tokens? +- Token refresh: is refresh handled atomically (no race condition)? +- Session expiry: does the app handle 401 gracefully without data loss? +- Certificate pinning: configured and tested? + +### Data at rest +- SQLite/Hive encryption: sensitive DBs encrypted? +- Cache poisoning: cached API responses validated before use? +- Keychain/Keystore usage for cryptographic keys + +### Network security +- All endpoints HTTPS — any http:// URLs? +- Certificate validation — any `badCertificateCallback: true`? +- Sensitive data in URL params/query strings? +- Request/response logging in production? (must be off) + +### Code injection risks +- Dynamic code execution patterns +- WebView usage — JavaScript interface security +- Deep link parameter validation (no path traversal) + +## Output format +For each finding: +``` +[RISK: Critical/High/Medium/Low] +LOCATION: File / function +ISSUE: Detailed description +CVSS-like impact: Confidentiality/Integrity/Availability +REMEDIATION: Specific code fix +``` diff --git a/flutter-cursor-templates/templates/agents/test-writer.mdc.tmpl b/flutter-cursor-templates/templates/agents/test-writer.mdc.tmpl new file mode 100644 index 0000000..f05de3c --- /dev/null +++ b/flutter-cursor-templates/templates/agents/test-writer.mdc.tmpl @@ -0,0 +1,33 @@ +--- +name: test-writer +description: "Writes {{STATE_MANAGEMENT}} unit tests for {{PROJECT_NAME}}. Ask: 'Write tests for [class]' or '@test-writer generate tests'" +model: claude-sonnet-4-20250514 +context: auto +allowed-tools: [read_file, write_file, list_files] +--- + +You are a Flutter test engineer for **{{PROJECT_NAME}}** using **{{STATE_MANAGEMENT}}**. + +## Test pattern to follow +```dart +{{TEST_PATTERN}} +``` + +## When asked to write tests: +1. Read the source file completely +2. Identify all public methods and state transitions +3. Write tests for: + - Happy path (successful operation) + - Error path (failure, exception handling) + - Edge cases (empty data, boundary values) +4. Use `mocktail` for all mocking +5. Follow `Given/When/Then` naming: `'given X, when Y, then emits Z'` + +## File placement +- Unit tests: `test/features/[feature]/[file]_test.dart` +- Widget tests: `test/features/[feature]/[screen]_widget_test.dart` +- Coverage target: 80% minimum for business logic classes + +## Output +Write the complete test file, ready to run. Include all imports. +After writing, run: `dart test path/to/test_file.dart` diff --git a/flutter-cursor-templates/templates/agents/ui-validator.mdc.tmpl b/flutter-cursor-templates/templates/agents/ui-validator.mdc.tmpl new file mode 100644 index 0000000..8033838 --- /dev/null +++ b/flutter-cursor-templates/templates/agents/ui-validator.mdc.tmpl @@ -0,0 +1,47 @@ +--- +name: ui-validator +description: "Validates {{PROJECT_NAME}} UI against design system and UX standards. Ask: 'Validate this screen' or '@ui-validator check'" +model: claude-sonnet-4-20250514 +context: auto +allowed-tools: [read_file, list_files] +--- + +You are a UI/UX validator for **{{PROJECT_NAME}}**. + +## Validate every screen for: + +### State coverage ({{STATE_MANAGEMENT}}) +- Loading state: shows shimmer (NOT spinner unless brief) +- Empty state: shows illustration + CTA (NOT blank screen) +- Error state: shows message + retry button (NOT toast-only) +- Data state: renders content correctly + +### Accessibility +- All interactive widgets have semantic labels +- Minimum touch targets: 48×48dp +- Sufficient color contrast (4.5:1 minimum) + +### Responsive layout +- No hardcoded pixel widths +- Tested at 375px and 414px viewport widths +- `SafeArea` used correctly on iOS + +### Platform-specific ({{PLATFORMS_LIST}}) +{{#if web}} +- No dart:io imports in web-targeted code +- PWA-compatible (no native-only APIs without fallbacks) +{{/if}} +{{#if ios}} +- Safe area respected (notch, Dynamic Island) +- iOS Human Interface Guidelines followed +{{/if}} + +### Security (UI layer) +- No credentials shown in plaintext +- Sensitive screens wrapped with screenshot prevention + +## Output +List each violation with: +- Location (file:widget) +- What's wrong +- How to fix it diff --git a/flutter-cursor-templates/templates/hooks/arch-guard.ts.tmpl b/flutter-cursor-templates/templates/hooks/arch-guard.ts.tmpl new file mode 100644 index 0000000..f53d62d --- /dev/null +++ b/flutter-cursor-templates/templates/hooks/arch-guard.ts.tmpl @@ -0,0 +1,141 @@ +#!/usr/bin/env ts-node +// arch-guard.ts — Pre-commit hook: enforces {{ARCHITECTURE}} import boundaries +// Generated for {{PROJECT_NAME}} ({{ARCHITECTURE}} architecture) + +import * as fs from 'fs'; +import * as path from 'path'; +import { execSync } from 'child_process'; + +const RED = '\x1b[31m'; +const YELLOW = '\x1b[33m'; +const GREEN = '\x1b[32m'; +const RESET = '\x1b[0m'; + +interface ArchRule { + sourcePattern: RegExp; + forbiddenImports: RegExp[]; + message: string; +} + +// Architecture-specific rules for {{ARCHITECTURE}} +const ARCH_RULES: ArchRule[] = [ + ...getArchRules() +]; + +function getArchRules(): ArchRule[] { + const arch = '{{ARCH_RAW}}'; + + if (arch === 'clean') { + return [ + { + sourcePattern: /features\/\w+\/domain\//, + forbiddenImports: [/features\/\w+\/data\//, /features\/\w+\/presentation\//], + message: 'domain/ must not import from data/ or presentation/', + }, + { + sourcePattern: /features\/\w+\/presentation\//, + forbiddenImports: [/features\/\w+\/data\//], + message: 'presentation/ must not import from data/ directly (use domain interfaces)', + }, + ]; + } + + if (arch === 'feature_first') { + return [ + { + // A feature file must not import from another feature + sourcePattern: /features\/(\w+)\//, + forbiddenImports: [], // checked dynamically below + message: 'Features must not import from other feature folders', + }, + ]; + } + + if (arch === 'mvvm') { + return [ + { + sourcePattern: /viewmodels\//, + forbiddenImports: [/package:flutter\//, /widgets\//], + message: 'ViewModels must not import Flutter widgets — they must be plain Dart testable', + }, + ]; + } + + return []; +} + +function getChangedDartFiles(): string[] { + try { + const result = execSync('git diff --cached --name-only --diff-filter=ACM', { encoding: 'utf8' }); + return result.split('\n').filter(f => f.endsWith('.dart') && fs.existsSync(f)); + } catch { + return []; + } +} + +function checkFile(filePath: string): string[] { + const violations: string[] = []; + const content = fs.readFileSync(filePath, 'utf8'); + const imports = content.match(/^import\s+'[^']+'/gm) ?? []; + + for (const rule of ARCH_RULES) { + if (!rule.sourcePattern.test(filePath)) continue; + + // Feature-first cross-feature check + if ('{{ARCH_RAW}}' === 'feature_first') { + const srcFeatureMatch = filePath.match(/features\/(\w+)\//); + if (srcFeatureMatch) { + const srcFeature = srcFeatureMatch[1]; + for (const imp of imports) { + const impFeatureMatch = imp.match(/features\/(\w+)\//); + if (impFeatureMatch && impFeatureMatch[1] !== srcFeature) { + violations.push( + `${RED}ARCH VIOLATION${RESET} in ${filePath}:\n` + + ` ${YELLOW}${rule.message}${RESET}\n` + + ` Import: ${imp}\n` + + ` Fix: Move shared code to core/ or shared/` + ); + } + } + } + continue; + } + + // Standard forbidden import check + for (const imp of imports) { + for (const forbidden of rule.forbiddenImports) { + if (forbidden.test(imp)) { + violations.push( + `${RED}ARCH VIOLATION${RESET} in ${filePath}:\n` + + ` ${YELLOW}${rule.message}${RESET}\n` + + ` Import: ${imp}` + ); + } + } + } + } + + return violations; +} + +const changedFiles = getChangedDartFiles(); +if (changedFiles.length === 0) { + console.log(`${GREEN}✔ arch-guard: no Dart files changed${RESET}`); + process.exit(0); +} + +console.log(`🏛 arch-guard checking ${changedFiles.length} file(s) for {{ARCHITECTURE}} violations...`); + +const allViolations: string[] = []; +for (const file of changedFiles) { + allViolations.push(...checkFile(file)); +} + +if (allViolations.length > 0) { + console.error(`\n${RED}✘ Architecture boundary violations detected:${RESET}\n`); + for (const v of allViolations) console.error(v + '\n'); + console.error(`Total: ${allViolations.length} violation(s). Fix before committing.`); + process.exit(1); +} + +console.log(`${GREEN}✔ arch-guard: no violations found${RESET}`); diff --git a/flutter-cursor-templates/templates/hooks/flutter-analyze.ts.tmpl b/flutter-cursor-templates/templates/hooks/flutter-analyze.ts.tmpl new file mode 100644 index 0000000..a8918a7 --- /dev/null +++ b/flutter-cursor-templates/templates/hooks/flutter-analyze.ts.tmpl @@ -0,0 +1,32 @@ +#!/usr/bin/env ts-node +// flutter-analyze.ts — Pre-commit hook: runs dart analyze and dart format check +import { execSync } from 'child_process'; + +const RED = '\x1b[31m'; +const GREEN = '\x1b[32m'; +const RESET = '\x1b[0m'; + +function run(cmd: string): { stdout: string; code: number } { + try { + const stdout = execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); + return { stdout, code: 0 }; + } catch (e: any) { + return { stdout: e.stdout ?? e.message, code: e.status ?? 1 }; + } +} + +console.log('🔍 Running flutter analyze...'); +const analyze = run('flutter analyze --no-congratulate'); +if (analyze.code !== 0) { + console.error(`${RED}✘ flutter analyze failed:\n${analyze.stdout}${RESET}`); + process.exit(1); +} + +console.log('🎨 Checking dart format...'); +const format = run('dart format --output=none --set-exit-if-changed lib/ test/'); +if (format.code !== 0) { + console.error(`${RED}✘ Unformatted files detected. Run: dart format lib/ test/${RESET}`); + process.exit(1); +} + +console.log(`${GREEN}✔ flutter analyze + dart format passed${RESET}`); diff --git a/flutter-cursor-templates/templates/hooks/grind-tests.ts.tmpl b/flutter-cursor-templates/templates/hooks/grind-tests.ts.tmpl new file mode 100644 index 0000000..dd6385c --- /dev/null +++ b/flutter-cursor-templates/templates/hooks/grind-tests.ts.tmpl @@ -0,0 +1,16 @@ +#!/usr/bin/env ts-node +// grind-tests.ts — Pre-push hook: runs flutter test +import { execSync } from 'child_process'; + +const RED = '\x1b[31m'; +const GREEN = '\x1b[32m'; +const RESET = '\x1b[0m'; + +console.log('🧪 Running flutter test...'); +try { + execSync('flutter test --coverage', { stdio: 'inherit' }); + console.log(`${GREEN}✔ All tests passed${RESET}`); +} catch { + console.error(`${RED}✘ Tests failed. Fix failing tests before pushing.${RESET}`); + process.exit(1); +} diff --git a/flutter-cursor-templates/templates/hooks/hooks.json.tmpl b/flutter-cursor-templates/templates/hooks/hooks.json.tmpl new file mode 100644 index 0000000..e74d712 --- /dev/null +++ b/flutter-cursor-templates/templates/hooks/hooks.json.tmpl @@ -0,0 +1,19 @@ +{ + "hooks": [ + { + "name": "pre-commit: flutter analyze", + "command": "npx ts-node .cursor/hooks/flutter-analyze.ts", + "events": ["pre-commit"] + }, + { + "name": "pre-commit: arch guard", + "command": "npx ts-node .cursor/hooks/arch-guard.ts", + "events": ["pre-commit"] + }, + { + "name": "pre-push: run tests", + "command": "npx ts-node .cursor/hooks/grind-tests.ts", + "events": ["pre-push"] + } + ] +} diff --git a/flutter-cursor-templates/templates/rules/architecture/clean.mdc.tmpl b/flutter-cursor-templates/templates/rules/architecture/clean.mdc.tmpl new file mode 100644 index 0000000..a35cb52 --- /dev/null +++ b/flutter-cursor-templates/templates/rules/architecture/clean.mdc.tmpl @@ -0,0 +1,53 @@ +--- +description: "Clean Architecture conventions for {{PROJECT_NAME}}" +alwaysApply: true +--- + +# Clean Architecture — {{PROJECT_NAME}} + +## Layer structure +``` +lib/features/[feature]/ + ├── domain/ + │ ├── entities/ ← Pure Dart, no framework imports + │ ├── repositories/ ← Abstract interfaces only + │ └── usecases/ ← Single-responsibility business operations + ├── data/ + │ ├── datasources/ ← Remote (API) + Local (cache) implementations + │ ├── models/ ← DTOs with fromJson/toJson (can use Freezed) + │ └── repositories/ ← Implements domain/repositories interfaces + └── presentation/ + ├── bloc/ or notifiers/ + ├── pages/ + └── widgets/ +``` + +## Import rules (STRICTLY ENFORCED by arch-guard hook) +{{ARCH_IMPORT_RULES}} + +## UseCase pattern +```dart +// One UseCase = one operation = one method +class GetProductsUseCase { + final ProductRepository _repository; + const GetProductsUseCase(this._repository); + + Future>> call(ProductFilter filter) => + _repository.getProducts(filter); +} +``` + +## Entity rules +- Entities are pure Dart — zero Flutter or framework imports +- Entities are immutable — use `final` fields + factory constructors +- Entities NEVER have `fromJson`/`toJson` — that belongs in the data layer model + +## Repository rules +- Domain defines the **interface** (abstract class) +- Data layer **implements** it +- Use `Either` or `Result` return types — never throw in domain + +## Dependency injection +- Use `injectable` + `get_it` if `codegen` includes `injectable` +- All UseCases injected into BLoC/Notifier via constructor +- `DataSource → Repository → UseCase → Bloc` dependency direction diff --git a/flutter-cursor-templates/templates/rules/architecture/feature_first.mdc.tmpl b/flutter-cursor-templates/templates/rules/architecture/feature_first.mdc.tmpl new file mode 100644 index 0000000..fb4c5d9 --- /dev/null +++ b/flutter-cursor-templates/templates/rules/architecture/feature_first.mdc.tmpl @@ -0,0 +1,49 @@ +--- +description: "Feature-First architecture conventions for {{PROJECT_NAME}}" +alwaysApply: true +--- + +# Feature-First Architecture — {{PROJECT_NAME}} + +## Folder structure +``` +lib/ + features/ + auth/ + auth_screen.dart + auth_provider.dart (or auth_bloc.dart) + auth_repository.dart + auth_model.dart + widgets/ + login_form.dart + social_login_button.dart + home/ + home_screen.dart + home_provider.dart + ... + core/ + di/ ← Dependency injection setup + network/ ← HTTP client, interceptors + storage/ ← Local storage abstraction + widgets/ ← Shared widgets used by 3+ features + theme/ ← App theme, colors, text styles + routing/ ← Router config + shared/ + models/ ← Models shared across features + utils/ ← Pure utility functions +``` + +## Import rules (STRICTLY ENFORCED by arch-guard hook) +{{ARCH_IMPORT_RULES}} + +## Feature isolation rules +- A feature folder is self-contained: its own screen, state, model, repository +- Features MUST NOT import from other feature folders +- Shared code MUST be extracted to `core/` or `shared/` before sharing +- The 3-feature rule: if 3+ features need the same widget → move it to `core/widgets/` + +## File naming within a feature +- `[feature]_screen.dart` — the main screen widget +- `[feature]_provider.dart` — Riverpod providers (or `[feature]_bloc.dart`) +- `[feature]_repository.dart` — data fetching + caching +- `[feature]_model.dart` — data model (Freezed or plain Dart) diff --git a/flutter-cursor-templates/templates/rules/architecture/layered.mdc.tmpl b/flutter-cursor-templates/templates/rules/architecture/layered.mdc.tmpl new file mode 100644 index 0000000..351389c --- /dev/null +++ b/flutter-cursor-templates/templates/rules/architecture/layered.mdc.tmpl @@ -0,0 +1,17 @@ +--- +description: "Layered architecture conventions for {{PROJECT_NAME}}" +alwaysApply: true +--- + +# Layered Architecture — {{PROJECT_NAME}} + +## Layers (top → bottom) +``` +Presentation → Service/BLoC → Repository → Data Source +``` + +## Rules +- Dependencies flow downward only — upper layers depend on lower layers +- Each layer communicates via interfaces (abstract classes) +- Data transformations happen at layer boundaries (DTOs → domain models) +{{ARCH_IMPORT_RULES}} diff --git a/flutter-cursor-templates/templates/rules/architecture/mvc.mdc.tmpl b/flutter-cursor-templates/templates/rules/architecture/mvc.mdc.tmpl new file mode 100644 index 0000000..222eb87 --- /dev/null +++ b/flutter-cursor-templates/templates/rules/architecture/mvc.mdc.tmpl @@ -0,0 +1,20 @@ +--- +description: "MVC architecture conventions for {{PROJECT_NAME}}" +alwaysApply: true +--- + +# MVC Architecture — {{PROJECT_NAME}} + +## Layer responsibilities +- **Model**: Data + business logic. Pure Dart. +- **View**: Renders UI, observes Controller state. No business logic. +- **Controller** (GetX): Connects Model ↔ View. Manages state transitions. + +## Import rules +{{ARCH_IMPORT_RULES}} + +## Controller rules +- Controllers are injected via `Binding`, never created in widgets +- One Controller per feature screen (not per widget) +- Controllers fetch data in `onInit()`, clean up in `onClose()` +- All reactive state marked with `.obs` diff --git a/flutter-cursor-templates/templates/rules/architecture/mvvm.mdc.tmpl b/flutter-cursor-templates/templates/rules/architecture/mvvm.mdc.tmpl new file mode 100644 index 0000000..be1b9a0 --- /dev/null +++ b/flutter-cursor-templates/templates/rules/architecture/mvvm.mdc.tmpl @@ -0,0 +1,37 @@ +--- +description: "MVVM architecture conventions for {{PROJECT_NAME}}" +alwaysApply: true +--- + +# MVVM Architecture — {{PROJECT_NAME}} + +## Layer responsibilities +- **View** (Widget): Renders UI, dispatches user actions to ViewModel. Zero business logic. +- **ViewModel**: Holds UI state, calls Model/Repository, exposes streams/notifiers. Zero Flutter imports. +- **Model**: Plain Dart data structures + repository interfaces. + +## Import rules +{{ARCH_IMPORT_RULES}} + +## ViewModel rules +```dart +// ViewModel has NO Flutter imports — testable with plain dart test +class AuthViewModel extends ChangeNotifier { + final AuthRepository _repository; + AuthViewModel(this._repository); + + AuthViewState _state = const AuthViewState.initial(); + AuthViewState get state => _state; + + Future login(String email, String password) async { + _state = const AuthViewState.loading(); + notifyListeners(); + final result = await _repository.login(email, password); + _state = result.fold( + (error) => AuthViewState.error(error.message), + (user) => AuthViewState.success(user), + ); + notifyListeners(); + } +} +``` diff --git a/flutter-cursor-templates/templates/rules/backend/firebase.mdc.tmpl b/flutter-cursor-templates/templates/rules/backend/firebase.mdc.tmpl new file mode 100644 index 0000000..be75e9f --- /dev/null +++ b/flutter-cursor-templates/templates/rules/backend/firebase.mdc.tmpl @@ -0,0 +1,49 @@ +--- +description: "Firebase conventions for {{PROJECT_NAME}}" +alwaysApply: true +--- + +# Firebase Standards — {{PROJECT_NAME}} + +## Firestore +- Collection names: `camelCase` plural — `users`, `products`, `orderItems` +- Document IDs: use Firebase auto-IDs unless a natural key exists +- **Streams vs Futures**: use `snapshots()` for live data, `get()` for one-time reads +- Always handle `FirebaseException` explicitly — catch by `e.code` not generic `Exception` +- Paginate large collections with `startAfterDocument` — never fetch unbounded collections + +```dart +// ✅ Stream-based real-time listener +Stream> watchProducts() { + return _firestore.collection('products') + .where('isActive', isEqualTo: true) + .snapshots() + .map((snap) => snap.docs.map(Product.fromDoc).toList()); +} + +// ✅ Handle FirebaseException by code +try { + await _firestore.collection('orders').add(order.toMap()); +} on FirebaseException catch (e) { + switch (e.code) { + case 'permission-denied': throw AppError.authError('Insufficient permissions'); + case 'unavailable': throw AppError.networkError(); + default: throw AppError.unknown(e); + } +} +``` + +## Firebase Auth +- Always use `authStateChanges()` stream — never cache auth state locally +- Handle all error codes: `user-not-found`, `wrong-password`, `email-already-in-use`, `network-request-failed` +- Sign-out: clear all local state AND call `FirebaseAuth.instance.signOut()` + +## Cloud Functions +- Call via `FirebaseFunctions.instance.httpsCallable('functionName')` +- Handle `FirebaseFunctionsException` with `.code` and `.message` +- Never expose internal errors to client — functions return structured error responses + +## Security (complement to security-standards.mdc) +- Firestore Security Rules must be tested with the emulator before deploying +- No `allow read, write: if true` — even in development +- Rule coverage: every collection must have explicit rules diff --git a/flutter-cursor-templates/templates/rules/backend/realtime.mdc.tmpl b/flutter-cursor-templates/templates/rules/backend/realtime.mdc.tmpl new file mode 100644 index 0000000..2fc1669 --- /dev/null +++ b/flutter-cursor-templates/templates/rules/backend/realtime.mdc.tmpl @@ -0,0 +1,25 @@ +--- +description: "Real-time feature conventions for {{PROJECT_NAME}}" +alwaysApply: true +--- + +# Real-time Features — {{PROJECT_NAME}} + +## Connection management +- Always expose a `connectionState` stream — UI must show "offline" indicator +- Implement exponential backoff for reconnection (1s, 2s, 4s, 8s, max 60s) +- Cancel all subscriptions in `dispose()` — memory leaks are the #1 bug in real-time apps + +## Offline-first strategy +- Cache last known state locally (Hive, Drift, or Isar) +- Show stale data with a "last updated" timestamp while reconnecting +- Queue mutations offline, replay on reconnect (use `connectivity_plus`) + +## WebSocket / SSE +- Use `web_socket_channel` for WebSocket — never raw `dart:io` WebSocket +- Implement heartbeat/ping to detect dead connections +- Parse and validate all incoming messages — never trust raw server data + +## UI indicators +- Show a persistent banner when offline: "You're offline — changes will sync when reconnected" +- Animate the banner away on reconnection — don't just hide it abruptly diff --git a/flutter-cursor-templates/templates/rules/backend/rest.mdc.tmpl b/flutter-cursor-templates/templates/rules/backend/rest.mdc.tmpl new file mode 100644 index 0000000..f108d26 --- /dev/null +++ b/flutter-cursor-templates/templates/rules/backend/rest.mdc.tmpl @@ -0,0 +1,52 @@ +--- +description: "REST API conventions for {{PROJECT_NAME}}" +alwaysApply: true +--- + +# REST API Standards — {{PROJECT_NAME}} + +## HTTP client setup (Dio) +```dart +// In core/network/dio_client.dart +Dio createDioClient({required AppConfig config}) { + final dio = Dio(BaseOptions( + baseUrl: config.baseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + headers: {'Content-Type': 'application/json', 'Accept': 'application/json'}, + )); + + dio.interceptors.addAll([ + AuthInterceptor(tokenStorage: getIt()), + LoggingInterceptor(), // debug builds only + RetryInterceptor(dio), // 3 retries on network errors + ]); + return dio; +} +``` + +## DTOs (Data Transfer Objects) +- DTOs live in the `data/` layer — **never** pass raw JSON maps to the domain layer +- Use Freezed for DTOs if `codegen` includes `json_serializable` or `freezed` +- `fromJson` factory must handle null fields gracefully — API response fields are not guaranteed + +## Error handling +```dart +// Map HTTP errors → AppError domain types +AppError _mapDioError(DioException e) => switch (e.type) { + DioExceptionType.connectionTimeout => const NetworkError(statusCode: null), + DioExceptionType.receiveTimeout => const NetworkError(statusCode: null), + DioExceptionType.badResponse => _mapStatusCode(e.response?.statusCode), + DioExceptionType.connectionError => const NetworkError(statusCode: null), + _ => UnknownError(e), +}; +``` + +## API versioning +- Base URL includes version: `https://api.{{PROJECT_NAME}}.com/v1/` +- When upgrading API version, keep old version working until all clients migrate + +## Auth token interceptor +- Inject `Authorization: Bearer ` automatically on every request +- On 401: refresh token once, retry original request, then logout if refresh fails +- On 403: map to `AppError.authError('Insufficient permissions')` diff --git a/flutter-cursor-templates/templates/rules/backend/supabase.mdc.tmpl b/flutter-cursor-templates/templates/rules/backend/supabase.mdc.tmpl new file mode 100644 index 0000000..0cbcd7a --- /dev/null +++ b/flutter-cursor-templates/templates/rules/backend/supabase.mdc.tmpl @@ -0,0 +1,52 @@ +--- +description: "Supabase conventions for {{PROJECT_NAME}}" +alwaysApply: true +--- + +# Supabase Standards — {{PROJECT_NAME}} + +## Row Level Security (RLS) awareness +- **ALWAYS** assume RLS is enabled — never write queries that assume full table access +- Test queries in the Supabase dashboard before implementing in Flutter +- If a query returns empty unexpectedly, check RLS policies first + +## Queries +```dart +// ✅ Type-safe query with error handling +Future> getProducts() async { + final response = await _supabase + .from('products') + .select('id, name, price, category_id, categories(name)') + .eq('is_active', true) + .order('created_at', ascending: false) + .limit(50); + return response.map(Product.fromMap).toList(); +} +``` + +## Realtime subscriptions +```dart +StreamSubscription>>? _sub; + +void watchOrders(String userId) { + _sub = _supabase + .from('orders') + .stream(primaryKey: ['id']) + .eq('user_id', userId) + .listen( + (data) => _updateOrders(data), + onError: (e) => _handleError(e), + ); +} + +@override +void dispose() { + _sub?.cancel(); // ALWAYS cancel in dispose() + super.dispose(); +} +``` + +## Auth session +- Use `supabase.auth.onAuthStateChange` stream — never poll auth state +- Persist session: `supabase-flutter` handles this automatically via secure storage +- `session.accessToken` expires — check `session.isExpired` before sensitive operations diff --git a/flutter-cursor-templates/templates/rules/codegen/codegen-freezed.mdc.tmpl b/flutter-cursor-templates/templates/rules/codegen/codegen-freezed.mdc.tmpl new file mode 100644 index 0000000..757850f --- /dev/null +++ b/flutter-cursor-templates/templates/rules/codegen/codegen-freezed.mdc.tmpl @@ -0,0 +1,64 @@ +--- +description: "Freezed code generation conventions for {{PROJECT_NAME}} — Pillar 4" +alwaysApply: true +--- + +# Freezed Standards — {{PROJECT_NAME}} + +## When to use Freezed +- All domain entities and data models — use `@freezed` +- Union types / sealed classes — use `@freezed` with multiple constructors +- BLoC states and events — use `@freezed` + +## Model pattern +```dart +@freezed +class Product with _$Product { + const factory Product({ + required String id, + required String name, + required double price, + @Default(0) int stockCount, + String? imageUrl, + }) = _Product; + + factory Product.fromJson(Map json) => _$ProductFromJson(json); +} +``` + +## Union type pattern +```dart +@freezed +sealed class ProductState with _$ProductState { + const factory ProductState.initial() = ProductInitial; + const factory ProductState.loading() = ProductLoading; + const factory ProductState.loaded(List products) = ProductLoaded; + const factory ProductState.error(String message) = ProductError; +} + +// Usage — exhaustive switch +final widget = state.when( + initial: () => const SizedBox.shrink(), + loading: () => const ProductShimmer(), + loaded: (products) => ProductList(products: products), + error: (msg) => ErrorWidget(message: msg), +); +``` + +## Critical rules +- **NEVER** edit `.freezed.dart` or `.g.dart` files — they are generated +- Run `dart run build_runner build --delete-conflicting-outputs` after changes +- Run `dart run build_runner watch` during development +- Add `*.freezed.dart` and `*.g.dart` to `.gitignore` (or commit them — team must decide) +- Always define `copyWith` via Freezed — never write manual `copyWith` + +## Patterns to avoid +```dart +// ❌ Manual copyWith — replaced by Freezed +Product copyWith({String? name, double? price}) => Product( + id: id, name: name ?? this.name, price: price ?? this.price, +); + +// ✅ Let Freezed generate it +product.copyWith(name: 'New Name', price: 9.99) +``` diff --git a/flutter-cursor-templates/templates/rules/codegen/codegen-injectable.mdc.tmpl b/flutter-cursor-templates/templates/rules/codegen/codegen-injectable.mdc.tmpl new file mode 100644 index 0000000..6550292 --- /dev/null +++ b/flutter-cursor-templates/templates/rules/codegen/codegen-injectable.mdc.tmpl @@ -0,0 +1,42 @@ +--- +description: "Injectable dependency injection conventions for {{PROJECT_NAME}} — Pillar 4" +alwaysApply: true +--- + +# Injectable (get_it) Standards — {{PROJECT_NAME}} + +## Setup +```dart +// lib/core/di/injection.dart +@InjectableInit() +void configureDependencies() => getIt.init(); +``` + +## Annotations +```dart +@singleton // One instance for app lifetime +@lazySingleton // Created on first access (preferred for most services) +@injectable // New instance each time (use sparingly) +@factoryMethod // Custom factory logic +``` + +## Example +```dart +@lazySingleton +class ProductRepository { + final DioClient _client; + const ProductRepository(this._client); // Constructor injection +} + +@injectable +class GetProductsUseCase { + final ProductRepository _repo; + const GetProductsUseCase(this._repo); +} +``` + +## Rules +- Run `dart run build_runner build` after adding/modifying `@injectable` annotations +- **NEVER** use `getIt()` in widget `build()` methods — inject via constructor or provider +- Use `@module` for third-party registrations (Dio, SharedPreferences, etc.) +- Register environment-specific implementations with `@Environment('dev')` / `@Environment('prod')` diff --git a/flutter-cursor-templates/templates/rules/codegen/codegen-json_serializable.mdc.tmpl b/flutter-cursor-templates/templates/rules/codegen/codegen-json_serializable.mdc.tmpl new file mode 100644 index 0000000..b0f5a4e --- /dev/null +++ b/flutter-cursor-templates/templates/rules/codegen/codegen-json_serializable.mdc.tmpl @@ -0,0 +1,37 @@ +--- +description: "json_serializable conventions for {{PROJECT_NAME}} — Pillar 4" +alwaysApply: true +--- + +# json_serializable Standards — {{PROJECT_NAME}} + +## Model annotation +```dart +@JsonSerializable(explicitToJson: true) // explicitToJson for nested objects +class ProductDto { + final String id; + final String name; + @JsonKey(name: 'unit_price') // snake_case API → camelCase Dart + final double unitPrice; + @JsonKey(defaultValue: false) + final bool isActive; + final DateTime createdAt; // auto-converted from ISO 8601 string + + const ProductDto({ + required this.id, + required this.name, + required this.unitPrice, + required this.isActive, + required this.createdAt, + }); + + factory ProductDto.fromJson(Map json) => _$ProductDtoFromJson(json); + Map toJson() => _$ProductDtoToJson(this); +} +``` + +## Critical rules +- **NEVER** edit `*.g.dart` files +- Use `@JsonKey(defaultValue: ...)` for nullable API fields — API contracts change +- Use `explicitToJson: true` whenever the model has nested objects +- Null safety: API fields not guaranteed to be non-null should be `String?` not `String` diff --git a/flutter-cursor-templates/templates/rules/codegen/codegen-retrofit.mdc.tmpl b/flutter-cursor-templates/templates/rules/codegen/codegen-retrofit.mdc.tmpl new file mode 100644 index 0000000..4e10bd3 --- /dev/null +++ b/flutter-cursor-templates/templates/rules/codegen/codegen-retrofit.mdc.tmpl @@ -0,0 +1,30 @@ +--- +description: "Retrofit (Dio) API client conventions for {{PROJECT_NAME}} — Pillar 4" +alwaysApply: true +--- + +# Retrofit Standards — {{PROJECT_NAME}} + +## API client definition +```dart +@RestApi() +abstract class ProductApiClient { + factory ProductApiClient(Dio dio, {String? baseUrl}) = _ProductApiClient; + + @GET('/products') + Future> getProducts(@Query('category') String? category); + + @GET('/products/{id}') + Future getProduct(@Path('id') String id); + + @POST('/products') + Future createProduct(@Body() CreateProductDto dto); +} +``` + +## Rules +- **NEVER** edit `*.g.dart` files +- Run `dart run build_runner build` after modifying API client +- All DTOs used in Retrofit must have `fromJson`/`toJson` (via `json_serializable` or Freezed) +- Handle `DioException` in the repository layer — never let it reach the presentation layer +- Use `@Headers({'Content-Type': 'application/json'})` at class level, not per-method diff --git a/flutter-cursor-templates/templates/rules/error-handling/error-handling.mdc.tmpl b/flutter-cursor-templates/templates/rules/error-handling/error-handling.mdc.tmpl new file mode 100644 index 0000000..80c433e --- /dev/null +++ b/flutter-cursor-templates/templates/rules/error-handling/error-handling.mdc.tmpl @@ -0,0 +1,67 @@ +--- +description: "Global error handling strategy for {{PROJECT_NAME}} — always applied" +alwaysApply: true +--- + +# Global Error Handling — {{PROJECT_NAME}} + +## Flutter error boundaries +Configure in `main.dart` — do this ONCE and never bypass it: + +```dart +void main() { + FlutterError.onError = (details) { + FlutterError.presentError(details); + // TODO: Send to crash reporter (Sentry/Firebase Crashlytics) + crashReporter.recordFlutterError(details); + }; + + PlatformDispatcher.instance.onError = (error, stack) { + // TODO: Send to crash reporter + crashReporter.recordError(error, stack, fatal: true); + return true; + }; + + runApp(const MyApp()); +} +``` + +## Error types hierarchy +Define a sealed class for domain errors — never throw raw exceptions in business logic: + +```dart +sealed class AppError { + const AppError(); +} +class NetworkError extends AppError { final int? statusCode; const NetworkError({this.statusCode}); } +class AuthError extends AppError { final String reason; const AuthError(this.reason); } +class NotFoundError extends AppError { final String resource; const NotFoundError(this.resource); } +class UnknownError extends AppError { final Object cause; const UnknownError(this.cause); } +``` + +## Repository layer +- Wrap ALL external calls in try/catch and return `Either` or `Result` +- NEVER let raw exceptions bubble to the presentation layer +- Log at the repository layer, not the UI layer + +## Presentation layer +- Every async widget MUST handle error state explicitly — no silent failures +- Show user-friendly error messages: map `AppError` subtype → readable string +- Provide a "Try again" action for recoverable errors (network, timeout) +- For fatal errors (auth expired), redirect to login — never show a dead screen + +## Crash reporting +- Integrate Sentry or Firebase Crashlytics before first TestFlight / Play beta +- Set `user` context on crash reporter after login (id only, no PII) +- Add `breadcrumbs` for key user actions to aid reproduction + +## Logging strategy +``` +Level | Use case +DEBUG | Development only (strip from release) +INFO | Key user flows (login, purchase, etc.) +WARNING | Recoverable errors, fallbacks used +ERROR | Unrecoverable errors, unexpected states +``` +- Use `logger` package — never bare `print()` +- Logger instance per class: `final _log = Logger('ClassName');` diff --git a/flutter-cursor-templates/templates/rules/i18n/localization.mdc.tmpl b/flutter-cursor-templates/templates/rules/i18n/localization.mdc.tmpl new file mode 100644 index 0000000..dd79604 --- /dev/null +++ b/flutter-cursor-templates/templates/rules/i18n/localization.mdc.tmpl @@ -0,0 +1,53 @@ +--- +description: "Localization / i18n conventions for {{PROJECT_NAME}}" +alwaysApply: true +--- + +# Localization Standards — {{PROJECT_NAME}} + +## Supported locales: {{LOCALES_LIST}} + +## Setup +- Use Flutter's built-in `AppLocalizations` (generated from `.arb` files) +- ARB files: `lib/l10n/app_en.arb`, `lib/l10n/app_fr.arb`, etc. +- Never hardcode user-facing strings — always use `context.l10n.stringKey` + +## AppLocalizations access +```dart +// In widgets: +final l10n = AppLocalizations.of(context)!; +Text(l10n.welcomeMessage) // ✅ + +// Extension for convenience: +extension LocalizationX on BuildContext { + AppLocalizations get l10n => AppLocalizations.of(this)!; +} +Text(context.l10n.welcomeMessage) // ✅ +``` + +## ARB file format +```json +{ + "welcomeMessage": "Welcome back, {name}!", + "@welcomeMessage": { + "description": "Shown on home screen after login", + "placeholders": { + "name": { "type": "String", "example": "Alice" } + } + }, + "itemCount": "{count, plural, =0{No items} =1{1 item} other{{count} items}}", + "@itemCount": { + "placeholders": { + "count": { "type": "int" } + } + } +} +``` + +## Rules +- **NEVER** hardcode user-facing strings as string literals in widget files +- All new strings added to ALL locale ARB files simultaneously — broken translations break builds +- Use ICU message format for plurals and gendered strings +- Date/time formatting: use `intl` package `DateFormat` — never `date.toString()` +- Currency: use `NumberFormat.currency(locale: locale)` — never manual formatting +- RTL support: wrap with `Directionality` where needed; test with Arabic locale diff --git a/flutter-cursor-templates/templates/rules/platform/platform-android.mdc.tmpl b/flutter-cursor-templates/templates/rules/platform/platform-android.mdc.tmpl new file mode 100644 index 0000000..fa2ebe2 --- /dev/null +++ b/flutter-cursor-templates/templates/rules/platform/platform-android.mdc.tmpl @@ -0,0 +1,34 @@ +--- +description: "Android-specific conventions for {{PROJECT_NAME}} — Pillar 4" +alwaysApply: true +--- + +# Android Platform Standards — {{PROJECT_NAME}} + +## Target SDK +- `compileSdkVersion` / `targetSdkVersion`: 34 (Android 14) minimum for new apps +- `minSdkVersion`: 21 (Android 5.0) unless brief specifies otherwise +- Update `android/app/build.gradle` when bumping target SDK + +## Permissions +- Declare only needed permissions in `AndroidManifest.xml` +- Runtime permissions: use `permission_handler` — never skip rationale step +- Android 13+: granular media permissions (`READ_MEDIA_IMAGES` not `READ_EXTERNAL_STORAGE`) +- Android 14+: `FOREGROUND_SERVICE_TYPE` required for foreground services + +## Adaptive icons +- Provide both foreground and background layers in `android/app/src/main/res/` +- Test on dark theme, coloured theme, and themed icons (Android 13+) + +## Deep links / App Links +- Verify domain ownership: `.well-known/assetlinks.json` on your server +- Test with: `adb shell am start -a android.intent.action.VIEW -d "https://{{PROJECT_NAME}}.com/products/123"` + +## ProGuard / R8 +- Obfuscation rules in `android/app/proguard-rules.pro` +- Keep rules for: Dio, Freezed models, `@JsonKey` annotated classes +- Test release build thoroughly — obfuscation can break reflection-based code + +## Notifications +- Create notification channels before showing any notification (Android 8+) +- Notification icons must be monochrome on Android 5+ diff --git a/flutter-cursor-templates/templates/rules/platform/platform-desktop.mdc.tmpl b/flutter-cursor-templates/templates/rules/platform/platform-desktop.mdc.tmpl new file mode 100644 index 0000000..172360f --- /dev/null +++ b/flutter-cursor-templates/templates/rules/platform/platform-desktop.mdc.tmpl @@ -0,0 +1,36 @@ +--- +description: "Flutter Desktop conventions for {{PROJECT_NAME}} — Pillar 4" +alwaysApply: true +--- + +# Flutter Desktop Standards — {{PROJECT_NAME}} + +## Window management +- Use `window_manager` package for window title, size, minimize/maximize controls +- Set minimum window size to prevent unusable UI: `windowManager.setMinimumSize(const Size(800, 600))` +- Remember window position/size across sessions using `shared_preferences` + +## Keyboard shortcuts +- All primary actions must have keyboard shortcuts +- Use `Shortcuts` widget + `Actions` widget for app-wide shortcuts +- Follow platform conventions: `Cmd+S` (macOS), `Ctrl+S` (Windows/Linux) for save + +## Context menus +- Right-click context menus: use `ContextMenuRegion` widget +- Desktop users expect right-click everywhere + +## Platform-specific behavior +```dart +if (Platform.isMacOS) { + // macOS: use mac_window_manager for traffic lights placement +} else if (Platform.isWindows) { + // Windows: custom title bar with min/max/close buttons +} else if (Platform.isLinux) { + // Linux: respect window manager decorations +} +``` + +## File system +- `path_provider` provides platform-appropriate directories +- `file_picker` for open/save dialogs — never hardcode paths +- Handle file permission errors gracefully (especially on macOS with sandboxing) diff --git a/flutter-cursor-templates/templates/rules/platform/platform-ios.mdc.tmpl b/flutter-cursor-templates/templates/rules/platform/platform-ios.mdc.tmpl new file mode 100644 index 0000000..4feea1f --- /dev/null +++ b/flutter-cursor-templates/templates/rules/platform/platform-ios.mdc.tmpl @@ -0,0 +1,35 @@ +--- +description: "iOS-specific conventions for {{PROJECT_NAME}} — Pillar 4" +alwaysApply: true +--- + +# iOS Platform Standards — {{PROJECT_NAME}} + +## Platform-specific imports +- Use `dart:io` checks: `if (Platform.isIOS)` for conditional code +- iOS-only plugins: declare in `pubspec.yaml` with platform filter +- **NEVER** call `dart:io` directly in shared code — use `platform_channel` or `universal_io` + +## iOS-specific requirements +- Minimum deployment target: iOS 13.0 (or as specified in `ios/Podfile`) +- Privacy manifests: `ios/Runner/PrivacyInfo.xcprivacy` — required for App Store since 2024 +- Required `Info.plist` keys before using: + - Camera: `NSCameraUsageDescription` + - Photo library: `NSPhotoLibraryUsageDescription` + - Location: `NSLocationWhenInUseUsageDescription` + - Notifications: handled via `permission_handler` + +## Push notifications (iOS) +- Configure APNs certificates in Xcode signing & capabilities +- Request permission with `permission_handler` — show rationale screen first +- Handle foreground vs background vs terminated app states separately +- Test on a physical device — iOS simulator does not support push + +## Safe area +- Always wrap root scaffold with `SafeArea` or use `MediaQuery.of(context).padding` +- Dynamic Island / notch: test on iPhone 14 Pro and iPhone 15 Pro simulators + +## App Store compliance +- Screenshot: use `ScreenshotController` to exclude sensitive screens +- Sign in with Apple: required if any third-party social login is offered +- IPv6 compatibility required — no IPv4-only network code diff --git a/flutter-cursor-templates/templates/rules/platform/platform-web.mdc.tmpl b/flutter-cursor-templates/templates/rules/platform/platform-web.mdc.tmpl new file mode 100644 index 0000000..0a5d01a --- /dev/null +++ b/flutter-cursor-templates/templates/rules/platform/platform-web.mdc.tmpl @@ -0,0 +1,45 @@ +--- +description: "Flutter Web conventions for {{PROJECT_NAME}} — Pillar 4" +alwaysApply: true +--- + +# Flutter Web Standards — {{PROJECT_NAME}} + +## Critical: No dart:io on web +- **NEVER** import `dart:io` in shared code — it crashes on web +- Use `dart:html` or `universal_io` for platform-specific I/O +- Use `path_provider` alternatives: `universal_html` for web file access +- Check: `kIsWeb` constant before any platform-specific code + +```dart +// ✅ Platform-safe +import 'package:universal_io/io.dart'; + +// ❌ Crashes on web +import 'dart:io'; +``` + +## Web rendering +- Choose renderer carefully: + - `canvaskit` — pixel-perfect, larger initial load, better for graphics + - `html` — smaller load, uses DOM elements, inconsistent rendering +- Set in `index.html`: `flutterWebRenderer: "canvaskit"` + +## PWA requirements +- Update `web/manifest.json`: name, icons (192×192, 512×512), theme_color +- Service worker: configure for offline caching of app shell +- Test with Chrome DevTools → Lighthouse → PWA audit + +## Web-specific rendering caveats +- `BackdropFilter` has limited support on `html` renderer +- `Canvas` operations differ between renderers — test both +- Text selection differs — use `SelectableText` not `Text` where appropriate +- Scrollbars appear automatically on web — style or hide with `ScrollbarTheme` + +## URL strategy +- Use `usePathUrlStrategy()` in `main.dart` for clean URLs (no `#`) +- Configure server to redirect all paths to `index.html` (SPA routing) + +## Performance +- Lazy-load routes — use `GoRouter` deferred loading +- Initial load budget: < 3MB (canvaskit) or < 1MB (html renderer) diff --git a/flutter-cursor-templates/templates/rules/routing/auto_route.mdc.tmpl b/flutter-cursor-templates/templates/rules/routing/auto_route.mdc.tmpl new file mode 100644 index 0000000..a09a2a0 --- /dev/null +++ b/flutter-cursor-templates/templates/rules/routing/auto_route.mdc.tmpl @@ -0,0 +1,31 @@ +--- +description: "Auto Route conventions for {{PROJECT_NAME}}" +alwaysApply: true +--- + +# Auto Route Standards — {{PROJECT_NAME}} + +## Route definitions +```dart +@AutoRouterConfig() +class AppRouter extends $AppRouter { + @override + List get routes => [ + AutoRoute(page: HomeRoute.page, initial: true), + AutoRoute(page: ProductRoute.page, path: '/products/:id'), + AutoRoute(page: LoginRoute.page, guards: [AuthGuard]), + ]; +} +``` + +## Navigation +```dart +context.router.push(ProductRoute(id: product.id)); +context.router.pop(); +context.router.replace(HomeRoute()); +``` + +## Rules +- Always use typed `Route` classes — never string paths +- Guards implement `AutoRouteGuard` +- **NEVER** use `Navigator.push` directly diff --git a/flutter-cursor-templates/templates/rules/routing/getx_nav.mdc.tmpl b/flutter-cursor-templates/templates/rules/routing/getx_nav.mdc.tmpl new file mode 100644 index 0000000..ba14ab3 --- /dev/null +++ b/flutter-cursor-templates/templates/rules/routing/getx_nav.mdc.tmpl @@ -0,0 +1,32 @@ +--- +description: "GetX Navigation conventions for {{PROJECT_NAME}}" +alwaysApply: true +--- + +# GetX Navigation — {{PROJECT_NAME}} + +## Named routes +```dart +// app_pages.dart — central route definitions +abstract class AppPages { + static const initial = Routes.home; + static final routes = [ + GetPage(name: Routes.home, page: () => const HomeView(), binding: HomeBinding()), + GetPage(name: Routes.product, page: () => const ProductView(), binding: ProductBinding()), + ]; +} + +// Navigate — always use named routes +Get.toNamed(Routes.product, arguments: product); // push +Get.offAllNamed(Routes.home); // replace stack +Get.back(); // pop +``` + +## Bindings +- Every route has a `Binding` class that creates and injects dependencies +- **NEVER** use `Get.put()` in a widget — only in Bindings +- Use `Get.lazyPut()` for deferred creation + +## Rules +- **NEVER** use `Navigator.push/pop` +- All route strings in `lib/core/routing/routes.dart` as constants diff --git a/flutter-cursor-templates/templates/rules/routing/gorouter.mdc.tmpl b/flutter-cursor-templates/templates/rules/routing/gorouter.mdc.tmpl new file mode 100644 index 0000000..f9e13d5 --- /dev/null +++ b/flutter-cursor-templates/templates/rules/routing/gorouter.mdc.tmpl @@ -0,0 +1,61 @@ +--- +description: "GoRouter conventions for {{PROJECT_NAME}}" +alwaysApply: true +--- + +# GoRouter Standards — {{PROJECT_NAME}} + +## Typed routes (mandatory) +```dart +// Define typed routes — never use string paths directly in navigation calls +@TypedGoRoute(path: '/') +class HomeRoute extends GoRouteData { + const HomeRoute(); + @override Widget build(BuildContext ctx, GoRouterState state) => const HomeScreen(); +} + +@TypedGoRoute(path: '/products/:id') +class ProductRoute extends GoRouteData { + final String id; + const ProductRoute({required this.id}); + @override Widget build(BuildContext ctx, GoRouterState state) => ProductScreen(id: id); +} + +// Navigate with type safety +const ProductRoute(id: product.id).go(context); // ✅ +context.go('/products/${product.id}'); // ❌ — don't do this +``` + +## Auth guard +```dart +// Redirect logic in router config +redirect: (context, state) { + final isLoggedIn = ref.read(authProvider).isAuthenticated; + final isLoginRoute = state.matchedLocation == '/login'; + if (!isLoggedIn && !isLoginRoute) return '/login'; + if (isLoggedIn && isLoginRoute) return '/'; + return null; +}, +``` + +## Shell routes for bottom navigation +```dart +ShellRoute( + builder: (ctx, state, child) => MainScaffold(child: child), + routes: [ + GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), + GoRoute(path: '/search', builder: (_, __) => const SearchScreen()), + GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()), + ], +) +``` + +## Deep links +- Register URL schemes in `Info.plist` (iOS) and `AndroidManifest.xml` +- Test deep links with: `adb shell am start -a android.intent.action.VIEW -d "app://{{PACKAGE_ID}}/products/123"` +- Handle `GoRouter.of(context).routerDelegate.currentConfiguration` for dynamic links + +## Rules +- **NEVER** use `Navigator.push/pop` — use `context.go()`, `context.push()`, `context.pop()` +- All routes declared in one file: `lib/core/routing/app_router.dart` +- `BlocProvider`s for route-level blocs created inside the `builder` of each route diff --git a/flutter-cursor-templates/templates/rules/security/security-standards.mdc.tmpl b/flutter-cursor-templates/templates/rules/security/security-standards.mdc.tmpl new file mode 100644 index 0000000..01211e2 --- /dev/null +++ b/flutter-cursor-templates/templates/rules/security/security-standards.mdc.tmpl @@ -0,0 +1,109 @@ +--- +description: "Security standards for {{PROJECT_NAME}} — ALWAYS APPLIED on every file write" +alwaysApply: true +--- + +# Security Standards — {{PROJECT_NAME}} +> **Pillar 5**: Security is an always-on rule, not just a reactive agent. +> These rules apply to EVERY file write, regardless of feature or context. + +## Credential & secret management +- **NEVER** hardcode API keys, tokens, or secrets in source code +- **NEVER** commit `.env` files — use `.gitignore` and document required vars in `.env.example` +- API keys belong in: flavored `--dart-define` build args, or a secrets manager (e.g. AWS Secrets) +- Use `flutter_secure_storage` for tokens/credentials — **NEVER** `SharedPreferences` for sensitive data +- Obfuscation: enable `--obfuscate --split-debug-info=build/debug-symbols/` for release builds + +## Authentication & sessions +- JWT/session tokens: stored in `flutter_secure_storage`, never in `SharedPreferences` or local DB +- Implement token refresh with retry logic — never let a 401 show a raw error to the user +- Certificate pinning: required for production builds on {{AUTH}} — use `dio_pinning_interceptor` +- Biometric re-auth: require for any transaction > defined threshold + +## Data & privacy +- **NEVER** log PII (names, emails, phone numbers, addresses) to console or crash reporters +- Sanitize all user inputs before sending to backend +- Use `SensitiveWidget` wrapper to exclude screens from screenshots / app switcher thumbnails +- Comply with platform privacy requirements: iOS `NSPrivacyAccessedAPITypes`, Android permissions + +## Network security +- All API calls use HTTPS — no http:// in production +- Validate SSL certificates — never bypass with `badCertificateCallback: (_,_,_) => true` +- Add a timeout to every HTTP call: `connectTimeout: Duration(seconds: 10)` +- Rate-limit sensitive endpoints: auth, OTP, password reset + +## Dependency security +- Run `dart pub outdated` monthly — flag packages with known CVEs +- Never use a package with <100 pub points without explicit tech lead approval +- Pin critical security packages to exact versions in `pubspec.yaml` + +## Secure coding patterns +```dart +// ✅ Correct: secure token storage +final storage = FlutterSecureStorage(); +await storage.write(key: 'access_token', value: token); + +// ❌ Wrong: SharedPreferences for sensitive data +final prefs = await SharedPreferences.getInstance(); +prefs.setString('access_token', token); // NEVER do this + +// ✅ Correct: PII-safe logging +logger.info('User authenticated: userId=${user.id}'); // ok — id, not email +logger.debug('Payment processed: orderId=$orderId'); // ok — no card data + +// ❌ Wrong: PII in logs +logger.info('Login: email=${user.email}, phone=${user.phone}'); // NEVER + +// ✅ Correct: --dart-define for secrets (build arg, not in code) +// flutter build apk --dart-define=API_KEY=$API_KEY +const apiKey = String.fromEnvironment('API_KEY'); // acceptable + +// ✅ Correct: certificate pinning with Dio +(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () { + final client = HttpClient(); + client.badCertificateCallback = (cert, host, port) => false; // strict + return client; +}; +``` + +## Deep linking & intent security +- Validate all incoming deep link parameters — never trust raw URL params +- Use `app_links` package for verified deep link handling +- Restrict URL schemes to known patterns — reject unknown schemes +- For OAuth callbacks: validate `state` parameter to prevent CSRF + +## Storage security +- Encrypt locally cached sensitive data using `hive` with `HiveAesCipher` +- Clear sensitive data from memory after use (set to null, trigger GC) +- Do NOT cache auth tokens in network layer (no `dio_cache_interceptor` for auth endpoints) +- Use `SecureRandom` for nonce/token generation — never `Random()` + +## Code obfuscation & binary protection +```yaml +# android/app/build.gradle — release config +buildTypes { + release { + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } +} +``` +```bash +# Flutter release build with obfuscation +flutter build apk --release --obfuscate --split-debug-info=build/debug-symbols/ +flutter build ipa --release --obfuscate --split-debug-info=build/debug-symbols/ +``` + +## Release checklist +Before every production release, verify: +- [ ] No hardcoded secrets (`grep -r "api_key\|secret\|password\|token" lib/`) +- [ ] Debug flags disabled (`kDebugMode` guards on all debug-only paths) +- [ ] Obfuscation enabled in release build config +- [ ] Certificate pinning active and tested on both platforms +- [ ] Crash reporter PII filters configured (Sentry `beforeSend`, Firebase `setConsentType`) +- [ ] `flutter_secure_storage` used for all tokens — no `SharedPreferences` for sensitive keys +- [ ] Network calls all HTTPS — scan for `http://` in lib/ +- [ ] Dependencies audited: `dart pub outdated --json | jq '.packages[] | select(.isDiscontinued)'` +- [ ] Deep link parameters validated +- [ ] App transport security / network security config reviewed diff --git a/flutter-cursor-templates/templates/rules/state-management/bloc.mdc.tmpl b/flutter-cursor-templates/templates/rules/state-management/bloc.mdc.tmpl new file mode 100644 index 0000000..36a91de --- /dev/null +++ b/flutter-cursor-templates/templates/rules/state-management/bloc.mdc.tmpl @@ -0,0 +1,64 @@ +--- +description: "BLoC / Cubit conventions for {{PROJECT_NAME}}" +alwaysApply: true +--- + +# BLoC / Cubit Standards — {{PROJECT_NAME}} + +## When to use BLoC vs Cubit +- **Cubit**: simple state with no meaningful event history (toggle, counter, pagination) +- **BLoC**: event-driven flows where event history or transitions matter (auth, checkout, form wizard) + +## Event and State classes +```dart +// Events — sealed class (exhaustive switch) +sealed class AuthEvent { const AuthEvent(); } +final class AuthLoginRequested extends AuthEvent { + final String email, password; + const AuthLoginRequested({required this.email, required this.password}); +} +final class AuthLogoutRequested extends AuthEvent { const AuthLogoutRequested(); } + +// States — sealed class, immutable +sealed class AuthState { const AuthState(); } +final class AuthInitial extends AuthState { const AuthInitial(); } +final class AuthLoading extends AuthState { const AuthLoading(); } +final class AuthAuthenticated extends AuthState { + final User user; + const AuthAuthenticated(this.user); +} +final class AuthUnauthenticated extends AuthState { const AuthUnauthenticated(); } +final class AuthFailure extends AuthState { + final String message; + const AuthFailure(this.message); +} +``` + +## BlocProvider placement +- Create `BlocProvider` at the **route level** (in {{ROUTING}} route definitions) +- `MultiBlocProvider` at route level for screens needing multiple blocs +- **NEVER** create `BlocProvider` inside a widget's `build()` method + +## Usage rules +- **NEVER** call `bloc.add()` inside `build()` — only in gesture callbacks or `initState()` +- Use `BlocConsumer` only when BOTH `listen` + `build` logic are needed +- Use `BlocSelector` when only a subset of state triggers a rebuild +- Every BLoC must override `close()` and cancel `StreamSubscription`s + +## BlocBuilder patterns +```dart +BlocBuilder( + builder: (context, state) => switch (state) { + AuthInitial() => const SizedBox.shrink(), + AuthLoading() => const LoadingIndicator(), + AuthAuthenticated(user: final u) => HomeScreen(user: u), + AuthUnauthenticated() => const LoginScreen(), + AuthFailure(message: final m) => ErrorScreen(message: m), + }, +) +``` + +## File locations in {{PROJECT_NAME}} +- `lib/features/[feature]/presentation/bloc/[feature]_bloc.dart` +- `lib/features/[feature]/presentation/bloc/[feature]_event.dart` +- `lib/features/[feature]/presentation/bloc/[feature]_state.dart` diff --git a/flutter-cursor-templates/templates/rules/state-management/getx.mdc.tmpl b/flutter-cursor-templates/templates/rules/state-management/getx.mdc.tmpl new file mode 100644 index 0000000..1993700 --- /dev/null +++ b/flutter-cursor-templates/templates/rules/state-management/getx.mdc.tmpl @@ -0,0 +1,67 @@ +--- +description: "GetX conventions for {{PROJECT_NAME}} (legacy — migration available)" +alwaysApply: true +--- + +# GetX Standards — {{PROJECT_NAME}} +> ⚠️ This project uses GetX. See `migration-agent` for incremental migration to Riverpod. + +## Controller structure +```dart +class ProductsController extends GetxController { + final ProductRepository _repo; + ProductsController(this._repo); + + final RxList products = [].obs; + final RxBool isLoading = false.obs; + final Rx error = Rx(null); + + @override + void onInit() { + super.onInit(); + fetchProducts(); + } + + Future fetchProducts() async { + isLoading.value = true; + error.value = null; + try { + products.value = await _repo.getProducts(); + } catch (e) { + error.value = e.toString(); + } finally { + isLoading.value = false; + } + } +} +``` + +## View pattern +```dart +// Views extend GetView — never GetWidget or raw StatelessWidget +class ProductsView extends GetView { + @override + Widget build(BuildContext context) { + return Scaffold( + body: Obx(() { + if (controller.isLoading.value) return const ProductShimmer(); + if (controller.error.value != null) return ErrorWidget(controller.error.value!); + return ProductList(controller.products); + }), + ); + } +} +``` + +## Rules +- **NEVER** pass `BuildContext` into a controller +- Use `Binding` classes for dependency injection — never `Get.put()` in a widget +- Use `.obs` for all reactive state — never call `update()` on non-observable state +- Use `Get.find()` only in `Binding` classes, not in widgets +- **No business logic in Views** — controllers handle all logic + +## File locations in {{PROJECT_NAME}} +- `lib/features/[feature]/views/[feature]_view.dart` +- `lib/features/[feature]/controllers/[feature]_controller.dart` +- `lib/features/[feature]/bindings/[feature]_binding.dart` +- `lib/features/[feature]/models/[feature]_model.dart` diff --git a/flutter-cursor-templates/templates/rules/state-management/hooks_riverpod.mdc.tmpl b/flutter-cursor-templates/templates/rules/state-management/hooks_riverpod.mdc.tmpl new file mode 100644 index 0000000..5e4ea76 --- /dev/null +++ b/flutter-cursor-templates/templates/rules/state-management/hooks_riverpod.mdc.tmpl @@ -0,0 +1,44 @@ +--- +description: "Hooks + Riverpod conventions for {{PROJECT_NAME}}" +alwaysApply: true +--- + +# Hooks + Riverpod Standards — {{PROJECT_NAME}} + +## Widget base classes +- `HookConsumerWidget` — when you need BOTH hooks and Riverpod providers +- `HookWidget` — when you need ONLY hooks (no Riverpod) +- `ConsumerWidget` — when you need ONLY Riverpod (no hooks) + +## Hook rules +```dart +class ProductSearchWidget extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + // Hooks at the TOP of build() — never inside conditions or loops + final searchQuery = useState(''); + final searchCtrl = useTextEditingController(); + final focusNode = useFocusNode(); + final debounced = useDebounced(searchQuery.value, const Duration(milliseconds: 300)); + + // Riverpod below hooks + final results = ref.watch(searchResultsProvider(debounced)); + + return // ... + } +} +``` + +## What goes in hooks vs providers +| Concern | Tool | +|---------|------| +| Local UI state (text controller, animation, focus) | `useState`, `useAnimationController` | +| Server/async state | Riverpod `AsyncNotifier` | +| Cross-widget/feature state | Riverpod providers | +| Lifecycle side effects | `useEffect` | + +## Rules +- **NEVER** call hooks inside `if`, `for`, or callbacks +- `useEffect` cleanup MUST return a dispose function +- Custom hooks: prefix with `use`, live in `lib/core/hooks/` +- Do not use `flutter_riverpod` `ref.watch` inside `useEffect` — use `useRef` + `ref.listen` diff --git a/flutter-cursor-templates/templates/rules/state-management/riverpod.mdc.tmpl b/flutter-cursor-templates/templates/rules/state-management/riverpod.mdc.tmpl new file mode 100644 index 0000000..0f9ad67 --- /dev/null +++ b/flutter-cursor-templates/templates/rules/state-management/riverpod.mdc.tmpl @@ -0,0 +1,58 @@ +--- +description: "Riverpod conventions for {{PROJECT_NAME}}" +alwaysApply: true +--- + +# Riverpod Standards — {{PROJECT_NAME}} + +## Provider types +| Type | Use case | +|------|----------| +| `AsyncNotifier` | Async state from {{BACKEND}} | +| `Notifier` | Synchronous derived/local UI state | +| `StreamNotifier` | Real-time subscriptions | +| `@riverpod` function | Simple computed/derived values | + +## Code generation (mandatory) +```dart +// ✅ Always use @riverpod annotation +@riverpod +class AuthNotifier extends _$AuthNotifier { + @override + Future build() => ref.watch(authRepositoryProvider).currentUser(); + + Future login(String email, String password) async { + state = const AsyncLoading(); + state = await AsyncValue.guard( + () => ref.read(authRepositoryProvider).login(email, password), + ); + } +} + +// ❌ Never write manual provider declarations +final authProvider = StateNotifierProvider>( + (ref) => AuthNotifier(), +); // DON'T DO THIS +``` + +## Rules +- `ref.watch()` inside `build()` ONLY — **never** `ref.read()` inside `build()` +- `ref.read(provider.notifier).method()` for mutations in gesture handlers +- `ref.invalidate(provider)` to refresh — never manually reset state to `AsyncLoading()` +- Family providers for parameterized data: `productDetailsProvider(productId)` +- Providers scoped at feature level; core providers in `lib/core/di/` + +## AsyncValue in widgets +Every `AsyncValue` MUST handle all three states: +```dart +ref.watch(productsProvider).when( + data: (products) => ProductList(products: products), + loading: () => const ProductListShimmer(), // required + error: (e, _) => ErrorWidget(error: e), // required +) +``` + +## File locations in {{PROJECT_NAME}} +- `lib/features/[feature]/[feature]_provider.dart` (generated: `[feature]_provider.g.dart`) +- `lib/features/[feature]/[feature]_repository.dart` +- Run `dart run build_runner watch` during development diff --git a/flutter-cursor-templates/templates/rules/testing/testing-bloc.mdc.tmpl b/flutter-cursor-templates/templates/rules/testing/testing-bloc.mdc.tmpl new file mode 100644 index 0000000..874b5ff --- /dev/null +++ b/flutter-cursor-templates/templates/rules/testing/testing-bloc.mdc.tmpl @@ -0,0 +1,60 @@ +--- +description: "BLoC testing conventions for {{PROJECT_NAME}}" +alwaysApply: false +--- + +# BLoC Testing Standards — {{PROJECT_NAME}} + +## Test pattern (bloc_test) +```dart +// {{TEST_PATTERN}} + +void main() { + late AuthBloc authBloc; + late MockAuthRepository mockRepo; + + setUp(() { + mockRepo = MockAuthRepository(); + authBloc = AuthBloc(repository: mockRepo); + }); + + tearDown(() => authBloc.close()); + + group('AuthBloc', () { + blocTest( + 'emits [Loading, Authenticated] when login succeeds', + build: () { + when(() => mockRepo.login(any(), any())) + .thenAnswer((_) async => const Right(User(id: '1', email: 'test@test.com'))); + return authBloc; + }, + act: (bloc) => bloc.add(const AuthLoginRequested(email: 'test@test.com', password: 'pass')), + expect: () => [ + const AuthLoading(), + isA(), + ], + ); + + blocTest( + 'emits [Loading, Failure] when login fails', + build: () { + when(() => mockRepo.login(any(), any())) + .thenAnswer((_) async => const Left(AuthError('Invalid credentials'))); + return authBloc; + }, + act: (bloc) => bloc.add(const AuthLoginRequested(email: 'bad', password: 'bad')), + expect: () => [ + const AuthLoading(), + const AuthFailure('Invalid credentials'), + ], + ); + }); +} +``` + +## Rules +- Use `mocktail` for mocking — never `mockito` +- Every BLoC test file: `test/features/[feature]/[feature]_bloc_test.dart` +- Coverage requirement: all state transitions must be tested +- Use `Given/When/Then` naming in test descriptions +- Test error paths as thoroughly as success paths diff --git a/flutter-cursor-templates/templates/rules/testing/testing-e2e-patrol.mdc.tmpl b/flutter-cursor-templates/templates/rules/testing/testing-e2e-patrol.mdc.tmpl new file mode 100644 index 0000000..061d70f --- /dev/null +++ b/flutter-cursor-templates/templates/rules/testing/testing-e2e-patrol.mdc.tmpl @@ -0,0 +1,37 @@ +--- +description: "Patrol E2E testing conventions for {{PROJECT_NAME}}" +alwaysApply: false +--- + +# Patrol E2E Testing — {{PROJECT_NAME}} + +## Test structure +```dart +void main() { + patrolTest('User can complete checkout flow', ($) async { + await $.pumpWidgetAndSettle(const App()); + + // Login + await $(LoginScreen).waitUntilVisible(); + await $(#emailField).enterText('test@example.com'); + await $(#passwordField).enterText('password123'); + await $('Sign In').tap(); + + // Add to cart + await $(ProductCard).at(0).tap(); + await $('Add to Cart').tap(); + + // Checkout + await $('Cart').tap(); + await $('Checkout').tap(); + await $(CheckoutSuccessScreen).waitUntilVisible(); + }); +} +``` + +## Rules +- E2E tests in `integration_test/` — separate from unit tests +- Use `patrolTest` not `testWidgets` for E2E scenarios +- Tag tests with `@Tags(['slow'])` so CI can skip on PRs +- Run against real emulators/simulators, not mocked environments +- Test on minimum supported OS version for each platform diff --git a/flutter-cursor-templates/templates/rules/testing/testing-getx.mdc.tmpl b/flutter-cursor-templates/templates/rules/testing/testing-getx.mdc.tmpl new file mode 100644 index 0000000..6f172c0 --- /dev/null +++ b/flutter-cursor-templates/templates/rules/testing/testing-getx.mdc.tmpl @@ -0,0 +1,40 @@ +--- +description: "GetX testing conventions for {{PROJECT_NAME}}" +alwaysApply: false +--- + +# GetX Testing Standards — {{PROJECT_NAME}} + +## Test pattern +```dart +void main() { + late ProductsController controller; + late MockProductRepository mockRepo; + + setUp(() { + mockRepo = MockProductRepository(); + Get.testMode = true; + controller = Get.put(ProductsController(mockRepo)); + }); + + tearDown(() => Get.deleteAll()); + + test('loads products on init', () async { + when(() => mockRepo.getProducts()).thenAnswer((_) async => [fakeProduct]); + await controller.fetchProducts(); + expect(controller.products, [fakeProduct]); + expect(controller.isLoading.value, false); + }); + + testWidgets('ProductsView shows shimmer while loading', (tester) async { + controller.isLoading.value = true; + await tester.pumpWidget(GetMaterialApp(home: ProductsView())); + expect(find.byType(ProductShimmer), findsOneWidget); + }); +} +``` + +## Rules +- Use `Get.testMode = true` in setUp +- Always call `Get.deleteAll()` in tearDown +- Wrap widget tests in `GetMaterialApp`, not `MaterialApp` diff --git a/flutter-cursor-templates/templates/rules/testing/testing-riverpod.mdc.tmpl b/flutter-cursor-templates/templates/rules/testing/testing-riverpod.mdc.tmpl new file mode 100644 index 0000000..0695aeb --- /dev/null +++ b/flutter-cursor-templates/templates/rules/testing/testing-riverpod.mdc.tmpl @@ -0,0 +1,56 @@ +--- +description: "Riverpod testing conventions for {{PROJECT_NAME}}" +alwaysApply: false +--- + +# Riverpod Testing Standards — {{PROJECT_NAME}} + +## Test pattern +```dart +// {{TEST_PATTERN}} + +void main() { + late ProviderContainer container; + late MockProductRepository mockRepo; + + setUp(() { + mockRepo = MockProductRepository(); + container = ProviderContainer(overrides: [ + productRepositoryProvider.overrideWithValue(mockRepo), + ]); + }); + + tearDown(() => container.dispose()); // ALWAYS dispose + + test('ProductsNotifier loads products successfully', () async { + when(() => mockRepo.getProducts()).thenAnswer((_) async => [fakeProduct]); + + final notifier = container.read(productsProvider.notifier); + await notifier.loadProducts(); + + final state = container.read(productsProvider); + expect(state, isA>>()); + expect(state.value, [fakeProduct]); + }); +} +``` + +## Widget tests with Riverpod +```dart +testWidgets('ProductScreen shows shimmer while loading', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + productsProvider.overrideWith((ref) => const AsyncLoading()), + ], + child: const MaterialApp(home: ProductScreen()), + ), + ); + expect(find.byType(ProductShimmer), findsOneWidget); +}); +``` + +## Rules +- **Never** use a real `ProviderScope` in unit tests — always use `ProviderContainer` with overrides +- `addTearDown(container.dispose)` in every test that creates a container +- Test all three `AsyncValue` states: loading, data, error diff --git a/flutter-cursor-templates/templates/rules/universal/flutter-core.mdc.tmpl b/flutter-cursor-templates/templates/rules/universal/flutter-core.mdc.tmpl new file mode 100644 index 0000000..bcafe6a --- /dev/null +++ b/flutter-cursor-templates/templates/rules/universal/flutter-core.mdc.tmpl @@ -0,0 +1,40 @@ +--- +description: "Core Flutter conventions for {{PROJECT_NAME}} — always applied" +alwaysApply: true +--- + +# Flutter Core Standards — {{PROJECT_NAME}} + +## Const and performance +- Use `const` constructors wherever possible — compile-time guarantee of no rebuild +- Prefer `const` widgets at the leaf level: `const SizedBox.shrink()`, `const Padding(...)` +- Never use `const` with mutable values; lint: `prefer_const_constructors` is enabled + +## Null safety +- Never use `!` (bang operator) unless you have a 100% safe runtime guarantee with a comment +- Prefer `??`, `?.`, and `if (x != null)` guards +- Use `required` for all non-nullable named parameters +- Never use `late` without a guarantee of initialisation before first access + +## Widget lifecycle +- Override `dispose()` in every `StatefulWidget` that uses controllers, streams, or timers +- Cancel `StreamSubscription` in `dispose()`, not in `didUpdateWidget` +- Use `WidgetsBinding.instance.addPostFrameCallback` for post-build logic, not `Future.delayed(Duration.zero)` + +## Naming conventions +- Files: `snake_case.dart` +- Classes: `PascalCase` +- Variables/functions: `camelCase` +- Constants: `kCamelCase` or `SCREAMING_SNAKE` for true compile-time constants +- Private members: `_camelCase` + +## Imports +- Order: dart: → package: → relative +- Use relative imports within a feature; absolute for cross-feature +- Never import a feature's internal files from outside that feature + +## Code quality +- Max function length: 40 lines. Extract widgets and helpers aggressively +- No `print()` in production code — use a logging package +- All `TODO:` comments must include a ticket number: `// TODO: PROJ-123 — fix this` +- Run `dart format` before every commit diff --git a/flutter-cursor-templates/templates/rules/universal/project-context.mdc.tmpl b/flutter-cursor-templates/templates/rules/universal/project-context.mdc.tmpl new file mode 100644 index 0000000..3ae5c51 --- /dev/null +++ b/flutter-cursor-templates/templates/rules/universal/project-context.mdc.tmpl @@ -0,0 +1,45 @@ +--- +description: "Project context for {{PROJECT_NAME}} — always applied" +alwaysApply: true +--- + +# Project Context — {{PROJECT_NAME}} + +## Project identity +- **Name:** {{PROJECT_NAME}} +- **Package:** {{PACKAGE_ID}} +- **Description:** {{DESCRIPTION}} +- **Scale:** {{SCALE}} + +## Technology stack +- **State management:** {{STATE_MANAGEMENT}} +- **Architecture:** {{ARCHITECTURE}} +- **Routing:** {{ROUTING}} +- **Backend:** {{BACKEND}} +- **Auth:** {{AUTH}} +- **Platforms:** {{PLATFORMS_LIST}} +- **Code generation:** {{CODEGEN_LIST}} + +## Feature modules +{{FEATURES_LIST}} + +## Special capabilities +{{SPECIAL_FEATURES}} + +## Environments / flavors +- Flavors: {{FLAVORS_LIST}} +- CI/CD: {{CICD_TOOL}} + +## Design & API references +- Design source: {{DESIGN_SOURCE}} +- API docs: {{API_DOCS_FORMAT}} at `{{API_DOCS_PATH}}` + +## Architecture boundaries +{{ARCH_IMPORT_RULES}} + +## When generating code for this project +1. Always use {{STATE_MANAGEMENT}} patterns — never suggest alternatives +2. Always follow {{ARCHITECTURE}} folder structure +3. Always use {{ROUTING}} for navigation — never `Navigator.push` directly +4. Always target platforms: {{PLATFORMS_LIST}} +5. If code generation tools are used ({{CODEGEN_LIST}}), follow their conventions diff --git a/flutter-cursor-templates/templates/rules/universal/ui-ux-standards.mdc.tmpl b/flutter-cursor-templates/templates/rules/universal/ui-ux-standards.mdc.tmpl new file mode 100644 index 0000000..d839564 --- /dev/null +++ b/flutter-cursor-templates/templates/rules/universal/ui-ux-standards.mdc.tmpl @@ -0,0 +1,41 @@ +--- +description: "UI/UX standards for {{PROJECT_NAME}} — always applied" +alwaysApply: true +--- + +# UI / UX Standards — {{PROJECT_NAME}} + +## Loading states +- Every async operation MUST show a loading skeleton (shimmer), NOT a spinner unless < 300ms +- Use `shimmer` package with a shimmer that matches the final layout shape +- Never show a blank screen during loading — skeleton must fill the same space as the content + +## Empty states +- Every list/grid MUST have a distinct empty state widget: illustration + headline + CTA +- Empty state is different from error state — never reuse the same widget for both +- Empty state copy: positive framing ("No items yet — add your first one") + +## Error states +- Every async failure MUST show: error message + retry button +- Never swallow errors silently +- Error text: user-friendly, never expose stack traces or raw API messages + +## Navigation & transitions +- Use `IndexedStack` for bottom nav tabs — preserves scroll position +- Named routes only — never `Navigator.push(context, MaterialPageRoute(...))` +- Page transitions: use `CustomTransitionPage` with `FadeTransition` for modal sheets + +## Responsive layout +- Use `LayoutBuilder` or `MediaQuery` for breakpoints, not hardcoded pixel values +- Minimum touch target: 48×48 logical pixels (Material guideline) +- Test on 375px (iPhone SE) and 414px (iPhone Pro Max) widths minimum + +## Haptics +- Use `HapticFeedback.lightImpact()` on primary CTAs +- Use `HapticFeedback.selectionClick()` on toggle/checkbox interactions +- Never add haptics to destructive actions without confirmation + +## Accessibility +- All interactive widgets must have a `Semantics` label or `tooltip` +- Minimum contrast ratio: 4.5:1 (WCAG AA) +- Test with TalkBack / VoiceOver before each release diff --git a/flutter-cursor-templates/templates/skills/create-flavor/SKILL.md.tmpl b/flutter-cursor-templates/templates/skills/create-flavor/SKILL.md.tmpl new file mode 100644 index 0000000..c4cdfa6 --- /dev/null +++ b/flutter-cursor-templates/templates/skills/create-flavor/SKILL.md.tmpl @@ -0,0 +1,18 @@ +# Create Build Flavor — {{PROJECT_NAME}} + +Creates a new build flavor/environment. Current flavors: {{FLAVORS_LIST}}. + +## Usage +``` +Create a new flavor called [flavor_name] +``` + +## What gets created +1. Android: new `productFlavors` block in `android/app/build.gradle` +2. iOS: new scheme + configuration in Xcode (instructions provided) +3. `lib/core/config/[flavor_name]_config.dart` +4. `.env.[flavor_name]` template +5. {{CICD_TOOL}} pipeline update + +## CI/CD: {{CICD_TOOL}} +Generate the pipeline config snippet for the new flavor in {{CICD_TOOL}} format. diff --git a/flutter-cursor-templates/templates/skills/deploy/SKILL.md.tmpl b/flutter-cursor-templates/templates/skills/deploy/SKILL.md.tmpl new file mode 100644 index 0000000..f97e5da --- /dev/null +++ b/flutter-cursor-templates/templates/skills/deploy/SKILL.md.tmpl @@ -0,0 +1,21 @@ +# Deploy — {{PROJECT_NAME}} + +Guides through deployment to {{CICD_TOOL}} for any of the flavors: {{FLAVORS_LIST}}. + +## Usage +``` +Deploy [flavor] to [store/environment] +``` + +## {{CICD_TOOL}} pipeline +The AI will generate or update the {{CICD_RAW}} configuration file for: +- Build signing +- Running tests +- Distributing to Firebase App Distribution (beta) or App Store / Play Store (prod) + +## Pre-deploy checklist +- [ ] Version bump in `pubspec.yaml` +- [ ] Obfuscation enabled for prod: `--obfuscate --split-debug-info=build/debug-symbols/` +- [ ] No debug flags in production code +- [ ] Security checklist from `security-standards.mdc` passed +- [ ] `dart run cursor_gen --validate` passes diff --git a/flutter-cursor-templates/templates/skills/generate-api-client/SKILL.md.tmpl b/flutter-cursor-templates/templates/skills/generate-api-client/SKILL.md.tmpl new file mode 100644 index 0000000..40cfe37 --- /dev/null +++ b/flutter-cursor-templates/templates/skills/generate-api-client/SKILL.md.tmpl @@ -0,0 +1,16 @@ +# Generate API Client — {{PROJECT_NAME}} + +Generates type-safe API clients from {{API_DOCS_FORMAT}} spec at `{{API_DOCS_PATH}}`. + +## Usage +``` +Generate API client for [endpoint or resource name] +``` + +## Generated files +1. **DTO** (`data/models/[resource]_dto.dart`) — request/response models with json_serializable +2. **DataSource** (`data/datasources/[resource]_remote_datasource.dart`) — Dio calls with error handling +3. **Repository impl** (`data/repositories/[resource]_repository_impl.dart`) + +## After generation +Run: `dart run build_runner build --delete-conflicting-outputs` diff --git a/flutter-cursor-templates/templates/skills/generate-tests/SKILL.md.tmpl b/flutter-cursor-templates/templates/skills/generate-tests/SKILL.md.tmpl new file mode 100644 index 0000000..80d3ffb --- /dev/null +++ b/flutter-cursor-templates/templates/skills/generate-tests/SKILL.md.tmpl @@ -0,0 +1,41 @@ +# Generate Tests — {{PROJECT_NAME}} + +Generates comprehensive unit, widget, and integration tests for **{{STATE_MANAGEMENT}}** patterns. + +## Usage +``` +Generate tests for [ClassName or file path] +``` + +## Test generation process +1. Read the source file completely +2. Identify all testable units (public methods, state transitions, UI states) +3. Generate tests following this pattern: + ``` + {{TEST_PATTERN}} + ``` +4. Create mocks with `mocktail` for all dependencies +5. Place test file at `test/[mirror of source path]_test.dart` + +## Coverage targets +- Business logic (UseCases, Repositories, BLoC/Notifiers): **80% minimum** +- Widget tests: all three states (loading/error/data) must be tested +- E2E: only critical user flows + +## Test file structure +```dart +void main() { + // Setup + group('[ClassName]', () { + // Happy path tests + group('success cases', () { ... }); + // Error path tests + group('error cases', () { ... }); + // Edge cases + group('edge cases', () { ... }); + }); +} +``` + +## Naming convention +`'given [precondition], when [action], then [expected outcome]'` diff --git a/flutter-cursor-templates/templates/skills/scaffold-feature/SKILL.md.tmpl b/flutter-cursor-templates/templates/skills/scaffold-feature/SKILL.md.tmpl new file mode 100644 index 0000000..2c2dd61 --- /dev/null +++ b/flutter-cursor-templates/templates/skills/scaffold-feature/SKILL.md.tmpl @@ -0,0 +1,69 @@ +# Scaffold Feature — {{PROJECT_NAME}} + +Scaffolds a complete new feature module following **{{ARCHITECTURE}}** architecture with **{{STATE_MANAGEMENT}}** state management. + +## Usage +``` +Create a feature called [feature_name] with [description] +``` + +## What gets generated + +### For {{ARCHITECTURE}} architecture: +The AI will create all necessary files for the `[feature_name]` feature following {{ARCHITECTURE}} patterns. + +### File structure to create: + +**Clean Architecture:** +``` +lib/features/[feature_name]/ + domain/ + entities/[feature_name].dart + repositories/[feature_name]_repository.dart + usecases/ + get_[feature_name]_usecase.dart + create_[feature_name]_usecase.dart + data/ + models/[feature_name]_dto.dart + datasources/[feature_name]_remote_datasource.dart + repositories/[feature_name]_repository_impl.dart + presentation/ + [state_files]/ ← based on {{STATE_MANAGEMENT}} + pages/[feature_name]_page.dart + widgets/ +``` + +**Feature-First:** +``` +lib/features/[feature_name]/ + [feature_name]_screen.dart + [feature_name]_provider.dart ← or [feature_name]_bloc.dart + [feature_name]_repository.dart + [feature_name]_model.dart + widgets/ +``` + +## State management boilerplate + +### For {{STATE_MANAGEMENT}}: +Generate the appropriate state management files with: +- Initial/loading/success/error states +- All necessary events (for BLoC) +- Repository connection +- Dependency injection registration + +## Steps the AI takes +1. Ask: "What is the feature name and brief description?" +2. Ask: "What data does this feature manage? (e.g., list of products, single user profile)" +3. Generate all files with correct imports and patterns +4. Add the feature to the DI container +5. Add the route to {{ROUTING}} router +6. Create a placeholder test file + +## Code generation +{{#if codegen_freezed}} +- Generate Freezed model: run `dart run build_runner build` after scaffolding +{{/if}} +{{#if codegen_injectable}} +- Register in injectable: add `@lazySingleton` to repository +{{/if}} diff --git a/flutter-cursor-templates/templates/skills/scaffold-screen/SKILL.md.tmpl b/flutter-cursor-templates/templates/skills/scaffold-screen/SKILL.md.tmpl new file mode 100644 index 0000000..d5c6acd --- /dev/null +++ b/flutter-cursor-templates/templates/skills/scaffold-screen/SKILL.md.tmpl @@ -0,0 +1,61 @@ +# Scaffold Screen — {{PROJECT_NAME}} + +Creates a complete screen widget with all states handled, following **{{STATE_MANAGEMENT}}** patterns. + +## Usage +``` +Create a screen for [screen_name] that shows [content description] +``` + +## Generated screen template + +### {{STATE_MANAGEMENT}} screen pattern: + +**BLoC:** +```dart +class [ScreenName]Screen extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BlocBuilder<[Feature]Bloc, [Feature]State>( + builder: (context, state) => switch (state) { + [Feature]Initial() || [Feature]Loading() => const [ScreenName]Shimmer(), + [Feature]Loaded(:final data) => [ScreenName]Content(data: data), + [Feature]Empty() => const [ScreenName]EmptyState(), + [Feature]Error(:final message) => [ScreenName]ErrorState( + message: message, + onRetry: () => context.read<[Feature]Bloc>().add(const [Feature]LoadRequested()), + ), + }, + ); + } +} +``` + +**Riverpod:** +```dart +class [ScreenName]Screen extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch([feature]Provider); + return state.when( + loading: () => const [ScreenName]Shimmer(), + data: (data) => [ScreenName]Content(data: data), + error: (e, _) => [ScreenName]ErrorState(error: e), + ); + } +} +``` + +## Required sub-widgets to generate +1. `[ScreenName]Shimmer` — skeleton loading layout matching final content +2. `[ScreenName]EmptyState` — illustration + headline + CTA button +3. `[ScreenName]ErrorState` — error message + retry button +4. `[ScreenName]Content` — the actual data display + +## Platform considerations ({{PLATFORMS_LIST}}) +{{#if platform_web}} +- Web: ensure no dart:io usage; test at 375px and 1280px widths +{{/if}} +{{#if platform_desktop}} +- Desktop: add keyboard shortcut support for primary actions +{{/if}}