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:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user