@atlashub/smartstack-cli 3.24.0 → 3.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlashub/smartstack-cli",
3
- "version": "3.24.0",
3
+ "version": "3.25.0",
4
4
  "description": "SmartStack Claude Code automation toolkit - GitFlow, EF Core migrations, prompts and more",
5
5
  "author": {
6
6
  "name": "SmartStack",
@@ -505,3 +505,146 @@ services.AddValidatorsFromAssemblyContaining<Create{Name}DtoValidator>();
505
505
  | `Permission.Create()` | Does NOT exist — use `CreateForModule()`, `CreateForSection()`, etc. |
506
506
  | `GetAllAsync()` without search param | ALL GetAll endpoints MUST support `?search=` for EntityLookup |
507
507
  | FK field as plain text input | Frontend MUST use `EntityLookup` component for Guid FK fields |
508
+
509
+ ---
510
+
511
+ ## Critical Anti-Patterns (with code examples)
512
+
513
+ > **These are the most common and dangerous mistakes.** Each one has been observed in production code generation.
514
+
515
+ ### Anti-Pattern 1: HasQueryFilter with `!= Guid.Empty` (SECURITY — OWASP A01)
516
+
517
+ The `HasQueryFilter` in EF Core should use **runtime tenant resolution**, NOT a static comparison against `Guid.Empty`.
518
+
519
+ **INCORRECT — Does NOT isolate tenants:**
520
+ ```csharp
521
+ // WRONG: This only excludes empty GUIDs — ALL tenant data is still visible to everyone!
522
+ public void Configure(EntityTypeBuilder<MyEntity> builder)
523
+ {
524
+ builder.HasQueryFilter(e => e.TenantId != Guid.Empty);
525
+ }
526
+ ```
527
+
528
+ **CORRECT — Tenant isolation via service:**
529
+ ```csharp
530
+ // CORRECT: In SmartStack, tenant filtering is done in the SERVICE layer, not via HasQueryFilter.
531
+ public async Task<PaginatedResult<MyEntityDto>> GetAllAsync(...)
532
+ {
533
+ var query = _db.MyEntities
534
+ .Where(x => x.TenantId == _currentUser.TenantId) // MANDATORY runtime filter
535
+ .AsNoTracking();
536
+ // ...
537
+ }
538
+ ```
539
+
540
+ **Why it's wrong:** `HasQueryFilter(e => e.TenantId != Guid.Empty)` is a **static filter** — it only removes records with empty GUIDs. It does NOT restrict data to the current tenant. This is an **OWASP A01 Broken Access Control** vulnerability.
541
+
542
+ ---
543
+
544
+ ### Anti-Pattern 2: `List<T>` instead of `PaginatedResult<T>` for GetAll
545
+
546
+ **INCORRECT — No pagination:**
547
+ ```csharp
548
+ // WRONG: Returns all records at once — no pagination, no totalCount
549
+ public async Task<List<MyEntityDto>> GetAllAsync(CancellationToken ct)
550
+ {
551
+ return await _db.MyEntities
552
+ .Where(x => x.TenantId == _currentUser.TenantId)
553
+ .Select(x => new MyEntityDto(x.Id, x.Code, x.Name))
554
+ .ToListAsync(ct);
555
+ }
556
+ ```
557
+
558
+ **CORRECT — Paginated with search:**
559
+ ```csharp
560
+ // CORRECT: Returns PaginatedResult<T> with search, page, pageSize
561
+ public async Task<PaginatedResult<MyEntityDto>> GetAllAsync(
562
+ string? search = null, int page = 1, int pageSize = 20, CancellationToken ct = default)
563
+ {
564
+ var query = _db.MyEntities.Where(x => x.TenantId == _currentUser.TenantId).AsNoTracking();
565
+ if (!string.IsNullOrWhiteSpace(search))
566
+ query = query.Where(x => x.Name.Contains(search) || x.Code.Contains(search));
567
+ var totalCount = await query.CountAsync(ct);
568
+ var items = await query.OrderBy(x => x.Name).Skip((page - 1) * pageSize).Take(pageSize)
569
+ .Select(x => new MyEntityDto(x.Id, x.Code, x.Name, x.CreatedAt)).ToListAsync(ct);
570
+ return new PaginatedResult<MyEntityDto>(items, totalCount, page, pageSize);
571
+ }
572
+ ```
573
+
574
+ **Why it's wrong:** `List<T>` loads ALL records into memory. It also breaks `EntityLookup` which requires `{ items, totalCount }` response format.
575
+
576
+ ---
577
+
578
+ ### Anti-Pattern 3: Missing `IAuditableEntity` on tenant entities
579
+
580
+ **INCORRECT — No audit trail:**
581
+ ```csharp
582
+ // WRONG: Tenant entity without IAuditableEntity
583
+ public class MyEntity : BaseEntity, ITenantEntity
584
+ {
585
+ public Guid TenantId { get; private set; }
586
+ public string Code { get; private set; } = null!;
587
+ }
588
+ ```
589
+
590
+ **CORRECT — Always pair ITenantEntity with IAuditableEntity:**
591
+ ```csharp
592
+ public class MyEntity : BaseEntity, ITenantEntity, IAuditableEntity
593
+ {
594
+ public Guid TenantId { get; private set; }
595
+ public string? CreatedBy { get; set; }
596
+ public string? UpdatedBy { get; set; }
597
+ public string Code { get; private set; } = null!;
598
+ }
599
+ ```
600
+
601
+ **Why it's wrong:** Without `IAuditableEntity`, there is no record of who created or modified data. Mandatory for compliance in multi-tenant environments.
602
+
603
+ ---
604
+
605
+ ### Anti-Pattern 4: Code auto-generation with `Count() + 1`
606
+
607
+ **INCORRECT — Race condition:**
608
+ ```csharp
609
+ // WRONG: Two concurrent requests get the same count
610
+ var count = await _db.MyEntities.Where(x => x.TenantId == _currentUser.TenantId).CountAsync(ct);
611
+ return $"ENT{(count + 1):D4}";
612
+ ```
613
+
614
+ **CORRECT — Use `GenerateNextCodeAsync()` (atomic):**
615
+ ```csharp
616
+ var code = await GenerateNextCodeAsync("MyEntity", _currentUser.TenantId, ct);
617
+ ```
618
+
619
+ **Why it's wrong:** `Count() + 1` causes **race conditions** — concurrent requests generate duplicate codes.
620
+
621
+ ---
622
+
623
+ ### Anti-Pattern 5: Missing Update validator
624
+
625
+ **INCORRECT — Only CreateValidator:**
626
+ ```csharp
627
+ public class CreateMyEntityDtoValidator : AbstractValidator<CreateMyEntityDto>
628
+ {
629
+ public CreateMyEntityDtoValidator()
630
+ {
631
+ RuleFor(x => x.Code).NotEmpty().MaximumLength(100);
632
+ RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
633
+ }
634
+ }
635
+ // No UpdateMyEntityDtoValidator exists!
636
+ ```
637
+
638
+ **CORRECT — Always create validators in pairs:**
639
+ ```csharp
640
+ public class CreateMyEntityDtoValidator : AbstractValidator<CreateMyEntityDto> { /* ... */ }
641
+ public class UpdateMyEntityDtoValidator : AbstractValidator<UpdateMyEntityDto>
642
+ {
643
+ public UpdateMyEntityDtoValidator()
644
+ {
645
+ RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
646
+ }
647
+ }
648
+ ```
649
+
650
+ **Why it's wrong:** Without an `UpdateValidator`, the Update endpoint accepts **any data without validation**.
@@ -58,6 +58,26 @@ element: <EmployeesPage />
58
58
  <Suspense><EmployeesPage /></Suspense>
59
59
  ```
60
60
 
61
+ ### Client App.tsx — Lazy Imports Mandatory
62
+
63
+ > **CRITICAL:** In the client `App.tsx` (where `contextRoutes` are defined), ALL page imports MUST use `React.lazy()`.
64
+
65
+ **CORRECT — Lazy imports in client App.tsx:**
66
+ ```tsx
67
+ const ClientsListPage = lazy(() =>
68
+ import('@/pages/Business/HumanResources/Clients/ClientsListPage')
69
+ .then(m => ({ default: m.ClientsListPage }))
70
+ );
71
+ ```
72
+
73
+ **FORBIDDEN — Static imports in client App.tsx:**
74
+ ```tsx
75
+ // WRONG: Static import kills code splitting
76
+ import { ClientsListPage } from '@/pages/Business/HumanResources/Clients/ClientsListPage';
77
+ ```
78
+
79
+ > **Note:** The `smartstackRoutes.tsx` from the npm package may use static imports internally — this is acceptable for the package. But client `App.tsx` code MUST always use lazy imports for business pages.
80
+
61
81
  ---
62
82
 
63
83
  ## 2. I18n / Translations (react-i18next)
@@ -45,8 +45,10 @@ For each entity:
45
45
  ```
46
46
  1. MCP suggest_migration → get standardized name
47
47
  2. dotnet ef migrations add {Name} --project src/{Infra}.csproj --startup-project src/{Api}.csproj -o Persistence/Migrations
48
- 3. dotnet ef database update (if local DB)
49
- 4. dotnet build MUST PASS
48
+ 3. Cleanup corrupted EF Core artifacts:
49
+ for d in src/*/bin?Debug; do [ -d "$d" ] && rm -rf "$d"; done
50
+ 4. dotnet ef database update (if local DB)
51
+ 5. dotnet build → MUST PASS
50
52
  ```
51
53
 
52
54
  **BLOCKING:** If build fails after migration, fix EF configs before proceeding.
@@ -54,6 +54,9 @@ Verify:
54
54
  ## 4. Build Verification
55
55
 
56
56
  ```bash
57
+ # Cleanup corrupted EF Core design-time artifacts (Roslyn BuildHost bug on Windows)
58
+ for d in src/*/bin?Debug; do [ -d "$d" ] && echo "Removing corrupted artifact: $d" && rm -rf "$d"; done
59
+
57
60
  # Backend
58
61
  dotnet clean && dotnet restore && dotnet build
59
62
 
@@ -299,6 +302,17 @@ fi
299
302
  ### POST-CHECK 10: Form pages must have companion test files
300
303
 
301
304
  ```bash
305
+ # Minimum requirement: if frontend pages exist, at least 1 test file must be present
306
+ PAGE_FILES=$(find src/pages/ -name "*.tsx" ! -name "*.test.tsx" 2>/dev/null)
307
+ if [ -n "$PAGE_FILES" ]; then
308
+ ALL_TESTS=$(find src/pages/ -name "*.test.tsx" 2>/dev/null)
309
+ if [ -z "$ALL_TESTS" ]; then
310
+ echo "BLOCKING: No frontend test files found in src/pages/"
311
+ echo "Every form page MUST have a companion .test.tsx file"
312
+ exit 1
313
+ fi
314
+ fi
315
+
302
316
  # Every CreatePage and EditPage must have a .test.tsx file
303
317
  FORM_PAGES=$(find src/pages/ -name "*CreatePage.tsx" -o -name "*EditPage.tsx" 2>/dev/null | grep -v test)
304
318
  if [ -n "$FORM_PAGES" ]; then
@@ -320,14 +334,18 @@ fi
320
334
  # Check for FK fields rendered as plain text inputs in form pages
321
335
  FORM_PAGES=$(find src/pages/ -name "*CreatePage.tsx" -o -name "*EditPage.tsx" 2>/dev/null | grep -v test | grep -v node_modules)
322
336
  if [ -n "$FORM_PAGES" ]; then
323
- # Detect pattern: input with value containing "Id" (e.g., employeeId, departmentId)
324
- FK_TEXT_INPUTS=$(grep -Pn 'type=["\x27]text["\x27].*[a-z]+Id|value=\{[^}]*\.[a-z]+Id\}.*type=["\x27]text["\x27]' $FORM_PAGES 2>/dev/null)
325
- if [ -n "$FK_TEXT_INPUTS" ]; then
326
- echo "BLOCKING: FK fields rendered as plain text inputs MUST use EntityLookup component"
327
- echo "Users cannot type GUIDs manually. Use <EntityLookup /> from @/components/ui/EntityLookup"
328
- echo "See smartstack-frontend.md section 6 for the EntityLookup pattern"
329
- echo "$FK_TEXT_INPUTS"
330
- exit 1
337
+ # Detect FK input fields: <input> with name ending in "Id" (catches inputs with or without explicit type)
338
+ FK_INPUTS=$(grep -Pn '<input[^>]*name=["\x27][a-zA-Z]*Id["\x27]' $FORM_PAGES 2>/dev/null)
339
+ if [ -n "$FK_INPUTS" ]; then
340
+ # Filter out hidden inputs (legitimate for FK submission)
341
+ FK_TEXT_INPUTS=$(echo "$FK_INPUTS" | grep -Pv 'type=["\x27]hidden["\x27]')
342
+ if [ -n "$FK_TEXT_INPUTS" ]; then
343
+ echo "BLOCKING: FK fields rendered as plain text inputs — MUST use EntityLookup component"
344
+ echo "Users cannot type GUIDs manually. Use <EntityLookup /> from @/components/ui/EntityLookup"
345
+ echo "See smartstack-frontend.md section 6 for the EntityLookup pattern"
346
+ echo "$FK_TEXT_INPUTS"
347
+ exit 1
348
+ fi
331
349
  fi
332
350
 
333
351
  # Check for input placeholders mentioning "ID" or "id" or "Enter...Id"
@@ -362,9 +380,10 @@ fi
362
380
  ### POST-CHECK 13: No hardcoded Tailwind colors in generated pages
363
381
 
364
382
  ```bash
365
- NEW_PAGES=$(git diff --name-only HEAD 2>/dev/null | grep "src/pages/.*\.tsx$")
366
- if [ -n "$NEW_PAGES" ]; then
367
- HARDCODED=$(grep -Pn '(bg|text|border)-(?!\[)(red|blue|green|gray|white|black|slate|zinc|neutral|stone)-' $NEW_PAGES 2>/dev/null)
383
+ # Scan all page and component files directly (works for uncommitted/untracked files, Windows/WSL compatible)
384
+ ALL_PAGES=$(find src/pages/ src/components/ -name "*.tsx" 2>/dev/null | grep -v node_modules | grep -v "\.test\.")
385
+ if [ -n "$ALL_PAGES" ]; then
386
+ HARDCODED=$(grep -Pn '(bg|text|border)-(?!\[)(red|blue|green|gray|white|black|slate|zinc|neutral|stone)-' $ALL_PAGES 2>/dev/null)
368
387
  if [ -n "$HARDCODED" ]; then
369
388
  echo "WARNING: Pages should use CSS variables instead of hardcoded Tailwind colors"
370
389
  echo "Fix: bg-[var(--bg-card)] instead of bg-white, text-[var(--text-primary)] instead of text-gray-900"
@@ -373,6 +392,123 @@ if [ -n "$NEW_PAGES" ]; then
373
392
  fi
374
393
  ```
375
394
 
395
+ ### POST-CHECK 14: Routes seed data must match frontend contextRoutes
396
+
397
+ ```bash
398
+ SEED_ROUTES=$(grep -Poh 'Route\s*=\s*"([^"]+)"' $(find src/ -path "*/Seeding/Data/*" -name "*NavigationSeedData.cs" 2>/dev/null) 2>/dev/null | grep -oP '"[^"]+"' | tr -d '"' | sort -u)
399
+ APP_TSX=$(find src/ -name "App.tsx" -not -path "*/node_modules/*" 2>/dev/null | head -1)
400
+ if [ -n "$APP_TSX" ] && [ -n "$SEED_ROUTES" ]; then
401
+ FRONTEND_PATHS=$(grep -oP "path:\s*'([^']+)'" "$APP_TSX" | grep -oP "'[^']+'" | tr -d "'" | sort -u)
402
+ if [ -n "$FRONTEND_PATHS" ]; then
403
+ MISMATCH_FOUND=false
404
+ for SEED_ROUTE in $SEED_ROUTES; do
405
+ DEPTH=$(echo "$SEED_ROUTE" | tr '/' '\n' | grep -c '.')
406
+ if [ "$DEPTH" -lt 3 ]; then continue; fi
407
+ SEED_SUFFIX=$(echo "$SEED_ROUTE" | sed 's|^/[^/]*/||')
408
+ SEED_NORM=$(echo "$SEED_SUFFIX" | tr '[:upper:]' '[:lower:]' | tr -d '-')
409
+ MATCH_FOUND=false
410
+ for FE_PATH in $FRONTEND_PATHS; do
411
+ FE_BASE=$(echo "$FE_PATH" | sed 's|/list$||;s|/new$||;s|/:id.*||;s|/create$||')
412
+ FE_NORM=$(echo "$FE_BASE" | tr '[:upper:]' '[:lower:]' | tr -d '-')
413
+ if [ "$SEED_NORM" = "$FE_NORM" ]; then
414
+ MATCH_FOUND=true
415
+ break
416
+ fi
417
+ done
418
+ if [ "$MATCH_FOUND" = false ]; then
419
+ echo "BLOCKING: Seed data route has no matching frontend route: $SEED_ROUTE"
420
+ MISMATCH_FOUND=true
421
+ fi
422
+ done
423
+ if [ "$MISMATCH_FOUND" = true ]; then
424
+ echo "Fix: Ensure every NavigationSeedData route has a corresponding contextRoutes entry in App.tsx"
425
+ exit 1
426
+ fi
427
+ fi
428
+ fi
429
+ ```
430
+
431
+ ### POST-CHECK 15: HasQueryFilter must not use Guid.Empty (OWASP A01)
432
+
433
+ ```bash
434
+ CONFIG_FILES=$(find src/ -path "*/Configurations/*" -name "*Configuration.cs" 2>/dev/null)
435
+ if [ -n "$CONFIG_FILES" ]; then
436
+ BAD_FILTERS=$(grep -Pn 'HasQueryFilter.*Guid\.Empty' $CONFIG_FILES 2>/dev/null)
437
+ if [ -n "$BAD_FILTERS" ]; then
438
+ echo "BLOCKING (OWASP A01): HasQueryFilter uses Guid.Empty instead of runtime tenant isolation"
439
+ echo "$BAD_FILTERS"
440
+ echo ""
441
+ echo "Anti-pattern: .HasQueryFilter(e => e.TenantId != Guid.Empty)"
442
+ echo "Fix: Remove HasQueryFilter. Tenant isolation is handled by SmartStack base DbContext"
443
+ exit 1
444
+ fi
445
+ fi
446
+ ```
447
+
448
+ ### POST-CHECK 16: GetAll methods must return PaginatedResult<T>
449
+
450
+ ```bash
451
+ SERVICE_FILES=$(find src/ -path "*/Services/*" -name "*Service.cs" ! -name "I*Service.cs" 2>/dev/null)
452
+ if [ -n "$SERVICE_FILES" ]; then
453
+ BAD_RETURNS=$(grep -Pn '(Task<\s*(?:List|IEnumerable|IList|ICollection|IReadOnlyList|IReadOnlyCollection)<).*GetAll' $SERVICE_FILES 2>/dev/null)
454
+ if [ -n "$BAD_RETURNS" ]; then
455
+ echo "BLOCKING: GetAll methods must return PaginatedResult<T>, not List/IEnumerable"
456
+ echo "$BAD_RETURNS"
457
+ echo "Fix: Change return type to Task<PaginatedResult<{Entity}ResponseDto>>"
458
+ exit 1
459
+ fi
460
+ fi
461
+ ```
462
+
463
+ ### POST-CHECK 17: i18n files must contain required structural keys
464
+
465
+ ```bash
466
+ I18N_DIR="src/i18n/locales/fr"
467
+ if [ -d "$I18N_DIR" ]; then
468
+ REQUIRED_KEYS="actions columns empty errors form labels messages validation"
469
+ for JSON_FILE in "$I18N_DIR"/*.json; do
470
+ [ ! -f "$JSON_FILE" ] && continue
471
+ BASENAME=$(basename "$JSON_FILE")
472
+ case "$BASENAME" in common.json|navigation.json) continue;; esac
473
+ for KEY in $REQUIRED_KEYS; do
474
+ if ! jq -e "has(\"$KEY\")" "$JSON_FILE" > /dev/null 2>&1; then
475
+ echo "BLOCKING: i18n file missing required key '$KEY': $JSON_FILE"
476
+ echo "Module i18n files MUST contain: $REQUIRED_KEYS"
477
+ exit 1
478
+ fi
479
+ done
480
+ done
481
+ fi
482
+ ```
483
+
484
+ ### POST-CHECK 18: Entities must implement IAuditableEntity + Validators must have Create/Update pairs
485
+
486
+ ```bash
487
+ ENTITY_FILES=$(find src/ -path "*/Domain/Entities/*" -name "*.cs" 2>/dev/null)
488
+ if [ -n "$ENTITY_FILES" ]; then
489
+ for f in $ENTITY_FILES; do
490
+ if grep -q "ITenantEntity" "$f" && ! grep -q "IAuditableEntity" "$f"; then
491
+ echo "BLOCKING: Entity implements ITenantEntity but NOT IAuditableEntity: $f"
492
+ echo "Pattern: public class Entity : BaseEntity, ITenantEntity, IAuditableEntity"
493
+ exit 1
494
+ fi
495
+ done
496
+ fi
497
+ CREATE_VALIDATORS=$(find src/ -path "*/Validators/*" -name "Create*Validator.cs" 2>/dev/null)
498
+ if [ -n "$CREATE_VALIDATORS" ]; then
499
+ for f in $CREATE_VALIDATORS; do
500
+ VALIDATOR_DIR=$(dirname "$f")
501
+ ENTITY_NAME=$(basename "$f" | sed 's/^Create\(.*\)Validator\.cs$/\1/')
502
+ if [ ! -f "$VALIDATOR_DIR/Update${ENTITY_NAME}Validator.cs" ]; then
503
+ echo "BLOCKING: Create${ENTITY_NAME}Validator exists but Update${ENTITY_NAME}Validator is missing"
504
+ echo " Found: $f"
505
+ echo " Expected: $VALIDATOR_DIR/Update${ENTITY_NAME}Validator.cs"
506
+ exit 1
507
+ fi
508
+ done
509
+ fi
510
+ ```
511
+
376
512
  **If ANY POST-CHECK fails → fix in step-03, re-validate.**
377
513
 
378
514
  ---
@@ -414,6 +550,11 @@ AC2: {criterion} → PASS / FAIL (evidence: {file:line or test})
414
550
  | FK fields: EntityLookup, no plain text | PASS / N/A |
415
551
  | APIs: search parameter on GetAll | PASS / N/A |
416
552
  | CSS variables: no hardcoded colors | PASS / N/A |
553
+ | Routes: seed data vs frontend match | PASS / N/A |
554
+ | HasQueryFilter: no Guid.Empty pattern | PASS / N/A |
555
+ | GetAll: PaginatedResult required | PASS / N/A |
556
+ | I18n: required key structure | PASS / N/A |
557
+ | Entities: IAuditableEntity + validators | PASS / N/A |
417
558
  | Acceptance criteria | {X}/{Y} PASS |
418
559
  ```
419
560
 
@@ -107,7 +107,20 @@ dotnet ef migrations add "$MIGRATION_NAME" \
107
107
  --verbose
108
108
  ```
109
109
 
110
- ### 6. Verify Creation
110
+ ### 6. Cleanup EF Core Design-Time Artifacts
111
+
112
+ ```bash
113
+ # EF Core's Roslyn BuildHost creates corrupted bin folders on Windows (U+F05C instead of backslash)
114
+ # Clean up any bin*Debug folders that are NOT the normal bin/Debug path
115
+ for d in src/*/bin?Debug; do
116
+ if [ -d "$d" ]; then
117
+ echo "Removing corrupted EF Core artifact: $d"
118
+ rm -rf "$d"
119
+ fi
120
+ done
121
+ ```
122
+
123
+ ### 7. Verify Creation
111
124
 
112
125
  ```bash
113
126
  # Check files were created
@@ -56,8 +56,9 @@ Execution sequence:
56
56
  > NEVER use `$HOME/.dotnet/tools` alone — on WSL, `$HOME` resolves to `/home/{user}` where .NET SDK is not installed.
57
57
  1. Call `mcp__smartstack__suggest_migration` → get standardized name
58
58
  2. `dotnet ef migrations add {Name} --project src/{Infra}.csproj --startup-project src/{Api}.csproj -o Persistence/Migrations`
59
- 3. `dotnet ef database update --project src/{Infra}.csproj --startup-project src/{Api}.csproj`
60
- 4. `dotnet build --no-restore` verify build passes
59
+ 3. Cleanup corrupted EF Core artifacts: `for d in src/*/bin?Debug; do [ -d "$d" ] && rm -rf "$d"; done`
60
+ 4. `dotnet ef database update --project src/{Infra}.csproj --startup-project src/{Api}.csproj`
61
+ 5. `dotnet build --no-restore` → verify build passes
61
62
 
62
63
  **BLOCKING:** If migration fails, DO NOT proceed. Fix the EF configs first.
63
64
 
@@ -477,6 +478,29 @@ useState(showCreateModal/editDialog) → navigate to form page, NEVER to
477
478
  - Coverage >= 80%
478
479
  - No `[Fact(Skip = "...")]`
479
480
 
481
+ **Frontend test completeness (BLOCKING):**
482
+
483
+ After all frontend pages are generated, verify test coverage:
484
+
485
+ ```bash
486
+ # Count page files vs test files
487
+ PAGE_COUNT=$(find src/pages/ -name "*.tsx" ! -name "*.test.tsx" 2>/dev/null | wc -l)
488
+ TEST_COUNT=$(find src/pages/ -name "*.test.tsx" 2>/dev/null | wc -l)
489
+
490
+ if [ "$PAGE_COUNT" -gt 0 ] && [ "$TEST_COUNT" -eq 0 ]; then
491
+ echo "BLOCKING: Category D — No .test.tsx files found in src/pages/"
492
+ echo "Every module with pages MUST have at least one test file"
493
+ echo "Pages found: $PAGE_COUNT, Tests found: 0"
494
+ exit 1
495
+ fi
496
+
497
+ # Minimum ratio: at least 1 test per 3 pages
498
+ MIN_TESTS=$(( (PAGE_COUNT + 2) / 3 ))
499
+ if [ "$TEST_COUNT" -lt "$MIN_TESTS" ]; then
500
+ echo "WARNING: Low test coverage — $TEST_COUNT tests for $PAGE_COUNT pages (minimum: $MIN_TESTS)"
501
+ fi
502
+ ```
503
+
480
504
  ---
481
505
 
482
506
  ## Validation (FINAL — BLOCKING)
@@ -108,7 +108,7 @@ Batch: {batch.length} [{firstCategory}] → {batch.map(t => `[${t.id}] ${t.descr
108
108
 
109
109
  | Category | Action |
110
110
  |----------|--------|
111
- | `infrastructure` with `_migrationMeta` | Migration sequence: `suggest_migration` MCP → `dotnet ef migrations add` → `dotnet ef database update` → `dotnet build` |
111
+ | `infrastructure` with `_migrationMeta` | Migration sequence: `suggest_migration` MCP → `dotnet ef migrations add` → cleanup corrupted artifacts (`for d in src/*/bin?Debug; do [ -d "$d" ] && rm -rf "$d"; done`) → `dotnet ef database update` → `dotnet build` |
112
112
  | `infrastructure` with seed data keywords | **MANDATORY:** Read `references/core-seed-data.md` → implement templates → `dotnet build` |
113
113
  | `frontend` | MCP-first: `scaffold_api_client` → `scaffold_routes` (outputFormat: clientRoutes) → **wire to App.tsx (detect pattern: `contextRoutes` array OR JSX `<Route>`)** → create pages → `npm run typecheck && npm run lint` |
114
114
  | `test` | **Create tests:** `scaffold_tests` MCP → **Run:** `dotnet test --verbosity normal` → **Fix loop:** if fail → fix SOURCE CODE → rebuild → retest → repeat until 100% pass |
@@ -271,6 +271,17 @@ if [ -n "$LIST_PAGES" ]; then
271
271
  done
272
272
  fi
273
273
 
274
+ # First check: at least one test file must exist in pages/
275
+ PAGE_FILES=$(find src/pages/ -name "*.tsx" ! -name "*.test.tsx" 2>/dev/null)
276
+ if [ -n "$PAGE_FILES" ]; then
277
+ TEST_FILES=$(find src/pages/ -name "*.test.tsx" 2>/dev/null)
278
+ if [ -z "$TEST_FILES" ]; then
279
+ echo "BLOCKING: No frontend test files found in src/pages/"
280
+ echo "Every module with pages MUST have at least one .test.tsx file"
281
+ exit 1
282
+ fi
283
+ fi
284
+
274
285
  # POST-CHECK: Form pages must have companion test files
275
286
  FORM_PAGES=$(find src/pages/ -name "*CreatePage.tsx" -o -name "*EditPage.tsx" 2>/dev/null | grep -v test)
276
287
  if [ -n "$FORM_PAGES" ]; then
@@ -286,7 +297,8 @@ fi
286
297
  # POST-CHECK: FK fields must NOT be plain text inputs — use EntityLookup
287
298
  FORM_PAGES=$(find src/pages/ -name "*CreatePage.tsx" -o -name "*EditPage.tsx" 2>/dev/null | grep -v test | grep -v node_modules)
288
299
  if [ -n "$FORM_PAGES" ]; then
289
- FK_TEXT_INPUTS=$(grep -Pn 'type=["\x27]text["\x27].*[a-z]+Id|value=\{[^}]*\.[a-z]+Id\}.*type=["\x27]text["\x27]' $FORM_PAGES 2>/dev/null)
300
+ # Match any input with name ending in "Id" (except hidden inputs)
301
+ FK_TEXT_INPUTS=$(grep -Pn '<input[^>]*name=["\x27][a-zA-Z]*Id["\x27]' $FORM_PAGES 2>/dev/null | grep -v 'type=["\x27]hidden["\x27]')
290
302
  if [ -n "$FK_TEXT_INPUTS" ]; then
291
303
  echo "BLOCKING: FK Guid fields rendered as plain text inputs — MUST use EntityLookup"
292
304
  echo "See smartstack-frontend.md section 6 for the EntityLookup pattern"
@@ -313,6 +325,74 @@ if [ -n "$CTRL_FILES" ]; then
313
325
  fi
314
326
  done
315
327
  fi
328
+
329
+ # POST-CHECK: Route seed data vs frontend cross-validation
330
+ SEED_NAV_FILES=$(find src/ -path "*/Seeding/Data/*" -name "*NavigationSeedData.cs" 2>/dev/null)
331
+ APP_TSX=$(find src/ -name "App.tsx" -not -path "*/node_modules/*" 2>/dev/null | head -1)
332
+ if [ -n "$SEED_NAV_FILES" ] && [ -n "$APP_TSX" ]; then
333
+ SEED_ROUTES=$(grep -ohP 'Route\s*=\s*"([^"]+)"' $SEED_NAV_FILES | sed 's/Route\s*=\s*"//' | sed 's/"//' | sort -u)
334
+ CLIENT_PATHS=$(grep -ohP "path:\s*['\"]([^'\"]+)['\"]" "$APP_TSX" | sed "s/path:\s*['\"]//;s/['\"]//" | sort -u)
335
+ if [ -n "$SEED_ROUTES" ] && [ -z "$CLIENT_PATHS" ]; then
336
+ echo "WARNING: Seed data has navigation routes but App.tsx has no client routes defined"
337
+ fi
338
+ fi
339
+
340
+ # POST-CHECK: HasQueryFilter anti-pattern
341
+ CONFIG_FILES=$(find src/ -path "*/Configurations/*" -name "*Configuration.cs" 2>/dev/null)
342
+ if [ -n "$CONFIG_FILES" ]; then
343
+ BAD_FILTER=$(grep -Pn 'HasQueryFilter.*Guid\.Empty' $CONFIG_FILES 2>/dev/null)
344
+ if [ -n "$BAD_FILTER" ]; then
345
+ echo "BLOCKING: HasQueryFilter uses Guid.Empty — must use runtime tenant filter"
346
+ echo "$BAD_FILTER"
347
+ exit 1
348
+ fi
349
+ fi
350
+
351
+ # POST-CHECK: PaginatedResult<T> for GetAll
352
+ SERVICE_FILES=$(find src/ -path "*/Services/*" -name "*Service.cs" ! -name "I*Service.cs" 2>/dev/null)
353
+ if [ -n "$SERVICE_FILES" ]; then
354
+ BAD_GETALL=$(grep -Pn 'Task<(List|IEnumerable|IList|ICollection)<' $SERVICE_FILES 2>/dev/null | grep -i "getall")
355
+ if [ -n "$BAD_GETALL" ]; then
356
+ echo "BLOCKING: GetAll methods must return PaginatedResult<T>, not List/IEnumerable"
357
+ echo "$BAD_GETALL"
358
+ exit 1
359
+ fi
360
+ fi
361
+
362
+ # POST-CHECK: i18n required key structure
363
+ FR_I18N_FILES=$(find src/i18n/locales/fr/ -name "*.json" 2>/dev/null)
364
+ if [ -n "$FR_I18N_FILES" ]; then
365
+ for f in $FR_I18N_FILES; do
366
+ BASENAME=$(basename "$f")
367
+ case "$BASENAME" in common.json|navigation.json) continue;; esac
368
+ for KEY in "actions" "labels" "errors"; do
369
+ if ! grep -q "\"$KEY\"" "$f"; then
370
+ echo "WARNING: i18n file missing required key '$KEY': $f"
371
+ fi
372
+ done
373
+ done
374
+ fi
375
+
376
+ # POST-CHECK: IAuditableEntity + Validator pairing
377
+ ENTITY_FILES=$(find src/ -path "*/Domain/Entities/Business/*" -name "*.cs" 2>/dev/null)
378
+ if [ -n "$ENTITY_FILES" ]; then
379
+ for f in $ENTITY_FILES; do
380
+ if grep -q "ITenantEntity" "$f" && ! grep -q "IAuditableEntity" "$f"; then
381
+ echo "WARNING: Entity has ITenantEntity but missing IAuditableEntity: $f"
382
+ fi
383
+ done
384
+ fi
385
+ CREATE_VALIDATORS=$(find src/ -path "*/Validators/*" -name "Create*Validator.cs" 2>/dev/null)
386
+ if [ -n "$CREATE_VALIDATORS" ]; then
387
+ for CV in $CREATE_VALIDATORS; do
388
+ UV=$(echo "$CV" | sed 's/Create/Update/')
389
+ if [ ! -f "$UV" ]; then
390
+ echo "BLOCKING: CreateValidator without matching UpdateValidator: $CV"
391
+ echo "Expected: $UV"
392
+ exit 1
393
+ fi
394
+ done
395
+ fi
316
396
  ```
317
397
 
318
398
  **Error resolution cycle (ALL categories):**
@@ -15,9 +15,12 @@ next_step: steps/step-02-unit-tests.md
15
15
  ls *.sln 2>/dev/null || find . -maxdepth 2 -name "*.sln" -type f
16
16
  ```
17
17
 
18
- ### 2. Build the entire solution
18
+ ### 2. Cleanup & Build
19
19
 
20
20
  ```bash
21
+ # Cleanup corrupted EF Core design-time artifacts (Roslyn BuildHost bug on Windows)
22
+ for d in src/*/bin?Debug; do [ -d "$d" ] && echo "Removing corrupted artifact: $d" && rm -rf "$d"; done
23
+
21
24
  dotnet build {SolutionFile} --verbosity minimal
22
25
  ```
23
26