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.

This commit is contained in:
2026-05-12 22:29:55 +05:30
commit 6dfb9a8aa5
72 changed files with 4542 additions and 0 deletions
@@ -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<void> main(List<String> 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<void> _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<void> _writeOutput(
String outputDir, Map<String, String> 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');
}
@@ -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
}
}
}
@@ -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
@@ -0,0 +1,80 @@
import 'dart:io';
import 'package:yaml/yaml.dart';
import 'models.dart';
class BriefLoader {
static Future<ProjectBrief> 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<String>? _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;
}
}
@@ -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');
}
@@ -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<String> backends; // firebase | supabase | rest
final String auth; // firebase_auth | supabase_auth | jwt_rest | oauth2 | none
// NEW — Pillar 4: platform targets
final List<String> platforms; // ios | android | web | desktop
final List<String> codegenTools; // freezed | json_serializable | injectable | retrofit
// Environments
final List<String> 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<String> referenceRepos;
final List<String> localPaths;
// Features
final List<String> featureModules;
final List<String> specialFeatures; // realtime | push_notifications | deep_linking | offline_first
// Localization
final bool i18nEnabled;
final List<String> 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<String> errors;
final List<String> 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<String> changelog;
const VersionStatus({
required this.hasUpdate,
required this.currentVersion,
required this.latestVersion,
this.changelog = const [],
});
}
class OverrideSnapshot {
final List<String> customFiles; // files in custom/
final Map<String, String> customSections; // CURSOR:CUSTOM blocks per file
const OverrideSnapshot({required this.customFiles, required this.customSections});
}
@@ -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<OverrideSnapshot> snapshot(String outputDir) async {
final customPath = p.join(outputDir, _customDir);
final customFiles = <String>[];
final customSections = <String, String>{};
// 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<void> 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
''';
}
@@ -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<Map<String, String>> render({
required ProjectBrief brief,
required List<String> templateFiles,
required String templateSrc,
}) async {
final context = _buildContext(brief);
final output = <String, String>{};
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<String, String> _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<String, String> 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<MyCubit, MyState>(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<MyController>(); expect(ctrl.value, expected);';
default:
return '// Write tests appropriate for $sm state management';
}
}
}
@@ -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/<key>.mdc.tmpl (or SKILL.md.tmpl for skills)
static List<String> resolve(ProjectBrief brief) {
final files = <String>[];
// ── 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<String, String> resolveWithReasons(ProjectBrief brief) {
final resolved = resolve(brief);
final reasons = <String, String>{};
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';
}
}
@@ -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<void> record({
required String projectName,
required String outputDir,
required List<String> templateFiles,
}) async {
final telemetryPath = p.join(outputDir, 'telemetry.json');
Map<String, dynamic> data;
try {
final file = File(telemetryPath);
data = file.existsSync()
? jsonDecode(await file.readAsString()) as Map<String, dynamic>
: _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<void> 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<String, dynamic>;
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<String>();
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<String, dynamic> _emptyData(String projectName) => {
'projectName': projectName,
'totalGenerations': 0,
'lastGeneratedAt': '',
'generations': [],
'note': 'Local-only telemetry. Never sent anywhere. opt-in via telemetry_opt_in: true.',
};
}
@@ -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<ValidationResult> 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<ValidationResult> validate(ProjectBrief brief) async {
return _validateBrief(brief);
}
static ValidationResult _validateYaml(YamlMap yaml) {
final errors = <String>[];
final warnings = <String>[];
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 = <String>[];
final warnings = <String>[];
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<String> valid, List<String> output,
) {
final val = map[field]?.toString();
if (val != null && !valid.contains(val)) {
output.add('stack.$field "$val" is not valid. Use: ${valid.join(", ")}');
}
}
}
@@ -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<VersionStatus> 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<void> 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<Map<String, dynamic>?> readLock(String outputDir) async {
final file = File(p.join(outputDir, _lockFileName));
if (!file.existsSync()) return null;
return jsonDecode(await file.readAsString()) as Map<String, dynamic>;
}
/// --check-updates: show detailed diff between locked and current version
static Future<void> 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<String> _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);
}
}
@@ -0,0 +1,180 @@
// wizard.dart — Interactive CLI wizard (brief discovery UX gap fix)
import 'dart:io';
import 'logger.dart';
class Wizard {
static Future<void> 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 = <String, dynamic>{};
// 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<String> 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<String> _askMultiChoice(String label, List<String> options, {List<int> 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<String, dynamic> 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']}
''';
}
}
@@ -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 = <String>[];
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<void> _compareGoldens(String goldenDir, Map<String, String> 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));
}