@atlashub/smartstack-cli 3.31.0 → 3.32.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.
Files changed (52) hide show
  1. package/.documentation/installation.html +7 -2
  2. package/README.md +7 -1
  3. package/dist/index.js +33 -37
  4. package/dist/index.js.map +1 -1
  5. package/dist/mcp-entry.mjs +547 -97
  6. package/dist/mcp-entry.mjs.map +1 -1
  7. package/package.json +1 -1
  8. package/scripts/health-check.sh +2 -1
  9. package/templates/mcp-scaffolding/controller.cs.hbs +10 -7
  10. package/templates/mcp-scaffolding/entity-extension.cs.hbs +132 -124
  11. package/templates/mcp-scaffolding/frontend/api-client.ts.hbs +4 -4
  12. package/templates/mcp-scaffolding/tests/controller.test.cs.hbs +38 -15
  13. package/templates/mcp-scaffolding/tests/service.test.cs.hbs +20 -8
  14. package/templates/skills/apex/SKILL.md +7 -9
  15. package/templates/skills/apex/_shared.md +9 -2
  16. package/templates/skills/apex/references/code-generation.md +412 -0
  17. package/templates/skills/apex/references/post-checks.md +377 -37
  18. package/templates/skills/apex/references/smartstack-api.md +229 -5
  19. package/templates/skills/apex/references/smartstack-frontend.md +368 -11
  20. package/templates/skills/apex/references/smartstack-layers.md +54 -7
  21. package/templates/skills/apex/steps/step-00-init.md +1 -2
  22. package/templates/skills/apex/steps/step-01-analyze.md +45 -2
  23. package/templates/skills/apex/steps/step-02-plan.md +23 -2
  24. package/templates/skills/apex/steps/step-03-execute.md +195 -5
  25. package/templates/skills/apex/steps/step-04-examine.md +18 -5
  26. package/templates/skills/apex/steps/step-05-deep-review.md +9 -11
  27. package/templates/skills/apex/steps/step-06-resolve.md +5 -9
  28. package/templates/skills/apex/steps/step-07-tests.md +66 -1
  29. package/templates/skills/apex/steps/step-08-run-tests.md +12 -3
  30. package/templates/skills/application/references/provider-template.md +62 -39
  31. package/templates/skills/application/templates-backend.md +3 -3
  32. package/templates/skills/application/templates-frontend.md +12 -12
  33. package/templates/skills/application/templates-seed.md +14 -4
  34. package/templates/skills/business-analyse/SKILL.md +9 -6
  35. package/templates/skills/business-analyse/questionnaire/04-data.md +8 -0
  36. package/templates/skills/business-analyse/references/agent-module-prompt.md +84 -5
  37. package/templates/skills/business-analyse/references/agent-pooling-best-practices.md +83 -19
  38. package/templates/skills/business-analyse/references/consolidation-structural-checks.md +6 -2
  39. package/templates/skills/business-analyse/references/team-orchestration.md +443 -110
  40. package/templates/skills/business-analyse/references/validation-checklist.md +5 -4
  41. package/templates/skills/business-analyse/schemas/sections/analysis-schema.json +44 -0
  42. package/templates/skills/business-analyse/steps/step-03a2-analysis.md +72 -1
  43. package/templates/skills/business-analyse/steps/step-03c-compile.md +93 -7
  44. package/templates/skills/business-analyse/steps/step-03d-validate.md +34 -2
  45. package/templates/skills/business-analyse/steps/step-04b-analyze.md +40 -0
  46. package/templates/skills/controller/references/controller-code-templates.md +2 -2
  47. package/templates/skills/controller/templates.md +12 -12
  48. package/templates/skills/feature-full/steps/step-01-implementation.md +4 -4
  49. package/templates/skills/ralph-loop/references/category-rules.md +44 -2
  50. package/templates/skills/ralph-loop/references/compact-loop.md +37 -0
  51. package/templates/skills/ralph-loop/references/core-seed-data.md +51 -20
  52. package/templates/skills/review-code/references/owasp-api-top10.md +1 -1
@@ -228,10 +228,11 @@ const checklist = {
228
228
  },
229
229
 
230
230
  seedDataBusiness: {
231
- check: "Business seed data template defined for modules with reference/lookup entities",
232
- status: specification.seedDataBusiness !== undefined ? "PASS" : "FAIL",
233
- blocking: true, // BLOCKINGmissing seed data causes empty dropdowns and test failures
234
- details: "Business seed data (reference types, categories, statuses) is required for testing and dev environment"
231
+ check: "Business seed data template defined (if applicable generated during implementation, not BA)",
232
+ status: specification.seedDataBusiness !== undefined ? "PASS" : "WARN",
233
+ blocking: false, // WARNING only business seed data is generated during ralph-loop implementation, not during BA phase
234
+ details: "Business seed data (reference types, categories, statuses) is generated during implementation (ralph-loop). " +
235
+ "BA phase ensures seedDataCore is complete (navigation, permissions). seedDataBusiness is optional at this stage."
235
236
  },
236
237
 
237
238
  // SECTION 8: API ENDPOINTS (BLOCKING)
@@ -78,6 +78,50 @@
78
78
  "description": { "type": "string" }
79
79
  }
80
80
  }
81
+ },
82
+ "codePattern": {
83
+ "type": "object",
84
+ "description": "Code auto-generation strategy for this entity. Defines how entity codes are automatically generated (e.g., acme-emp-00001).",
85
+ "properties": {
86
+ "strategy": {
87
+ "type": "string",
88
+ "enum": ["sequential", "timestamp-daily", "timestamp-minute", "year-sequential", "uuid-short", "manual"],
89
+ "default": "sequential",
90
+ "description": "Code generation strategy. sequential: {tenant}-{prefix}-{N}. timestamp-daily: {tenant}-{prefix}-{YYYYMMDD}-{N}. timestamp-minute: {tenant}-{prefix}-{YYYYMMDDHHmm}-{N}. year-sequential: {tenant}-{prefix}-{YYYY}-{N}. uuid-short: {tenant}-{prefix}-{8hex}. manual: user-provided."
91
+ },
92
+ "prefix": {
93
+ "type": "string",
94
+ "pattern": "^[a-z]{2,6}$",
95
+ "description": "Entity prefix in the code (e.g., emp for Employee, inv for Invoice)"
96
+ },
97
+ "includeTenantSlug": {
98
+ "type": "boolean",
99
+ "default": true,
100
+ "description": "Whether to include the tenant slug as prefix (e.g., acme-emp-00001 vs emp-00001)"
101
+ },
102
+ "separator": {
103
+ "type": "string",
104
+ "enum": ["-", "_"],
105
+ "default": "-",
106
+ "description": "Separator between code segments"
107
+ },
108
+ "estimatedVolume": {
109
+ "type": "integer",
110
+ "minimum": 10,
111
+ "maximum": 10000000,
112
+ "description": "Estimated number of records per tenant. Used to calculate digit count via x10 rule: digits = max(4, ceil(log10(volume * 10)))"
113
+ },
114
+ "digits": {
115
+ "type": "integer",
116
+ "minimum": 4,
117
+ "maximum": 7,
118
+ "description": "Number of digits for sequential part. Auto-calculated from estimatedVolume if omitted."
119
+ },
120
+ "example": {
121
+ "type": "string",
122
+ "description": "Example generated code for documentation (e.g., acme-emp-00001)"
123
+ }
124
+ }
81
125
  }
82
126
  }
83
127
  }
@@ -88,6 +88,61 @@ Define entities for this module (business attributes, not technical):
88
88
  > **Why forbidden:** `tableName`, `primaryKey` are technical fields that do NOT belong in analysis. `fields[]` is NOT recognized by the build pipeline — only `attributes[]` is. `type` with SQL types (UUID, string, timestamp) belongs in implementation, not analysis. Using this format causes **empty entity data in the HTML documentation** and **broken PRD extraction**.
89
89
  > The canonical format uses `attributes[]` (not `fields[]`), `relationships[]` (not FK inside fields), and NO technical fields (tableName, primaryKey).
90
90
 
91
+ #### 6b-bis. Code Pattern Definition
92
+
93
+ For EACH entity defined above, determine the code generation strategy.
94
+
95
+ > **STRUCTURE CARD: analysis.entities[].codePattern**
96
+ > ```json
97
+ > {
98
+ > "strategy": "sequential|timestamp-daily|timestamp-minute|year-sequential|uuid-short|manual",
99
+ > "prefix": "emp",
100
+ > "includeTenantSlug": true,
101
+ > "separator": "-",
102
+ > "estimatedVolume": 1000,
103
+ > "digits": 5,
104
+ > "example": "acme-emp-00001"
105
+ > }
106
+ > ```
107
+
108
+ **Strategy selection heuristic** (use as default when user does not specify):
109
+
110
+ | Entity type / name pattern | Default strategy | Example |
111
+ |---------------------------|-----------------|---------|
112
+ | Invoice, Order, Receipt, Bill, Delivery | `timestamp-daily` | `acme-inv-20260215-001` |
113
+ | Ticket, Request, Incident, Support, Alert | `timestamp-minute` | `acme-ticket-202602151430-001` |
114
+ | Contract, Agreement, Policy, License | `year-sequential` | `acme-ctr-2026-00001` |
115
+ | Reference, Category, Tag, Type, Status | `uuid-short` | `acme-ref-a1b2c3d4` |
116
+ | Employee, Customer, Partner, Project, Product | `sequential` | `acme-emp-00001` |
117
+ | User says "manual" or "user-provided" | `manual` | (user types the code) |
118
+
119
+ **Digits calculation (x10 rule):**
120
+ - Use Q4.4 (estimated volume) as input
121
+ - Apply: `digits = max(4, ceil(log10(volume * 10)))`
122
+ - If no volume estimate: default to 5 digits (capacity: 99,999)
123
+
124
+ **Interactive question (if NOT team agent):**
125
+
126
+ ```yaml
127
+ questions:
128
+ - header: "Code Pattern"
129
+ question: "For entity {entityName}, how should the Code be generated?"
130
+ options:
131
+ - label: "Auto-sequential (Recommended)"
132
+ description: "{tenant}-{prefix}-00001 — simple sequential numbering"
133
+ - label: "Date-based (daily)"
134
+ description: "{tenant}-{prefix}-20260215-001 — includes creation date"
135
+ - label: "Year-sequential"
136
+ description: "{tenant}-{prefix}-2026-00001 — yearly numbering"
137
+ - label: "Manual entry"
138
+ description: "User provides the code — no auto-generation"
139
+ multiSelect: false
140
+ ```
141
+
142
+ If team agent: use heuristic defaults autonomously.
143
+
144
+ **Reference:** See `apex/references/code-generation.md` for full ICodeGenerator<T> implementation details, DI registration patterns, and backend service integration.
145
+
91
146
  #### 6c. Process Flow
92
147
 
93
148
  Define the main business process flow for this module (not lifecycle — that's in 8j):
@@ -109,7 +164,10 @@ Define the main business process flow for this module (not lifecycle — that's
109
164
  > ```
110
165
  > **FORBIDDEN:** Do NOT put lifecycle/state machine data here. Use `specification.lifeCycles[]` for that.
111
166
 
112
- #### 6d. Data Lifecycle
167
+ #### 6d. Data Lifecycle (MANDATORY — ALL modules)
168
+
169
+ > **CRITICAL:** This section is MANDATORY for EVERY module, even simple ones without GDPR requirements.
170
+ > Missing `analysis.dataLifecycle` causes BLOCKING errors in step-03d validation and consolidation structural checks.
113
171
 
114
172
  Define data retention and lifecycle policies:
115
173
 
@@ -126,6 +184,19 @@ Define data retention and lifecycle policies:
126
184
  > ]
127
185
  > }
128
186
  > ```
187
+ >
188
+ > **Standard defaults** (use for modules without specific GDPR/retention requirements):
189
+ > ```json
190
+ > {
191
+ > "retentionPeriod": "Standard (as per company policy)",
192
+ > "archiveStrategy": "Soft-delete with audit trail",
193
+ > "gdprCompliance": "N/A — no PII or standard audit trail only",
194
+ > "states": [
195
+ > { "name": "active", "transitions": ["archived"] },
196
+ > { "name": "archived", "transitions": ["active"] }
197
+ > ]
198
+ > }
199
+ > ```
129
200
 
130
201
  ### 7. Business Rules Extraction
131
202
 
@@ -258,13 +258,47 @@ Module → Sections → Resources (levels 3-4-5 of the hierarchy).
258
258
  > ```json
259
259
  > {
260
260
  > "entries": [
261
- > { "level": "module", "code": "{module}", "labels": {"fr": "...", "en": "..."}, "route": "/business/{app}/{module}", "icon": "list" },
261
+ > { "level": "module", "code": "{module}", "labels": {"fr": "...", "en": "...", "it": "...", "de": "..."}, "route": "/business/{app}/{module}", "icon": "list" },
262
262
  > { "level": "section", "code": "list", "labels": {"fr": "Liste", "en": "List", "it": "Elenco", "de": "Liste"}, "route": "/business/{app}/{module}", "icon": "list" },
263
- > { "level": "section", "code": "dashboard", "labels": {"fr": "Dashboard", "en": "Dashboard"}, "route": "/business/{app}/{module}/dashboard", "icon": "chart-bar", "isNew": true }
263
+ > { "level": "section", "code": "dashboard", "labels": {"fr": "Tableau de bord", "en": "Dashboard", "it": "Cruscotto", "de": "Dashboard"}, "route": "/business/{app}/{module}/dashboard", "icon": "chart-bar", "isNew": true }
264
264
  > ]
265
265
  > }
266
266
  > ```
267
267
 
268
+ #### 8e-POST-CHECK: Navigation Enforcement (BLOCKING)
269
+
270
+ > **CRITICAL:** `specification.navigation.entries[]` is MANDATORY. If absent, auto-generate from sections.
271
+
272
+ ```javascript
273
+ const nav = specification.navigation;
274
+ if (!nav || !nav.entries || nav.entries.length === 0) {
275
+ console.warn('AUTO-FIX: navigation.entries is empty — generating from sections');
276
+ const sections = specification.sections || [];
277
+ const entries = [
278
+ { level: "module", code: "{module}", labels: {fr: "{ModuleName}", en: "{ModuleName}", it: "{ModuleName}", de: "{ModuleName}"}, route: "/business/{app}/{module}", icon: "list" }
279
+ ];
280
+ for (const section of sections) {
281
+ entries.push({
282
+ level: "section",
283
+ code: section.code,
284
+ labels: {fr: section.name || section.code, en: section.name || section.code, it: section.name || section.code, de: section.name || section.code},
285
+ route: section.route || `/business/{app}/{module}/${section.code}`,
286
+ icon: section.icon || "file-text"
287
+ });
288
+ }
289
+ specification.navigation = { entries };
290
+ }
291
+ // Verify ALL entries have 4 languages
292
+ for (const entry of specification.navigation.entries) {
293
+ for (const lang of ['fr', 'en', 'it', 'de']) {
294
+ if (!entry.labels[lang]) {
295
+ entry.labels[lang] = entry.labels['en'] || entry.labels['fr'] || entry.code;
296
+ console.warn(`AUTO-FIX: navigation entry "${entry.code}" missing ${lang} label — copied from fallback`);
297
+ }
298
+ }
299
+ }
300
+ ```
301
+
268
302
  #### 8f. SeedData Core
269
303
 
270
304
  7 MANDATORY typed arrays — each with structured objects, NOT flat strings or objects.
@@ -489,17 +523,64 @@ RESTful routes following SmartStack patterns.
489
523
  Translation keys for all UI text (4 languages: fr, en, it, de).
490
524
 
491
525
  > **STRUCTURE CARD: specification.i18nKeys**
526
+ > **CRITICAL:** ALL leaf translation keys MUST have 4 languages (fr, en, it, de). IT/DE can use shorter translations but MUST exist.
492
527
  > ```json
493
528
  > {
494
529
  > "title": { "fr": "{Module}", "en": "{Module}", "it": "{Module}", "de": "{Module}" },
495
- > "list": { "title": { "fr": "Liste", "en": "List" }, "empty": { "fr": "Aucun enregistrement", "en": "No records" } },
496
- > "create": { "title": { "fr": "Nouveau", "en": "New" } },
497
- > "detail": { "title": { "fr": "Détail", "en": "Detail" } },
498
- > "buttons": { "create": { "fr": "Créer", "en": "Create" }, "edit": { "fr": "Modifier", "en": "Edit" }, "delete": { "fr": "Supprimer", "en": "Delete" } },
499
- > "validation": { "required": { "fr": "Ce champ est requis", "en": "This field is required" } }
530
+ > "list": {
531
+ > "title": { "fr": "Liste", "en": "List", "it": "Elenco", "de": "Liste" },
532
+ > "empty": { "fr": "Aucun enregistrement", "en": "No records", "it": "Nessun record", "de": "Keine Einträge" }
533
+ > },
534
+ > "create": { "title": { "fr": "Nouveau", "en": "New", "it": "Nuovo", "de": "Neu" } },
535
+ > "detail": { "title": { "fr": "Détail", "en": "Detail", "it": "Dettaglio", "de": "Detail" } },
536
+ > "buttons": {
537
+ > "create": { "fr": "Créer", "en": "Create", "it": "Crea", "de": "Erstellen" },
538
+ > "edit": { "fr": "Modifier", "en": "Edit", "it": "Modifica", "de": "Bearbeiten" },
539
+ > "delete": { "fr": "Supprimer", "en": "Delete", "it": "Elimina", "de": "Löschen" },
540
+ > "save": { "fr": "Enregistrer", "en": "Save", "it": "Salva", "de": "Speichern" },
541
+ > "cancel": { "fr": "Annuler", "en": "Cancel", "it": "Annulla", "de": "Abbrechen" }
542
+ > },
543
+ > "validation": {
544
+ > "required": { "fr": "Ce champ est requis", "en": "This field is required", "it": "Campo obbligatorio", "de": "Pflichtfeld" }
545
+ > }
500
546
  > }
501
547
  > ```
502
548
 
549
+ #### 8l-POST-CHECK: i18n 4 Languages Enforcement (BLOCKING)
550
+
551
+ > **CRITICAL:** i18n keys with missing IT/DE translations are a recurring issue.
552
+ > This POST-CHECK walks ALL leaf keys and auto-fills missing translations.
553
+
554
+ ```javascript
555
+ const i18n = specification.i18nKeys || {};
556
+ const REQUIRED_LANGS = ['fr', 'en', 'it', 'de'];
557
+ let missingCount = 0;
558
+
559
+ function walkI18n(obj, path) {
560
+ if (typeof obj !== 'object' || obj === null) return;
561
+ // Leaf node: has at least 'fr' or 'en' as direct string properties
562
+ const hasLangKey = REQUIRED_LANGS.some(l => typeof obj[l] === 'string');
563
+ if (hasLangKey) {
564
+ for (const lang of REQUIRED_LANGS) {
565
+ if (!obj[lang] || typeof obj[lang] !== 'string') {
566
+ // Fallback: copy from en > fr > first available
567
+ obj[lang] = obj['en'] || obj['fr'] || Object.values(obj).find(v => typeof v === 'string') || path;
568
+ missingCount++;
569
+ }
570
+ }
571
+ } else {
572
+ // Branch node: recurse
573
+ for (const key of Object.keys(obj)) {
574
+ walkI18n(obj[key], `${path}.${key}`);
575
+ }
576
+ }
577
+ }
578
+ walkI18n(i18n, 'i18nKeys');
579
+ if (missingCount > 0) {
580
+ console.warn(`AUTO-FIX: filled ${missingCount} missing i18n translations (IT/DE fallbacks from EN/FR)`);
581
+ }
582
+ ```
583
+
503
584
  ---
504
585
 
505
586
  ## SELF-VERIFICATION (MANDATORY before loading next step)
@@ -521,6 +602,11 @@ Before loading step-03d-validate, verify all 12 subsections (8a-8l) are populate
521
602
 
522
603
  13. **Wireframes present** — `(specification.uiWireframes || specification.wireframes || []).length >= 1` (BLOCKING)
523
604
  Quick check: the wireframe data from step-03b MUST still be in memory OR persisted to feature.json
605
+
606
+ 14. **Wireframes create/edit** — For each data-centric section with a create form, a wireframe `{section}-create` MUST exist (BLOCKING).
607
+ For each modifiable entity, a wireframe `{section}-edit` SHOULD exist (WARNING if absent).
608
+ Create/edit wireframes are action-page wireframes — they are NOT separate navigation sections.
609
+ They describe the full-page form layout (fields, validation, submit/cancel buttons).
524
610
  (step-03b now writes wireframes intermediately). If wireframes are empty:
525
611
  - First, re-read the module feature.json to check if step-03b wrote them
526
612
  - If present in file but not in memory → load them from file
@@ -60,7 +60,9 @@ Validate the module specification for completeness and consistency, write to fea
60
60
  | permissionMatrix | 1 resource × 2 roles | `specification.permissionMatrix.permissions.length >= 1 && specification.permissionMatrix.roleAssignments.length >= 2` | PASS/FAIL |
61
61
  | entities | 1 | `analysis.entities.length >= 1` | PASS/FAIL |
62
62
  | entitySchemaFormat | attributes[] not fields[] (BLOCKING) | `analysis.entities.every(e => e.attributes?.length > 0)` | PASS/FAIL |
63
- | entityAttributeTypes | ALL attributes have `type` field (BLOCKING) | `analysis.entities.every(e => e.attributes.every(a => a.type))` | PASS/FAIL |
63
+ | entityAttributeTypes | ALL attributes have `type` field (BLOCKING) | `analysis.entities.every(e => e.attributes.every(a => a.type))` — **NOTE:** Entities in analysis phase may NOT have `type`. Step-03c auto-fix MUST have run first. If types are missing, RE-RUN step-03c auto-fix algorithm. | PASS/FAIL |
64
+ | navigation | entries[] present (BLOCKING) | `specification.navigation?.entries?.length >= 1` | PASS/FAIL |
65
+ | dataLifecycle | present in analysis (BLOCKING) | `analysis.dataLifecycle !== undefined` | PASS/FAIL |
64
66
  | wireframes | 1 per section (BLOCKING) | `(specification.uiWireframes \|\| specification.wireframes \|\| []).length >= (specification.sections \|\| []).length` (count REAL elements, check BOTH key names) | PASS/FAIL |
65
67
  | wireframeSchema | All required fields present (BLOCKING) | `(specification.uiWireframes \|\| specification.wireframes \|\| []).every(w => (w.screen \|\| w.title) && w.section && (w.mockup \|\| w.ascii \|\| w.content))` | PASS/FAIL |
66
68
  | sections | 1 (BLOCKING) | `specification.sections.length >= 1` (EVERY module needs at least 1 section) | PASS/FAIL |
@@ -243,6 +245,19 @@ ba-writer.enrichSection({
243
245
  })
244
246
  ```
245
247
 
248
+ #### 9e-POST-CHECK: Validation Section Persistence (BLOCKING)
249
+
250
+ > **CRITICAL:** The `validation` section MUST be persisted to feature.json. Without it, consolidation checks fail.
251
+
252
+ ```bash
253
+ MODULE_JSON="{module_feature_json_path}"
254
+ node -e "const d=JSON.parse(require('fs').readFileSync(process.argv[1],'utf-8'));
255
+ if(!d.validation||!d.validation.decision) { console.error('FAIL: validation section missing or incomplete'); process.exit(1); }
256
+ console.log('PASS: validation section present with decision');" "$MODULE_JSON"
257
+ ```
258
+
259
+ IF this check FAILS → re-execute section 9e write and re-run this check.
260
+
246
261
  #### 9f. Module Specification Checklist (BLOCKING)
247
262
 
248
263
  > **CRITICAL:** This checklist MUST be FULLY COMPLETED before marking module status = "specified".
@@ -453,10 +468,27 @@ const checks = [
453
468
  ['gherkin is array', Array.isArray(spec.gherkinScenarios)?1:0, 1],
454
469
  ['apiEndpoints >= 1', (spec.apiEndpoints||[]).length, 1],
455
470
  ['messages >= 4', (spec.messages||[]).length, 4],
456
- ['validations >= 1', (spec.validations||[]).length, 1]
471
+ ['validations >= 1', (spec.validations||[]).length, 1],
472
+ ['navigation entries', (spec.navigation?.entries||[]).length, 1],
473
+ ['dataLifecycle', analysis.dataLifecycle ? 1 : 0, 1],
474
+ ['validation section', data.validation ? 1 : 0, 1]
457
475
  ];
476
+ // AC-18: i18n 4 languages check
477
+ const i18n = spec.i18nKeys || {};
478
+ const i18nLangs = ['fr','en','it','de'];
479
+ let i18nMissing = 0;
480
+ function checkI18nLeaf(obj) {
481
+ if (typeof obj !== 'object' || obj === null) return;
482
+ const hasLang = i18nLangs.some(l => typeof obj[l] === 'string');
483
+ if (hasLang) { i18nLangs.forEach(l => { if (!obj[l]) i18nMissing++; }); }
484
+ else { Object.values(obj).forEach(v => checkI18nLeaf(v)); }
485
+ }
486
+ checkI18nLeaf(i18n);
487
+ checks.push(['i18n 4 languages (missing keys)', 0, i18nMissing > 10 ? 1 : 0]);
488
+
458
489
  const fails = checks.filter(c => c[1] < c[2]);
459
490
  fails.forEach(f => console.error('FAIL: ' + f[0] + ' = ' + f[1] + ' (min: ' + f[2] + ')'));
491
+ if (i18nMissing > 0 && i18nMissing <= 10) { console.warn('WARNING: ' + i18nMissing + ' i18n leaf keys missing translations (IT/DE)'); }
460
492
  // Check wireframe content
461
493
  const emptyWf = wf.filter(w => !w.mockup && !w.ascii && !w.content);
462
494
  if (emptyWf.length > 0) { fails.push(['wireframe content', 0, 1]); console.error('FAIL: ' + emptyWf.length + ' wireframes have EMPTY content'); }
@@ -87,6 +87,46 @@ options:
87
87
  description: "Le Manager devrait aussi pouvoir approuver les factures"
88
88
  ```
89
89
 
90
+ **3e. Permission-Role Coherence Across Modules (WARNING)**
91
+
92
+ For each role defined in `cadrage.applicationRoles`, verify the permission PATTERN is consistent across ALL modules:
93
+
94
+ ```javascript
95
+ // Build role-to-actions matrix per module
96
+ const roleActions = {};
97
+ for (const module of completedModules) {
98
+ const pm = module.specification.permissionMatrix;
99
+ for (const ra of pm.roleAssignments || []) {
100
+ const key = ra.role;
101
+ if (!roleActions[key]) roleActions[key] = {};
102
+ roleActions[key][module.code] = ra.permissions.map(p => p.split('.').pop()); // extract action
103
+ }
104
+ }
105
+
106
+ // Detect inconsistencies: role has action X in module A but not in module B
107
+ for (const [role, modules] of Object.entries(roleActions)) {
108
+ const allActions = new Set(Object.values(modules).flat());
109
+ for (const [mod, actions] of Object.entries(modules)) {
110
+ for (const action of allActions) {
111
+ if (!actions.includes(action)) {
112
+ // Role has this action in other modules but NOT in this one
113
+ const otherMods = Object.entries(modules).filter(([m, a]) => a.includes(action)).map(([m]) => m);
114
+ console.warn(`WARNING: permission-role-coherence: ${role} has "${action}" in [${otherMods.join(', ')}] but NOT in ${mod}`);
115
+ }
116
+ }
117
+ }
118
+ }
119
+ ```
120
+
121
+ Store as semantic check with severity WARNING (not ERROR — some permission differences may be intentional):
122
+ ```json
123
+ {
124
+ "check": "permission-role-coherence",
125
+ "status": "WARNING",
126
+ "details": "Contributor has 'create' in [Projects, TimeManagement] but NOT in Employees — verify if intentional"
127
+ }
128
+ ```
129
+
90
130
  ### 4. Semantic Validation (MANDATORY)
91
131
 
92
132
  For EACH module feature.json, execute these checks:
@@ -49,7 +49,7 @@ public class {module}Controller : ControllerBase
49
49
  ```csharp
50
50
  [HttpGet]
51
51
  [RequirePermission(Permissions.{module}.View)]
52
- [ProducesResponseType(typeof(PagedResult<{entity}ResponseDto>), StatusCodes.Status200OK)]
52
+ [ProducesResponseType(typeof(PaginatedResult<{entity}ResponseDto>), StatusCodes.Status200OK)]
53
53
  [ProducesResponseType(StatusCodes.Status401Unauthorized)]
54
54
  [ProducesResponseType(StatusCodes.Status403Forbidden)]
55
55
  public async Task<IActionResult> GetAll(
@@ -68,7 +68,7 @@ public async Task<IActionResult> GetAll(
68
68
  .Select(x => new {entity}ResponseDto(x))
69
69
  .ToListAsync(ct);
70
70
 
71
- return Ok(new PagedResult<{entity}ResponseDto>(items, total, page, pageSize));
71
+ return Ok(new PaginatedResult<{entity}ResponseDto>(items, total, page, pageSize));
72
72
  }
73
73
  ```
74
74
 
@@ -53,8 +53,8 @@ public class {Module}Controller : ControllerBase
53
53
 
54
54
  [HttpGet]
55
55
  [RequirePermission(Permissions.{PermissionClass}.View)]
56
- [ProducesResponseType(typeof(PagedResult<{Entity}ListDto>), StatusCodes.Status200OK)]
57
- public async Task<ActionResult<PagedResult<{Entity}ListDto>>> Get{Module}(
56
+ [ProducesResponseType(typeof(PaginatedResult<{Entity}ListDto>), StatusCodes.Status200OK)]
57
+ public async Task<ActionResult<PaginatedResult<{Entity}ListDto>>> Get{Module}(
58
58
  [FromQuery] int page = 1,
59
59
  [FromQuery] int pageSize = 20,
60
60
  [FromQuery] string? search = null,
@@ -89,7 +89,7 @@ public class {Module}Controller : ControllerBase
89
89
  _logger.LogInformation("User {User} retrieved {Count} {Module}",
90
90
  _currentUser.Email, items.Count, "{Module}");
91
91
 
92
- return Ok(new PagedResult<{Entity}ListDto>(items, totalCount, page, pageSize));
92
+ return Ok(new PaginatedResult<{Entity}ListDto>(items, totalCount, page, pageSize));
93
93
  }
94
94
 
95
95
  #endregion
@@ -329,7 +329,7 @@ public record Update{Entity}Request(
329
329
  string? Description
330
330
  );
331
331
 
332
- public record PagedResult<T>(
332
+ public record PaginatedResult<T>(
333
333
  List<T> Items,
334
334
  int TotalCount,
335
335
  int Page,
@@ -337,8 +337,8 @@ public record PagedResult<T>(
337
337
  )
338
338
  {
339
339
  public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
340
- public bool HasPrevious => Page > 1;
341
- public bool HasNext => Page < TotalPages;
340
+ public bool HasPreviousPage => Page > 1;
341
+ public bool HasNextPage => Page < TotalPages;
342
342
  }
343
343
 
344
344
  #endregion
@@ -813,7 +813,7 @@ return NotFound(new { message = "Resource not found" });
813
813
  ```csharp
814
814
  public static class QueryableExtensions
815
815
  {
816
- public static async Task<PagedResult<T>> ToPagedResultAsync<T>(
816
+ public static async Task<PaginatedResult<T>> ToPaginatedResultAsync<T>(
817
817
  this IQueryable<T> query,
818
818
  int page,
819
819
  int pageSize,
@@ -825,7 +825,7 @@ public static class QueryableExtensions
825
825
  .Take(pageSize)
826
826
  .ToListAsync(ct);
827
827
 
828
- return new PagedResult<T>(items, totalCount, page, pageSize);
828
+ return new PaginatedResult<T>(items, totalCount, page, pageSize);
829
829
  }
830
830
  }
831
831
  ```
@@ -1514,7 +1514,7 @@ public class ContactsController : ControllerBase
1514
1514
  {
1515
1515
  [HttpGet]
1516
1516
  [RequirePermission(Permissions.Business.Crm.Contacts.Read)]
1517
- public async Task<ActionResult<PagedResult<ContactDto>>> GetAll(...) { ... }
1517
+ public async Task<ActionResult<PaginatedResult<ContactDto>>> GetAll(...) { ... }
1518
1518
  }
1519
1519
 
1520
1520
  // v2 - Breaking changes only
@@ -1525,7 +1525,7 @@ public class ContactsV2Controller : ControllerBase
1525
1525
  {
1526
1526
  [HttpGet]
1527
1527
  [RequirePermission(Permissions.Business.Crm.Contacts.Read)]
1528
- public async Task<ActionResult<PagedResult<ContactV2Dto>>> GetAll(...) { ... }
1528
+ public async Task<ActionResult<PaginatedResult<ContactV2Dto>>> GetAll(...) { ... }
1529
1529
  }
1530
1530
  ```
1531
1531
 
@@ -1538,11 +1538,11 @@ public class ContactsController : ControllerBase
1538
1538
  {
1539
1539
  [HttpGet]
1540
1540
  [MapToApiVersion("1.0")]
1541
- public async Task<ActionResult<PagedResult<ContactDto>>> GetAllV1(...) { ... }
1541
+ public async Task<ActionResult<PaginatedResult<ContactDto>>> GetAllV1(...) { ... }
1542
1542
 
1543
1543
  [HttpGet]
1544
1544
  [MapToApiVersion("2.0")]
1545
- public async Task<ActionResult<PagedResult<ContactV2Dto>>> GetAllV2(...) { ... }
1545
+ public async Task<ActionResult<PaginatedResult<ContactV2Dto>>> GetAllV2(...) { ... }
1546
1546
  }
1547
1547
  ```
1548
1548
 
@@ -22,7 +22,7 @@ public enum {Entity}Status { Active = 0, Inactive = 1, Archived = 2 }
22
22
 
23
23
  ```csharp
24
24
  // I{Entity}Service.cs
25
- Task<PagedResult<{Entity}Dto>> GetAllAsync(...);
25
+ Task<PaginatedResult<{Entity}Dto>> GetAllAsync(...);
26
26
  Task<{Entity}Dto> CreateAsync(Create{Entity}Request request, CancellationToken ct);
27
27
 
28
28
  // DTOs
@@ -62,8 +62,8 @@ services.AddScoped<I{Entity}Service, {Entity}Service>();
62
62
  public class {Entity}Controller : ControllerBase
63
63
  {
64
64
  [HttpGet][RequirePermission(Permissions.{Area}.{Module}.View)]
65
- [ProducesResponseType(typeof(PagedResult<{Entity}Dto>), 200)]
66
- public async Task<ActionResult<PagedResult<{Entity}Dto>>> GetAll(...);
65
+ [ProducesResponseType(typeof(PaginatedResult<{Entity}Dto>), 200)]
66
+ public async Task<ActionResult<PaginatedResult<{Entity}Dto>>> GetAll(...);
67
67
 
68
68
  [HttpPost][RequirePermission(Permissions.{Area}.{Module}.Create)]
69
69
  [ProducesResponseType(typeof({Entity}Dto), 201)]
@@ -76,7 +76,7 @@ public class {Entity}Controller : ControllerBase
76
76
  ```typescript
77
77
  // {module}Api.ts
78
78
  export const {module}Api = {
79
- getAll: (page, pageSize, search?) => apiClient.get<PagedResult<{Entity}Dto>>('/{area}/{module}', { params }),
79
+ getAll: (page, pageSize, search?) => apiClient.get<PaginatedResult<{Entity}Dto>>('/{area}/{module}', { params }),
80
80
  create: (data) => apiClient.post<{Entity}Dto>('/{area}/{module}', data),
81
81
  };
82
82
 
@@ -205,7 +205,7 @@ public class {Entity}Service : I{Entity}Service
205
205
  _currentUser = currentUser;
206
206
  }
207
207
 
208
- public async Task<PagedResult<{Entity}Response>> GetAllAsync(/* filters */, CancellationToken ct)
208
+ public async Task<PaginatedResult<{Entity}Response>> GetAllAsync(/* filters */, CancellationToken ct)
209
209
  {
210
210
  var tenantId = _currentUser.TenantId;
211
211
  var query = _db.{Entities}
@@ -385,7 +385,7 @@ fi
385
385
  - Edit form: `EntityEditPage.tsx` with route `/{module}/:id/edit`
386
386
  - ALL forms are full pages with their own URL — NEVER Modal/Dialog/Drawer/Popup
387
387
  - Back button with `navigate(-1)` on every form page
388
- - Use React.lazy() + `<Suspense fallback={<PageLoader />}>` for form page imports
388
+ - Use React.lazy() + `<Suspense fallback={<PageLoader />}>` for ALL page imports (not just forms — see "Lazy loading" section above)
389
389
  - **Form tests (MANDATORY):** Co-located `EntityCreatePage.test.tsx` and `EntityEditPage.test.tsx`
390
390
  → Cover: rendering, validation, submit, pre-fill (edit), navigation, error handling
391
391
 
@@ -433,6 +433,45 @@ fi
433
433
  - Error messages MUST use i18n: `setError(t('{mod}:errors.saveFailed'))` — NEVER hardcoded English strings
434
434
  - Toast/notification messages MUST also use i18n
435
435
 
436
+ **Service call pattern (MANDATORY — NO custom entity hooks):**
437
+
438
+ > **ROOT CAUSE (test-v4-014):** ralph-loop generated custom hooks (`useEmployees()`, `useProjects()`,
439
+ > `useTimeManagement()`) that wrapped services with `useState`/`useEffect`/`try-catch`.
440
+ > These hooks swallowed 401 errors with generic `catch → setError(string)`, creating a race condition
441
+ > with SmartStack's auth interceptor (`window.location.href = "/login"`).
442
+ > Result: token cleared + redirect + React re-render → cascade of unauthenticated requests.
443
+
444
+ - Pages MUST call API services **directly** in `useCallback` + `useEffect` — NOT through custom wrapper hooks
445
+ - FORBIDDEN: `useEmployees()`, `useProjects()`, `use{Entity}()` — custom hooks that wrap service calls with useState/useEffect/try-catch
446
+ - Only allowed custom hook: `use{Module}Preferences.ts` (preferences only, step 5)
447
+ - Pattern for pages:
448
+ ```tsx
449
+ // CORRECT — direct service call in page component
450
+ const [data, setData] = useState<EmployeeResponseDto[]>([]);
451
+ const fetchData = useCallback(async () => {
452
+ setLoading(true);
453
+ try {
454
+ const response = await employeeApi.getAll({ pageSize: 200 });
455
+ setData(response.items); // ← extract .items from PaginatedResult
456
+ } catch { setError(t('employees:errors.loadFailed')); }
457
+ finally { setLoading(false); }
458
+ }, []);
459
+ useEffect(() => { fetchData(); }, [fetchData]);
460
+ ```
461
+ - **PaginatedResult<T>**: ALL service `getAll` methods MUST type responses as `PaginatedResult<T>` — extract `.items` in the page
462
+ - FORBIDDEN: `api.get<Employee[]>(url)` — use `api.get<PaginatedResult<EmployeeResponseDto>>(url)` then `.items`
463
+
464
+ **Lazy loading (MANDATORY — ALL pages, not just forms):**
465
+ - ALL page imports in App.tsx MUST use `React.lazy()` + `<Suspense fallback={<PageLoader />}>`
466
+ - FORBIDDEN: `lazy(() => import(...))` without a `<Suspense>` boundary in the rendering tree
467
+ - Pattern for App.tsx:
468
+ ```tsx
469
+ import { lazy, Suspense } from 'react';
470
+ const EmployeesListPage = lazy(() => import('./pages/.../EmployeesListPage'));
471
+ // In routes:
472
+ { path: 'employees', element: <Suspense fallback={<PageLoader />}><EmployeesListPage /></Suspense> }
473
+ ```
474
+
436
475
  **Dependency verification (BLOCKING):**
437
476
  - BEFORE writing any import, verify the package exists in `package.json`
438
477
  - If package not present: run `npm install {package}` BEFORE writing the import
@@ -464,6 +503,9 @@ window.confirm() / confirm() → MUST use ConfirmDialog component w
464
503
  Hardcoded English text in JSX (>Create<) → MUST use t('{mod}:actions.create', 'Create')
465
504
  Hardcoded error strings in hooks → MUST use t('{mod}:errors.loadFailed') in all hooks
466
505
  /business/humanresources/ → MUST use kebab-case: /business/human-resources/
506
+ useEmployees() / use{Entity}() hooks → pages call services DIRECTLY in useCallback (no hook wrapper)
507
+ api.get<Employee[]>(url) → api.get<PaginatedResult<Employee>>(url) then .items
508
+ lazy(() => import(...)) without Suspense → ALL lazy pages MUST have <Suspense fallback={<PageLoader />}>
467
509
  ```
468
510
 
469
511
  ---