proteum 2.5.0 → 2.5.2

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 (40) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +46 -19
  3. package/agents/project/AGENTS.md +9 -7
  4. package/agents/project/CODING_STYLE.md +1 -1
  5. package/agents/project/client/AGENTS.md +5 -1
  6. package/agents/project/diagnostics.md +1 -1
  7. package/agents/project/root/AGENTS.md +9 -7
  8. package/agents/project/server/services/AGENTS.md +4 -0
  9. package/agents/project/tests/AGENTS.md +1 -1
  10. package/cli/commands/verify.ts +117 -4
  11. package/cli/compiler/artifacts/controllerHelper.ts +66 -0
  12. package/cli/compiler/artifacts/controllers.ts +3 -0
  13. package/cli/compiler/artifacts/services.ts +14 -8
  14. package/cli/compiler/common/generatedRouteModules.ts +270 -53
  15. package/cli/presentation/commands.ts +11 -1
  16. package/cli/runtime/commands.ts +6 -0
  17. package/cli/scaffold/templates.ts +14 -6
  18. package/cli/utils/agents.ts +1 -1
  19. package/cli/verification/changed.ts +460 -0
  20. package/client/app/index.ts +22 -5
  21. package/client/services/router/index.tsx +1 -1
  22. package/client/services/router/request/api.ts +2 -2
  23. package/common/applicationConfig.ts +177 -0
  24. package/common/applicationConfigLoader.ts +33 -1
  25. package/common/dev/contractsDoctor.ts +16 -0
  26. package/config.ts +5 -1
  27. package/docs/migration-2.5.md +269 -0
  28. package/eslint.js +96 -50
  29. package/package.json +1 -1
  30. package/server/app/index.ts +28 -2
  31. package/server/services/router/index.ts +3 -3
  32. package/tests/cli-mcp-command.test.cjs +14 -0
  33. package/tests/client-app-error-handling.test.cjs +100 -0
  34. package/tests/contracts-doctor.test.cjs +98 -0
  35. package/tests/definition-contracts.test.cjs +129 -0
  36. package/tests/dev-transpile-watch.test.cjs +3 -6
  37. package/tests/eslint-rules.test.cjs +246 -7
  38. package/tests/scaffold-templates.test.cjs +43 -0
  39. package/tests/server-app-report-error.test.cjs +135 -0
  40. package/tests/verify-changed.test.cjs +200 -0
@@ -0,0 +1,460 @@
1
+ import cp from 'child_process';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ import { type TVerificationCheckScope, type TVerificationConfig, type TVerificationSuiteConfig } from '../../common/applicationConfig';
6
+ import { loadVerificationConfig } from '../../common/applicationConfigLoader';
7
+
8
+ type TChangedFileMode = 'all' | 'base' | 'staged';
9
+
10
+ export type TChangedVerificationCheck = {
11
+ command: string;
12
+ cwd: string;
13
+ id: string;
14
+ matchedFiles: string[];
15
+ reasons: string[];
16
+ scope: TVerificationCheckScope;
17
+ source: 'builtin' | 'config';
18
+ };
19
+
20
+ export type TChangedVerificationSkippedCheck = {
21
+ id: string;
22
+ matchedFiles: string[];
23
+ reason: string;
24
+ };
25
+
26
+ export type TChangedVerificationExecution = {
27
+ checkId: string;
28
+ command: string;
29
+ cwd: string;
30
+ durationMs: number;
31
+ exitCode: number | null;
32
+ signal: NodeJS.Signals | null;
33
+ status: 'failed' | 'passed';
34
+ };
35
+
36
+ export type TChangedVerificationPlan = {
37
+ changedFiles: string[];
38
+ configFilepath?: string;
39
+ configRoot: string;
40
+ docsOnly: boolean;
41
+ gitRoot: string;
42
+ selectedChecks: TChangedVerificationCheck[];
43
+ skippedChecks: TChangedVerificationSkippedCheck[];
44
+ };
45
+
46
+ export type TChangedVerificationResult = TChangedVerificationPlan & {
47
+ dryRun: boolean;
48
+ executions: TChangedVerificationExecution[];
49
+ result: {
50
+ failedChecks: number;
51
+ ok: boolean;
52
+ selectedChecks: number;
53
+ };
54
+ };
55
+
56
+ type TBuildChangedVerificationPlanOptions = {
57
+ changedFiles?: string[];
58
+ configSearchDir?: string;
59
+ cwd: string;
60
+ };
61
+
62
+ type TRunChangedVerificationOptions = TBuildChangedVerificationPlanOptions & {
63
+ base?: string;
64
+ dryRun?: boolean;
65
+ onPlan?: (plan: TChangedVerificationPlan) => void;
66
+ staged?: boolean;
67
+ };
68
+
69
+ const sourceExtensions = new Set(['.cjs', '.cts', '.js', '.jsx', '.mjs', '.mts', '.ts', '.tsx']);
70
+ const docsOnlyExtensions = new Set(['.feature', '.md', '.mdx', '.rst', '.txt']);
71
+ const defaultDocsOnlyReason = 'docs-only changes do not require targeted tests unless a project rule matched.';
72
+
73
+ const dedupe = <TValue>(values: TValue[]) => [...new Set(values)];
74
+ const normalizePath = (value: string) => value.replace(/\\/g, '/').replace(/^\.\//, '');
75
+ const quoteShellValue = (value: string) => `'${value.replace(/'/g, "'\\''")}'`;
76
+ const quoteFileList = (files: string[]) => files.map(quoteShellValue).join(' ');
77
+
78
+ const runGit = (cwd: string, args: string[]) => {
79
+ const result = cp.spawnSync('git', args, {
80
+ cwd,
81
+ encoding: 'utf8',
82
+ });
83
+
84
+ if (result.status !== 0) return [];
85
+
86
+ return result.stdout
87
+ .split(/\r?\n/)
88
+ .map((line) => line.trim())
89
+ .filter(Boolean);
90
+ };
91
+
92
+ export const resolveGitRoot = (cwd: string) => {
93
+ const [root] = runGit(cwd, ['rev-parse', '--show-toplevel']);
94
+ return root ? path.resolve(root) : path.resolve(cwd);
95
+ };
96
+
97
+ const fileExistsInRoot = (root: string, relativeFilepath: string) => fs.existsSync(path.join(root, relativeFilepath));
98
+
99
+ export const discoverChangedFiles = ({
100
+ base,
101
+ cwd,
102
+ staged,
103
+ }: {
104
+ base?: string;
105
+ cwd: string;
106
+ staged?: boolean;
107
+ }) => {
108
+ const gitRoot = resolveGitRoot(cwd);
109
+ const mode: TChangedFileMode = base ? 'base' : staged ? 'staged' : 'all';
110
+ const changedFiles =
111
+ mode === 'base'
112
+ ? runGit(gitRoot, ['diff', '--name-only', '--diff-filter=ACMR', `${base}...HEAD`])
113
+ : mode === 'staged'
114
+ ? runGit(gitRoot, ['diff', '--name-only', '--cached', '--diff-filter=ACMR'])
115
+ : [
116
+ ...runGit(gitRoot, ['diff', '--name-only', '--diff-filter=ACMR']),
117
+ ...runGit(gitRoot, ['diff', '--name-only', '--cached', '--diff-filter=ACMR']),
118
+ ...runGit(gitRoot, ['ls-files', '--others', '--exclude-standard']),
119
+ ];
120
+
121
+ return dedupe(changedFiles.map(normalizePath)).filter((filepath) => fileExistsInRoot(gitRoot, filepath));
122
+ };
123
+
124
+ const isTestFile = (filepath: string) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(filepath);
125
+ const isRelatedSourceFile = (filepath: string) => {
126
+ if (isTestFile(filepath)) return false;
127
+ if (!sourceExtensions.has(path.extname(filepath))) return false;
128
+
129
+ return (
130
+ filepath.startsWith('apps/') ||
131
+ filepath.startsWith('client/') ||
132
+ filepath.startsWith('cli/') ||
133
+ filepath.startsWith('common/') ||
134
+ filepath.startsWith('packages/') ||
135
+ filepath.startsWith('server/') ||
136
+ filepath.includes('/src/')
137
+ );
138
+ };
139
+
140
+ const isDocsOnlyFile = (filepath: string) =>
141
+ filepath === 'AGENTS.md' ||
142
+ filepath === 'README.md' ||
143
+ filepath.startsWith('docs/') ||
144
+ filepath.startsWith('agents/') ||
145
+ docsOnlyExtensions.has(path.extname(filepath));
146
+
147
+ const normalizeGlob = (glob: string) => normalizePath(glob.trim());
148
+
149
+ const escapeRegExp = (value: string) => value.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
150
+
151
+ const globToRegExp = (glob: string) => {
152
+ const normalized = normalizeGlob(glob);
153
+ let pattern = '';
154
+
155
+ for (let index = 0; index < normalized.length; ) {
156
+ if (normalized.slice(index, index + 3) === '**/') {
157
+ pattern += '(?:[^/]+/)*';
158
+ index += 3;
159
+ continue;
160
+ }
161
+
162
+ if (normalized.slice(index, index + 2) === '**') {
163
+ pattern += '.*';
164
+ index += 2;
165
+ continue;
166
+ }
167
+
168
+ const char = normalized[index];
169
+ pattern += char === '*' ? '[^/]*' : escapeRegExp(char);
170
+ index += 1;
171
+ }
172
+
173
+ return new RegExp(`^${pattern}$`);
174
+ };
175
+
176
+ const matchesGlob = (filepath: string, glob: string) => globToRegExp(glob).test(normalizePath(filepath));
177
+ const matchFiles = (files: string[], globs: readonly string[]) =>
178
+ files.filter((filepath) => globs.some((glob) => matchesGlob(filepath, glob)));
179
+
180
+ const getSuiteCommand = (suite: TVerificationSuiteConfig) => (typeof suite === 'string' ? suite : suite.command);
181
+ const getSuiteCwd = (suite: TVerificationSuiteConfig, configRoot: string) => {
182
+ if (typeof suite === 'string' || !suite.cwd) return configRoot;
183
+ return path.isAbsolute(suite.cwd) ? suite.cwd : path.resolve(configRoot, suite.cwd);
184
+ };
185
+ const getSuiteScope = (suite: TVerificationSuiteConfig, fallback: TVerificationCheckScope = 'targeted') =>
186
+ typeof suite === 'string' || !suite.scope ? fallback : suite.scope;
187
+
188
+ const expandCommand = (command: string, files: string[]) => command.replace(/\{files\}/g, quoteFileList(files));
189
+
190
+ const hasFilesPlaceholder = (command: string) => command.includes('{files}');
191
+
192
+ const addCheck = ({
193
+ check,
194
+ selectedChecks,
195
+ }: {
196
+ check: TChangedVerificationCheck;
197
+ selectedChecks: TChangedVerificationCheck[];
198
+ }) => {
199
+ const existing = selectedChecks.find((entry) => entry.command === check.command && entry.cwd === check.cwd);
200
+ if (!existing) {
201
+ selectedChecks.push(check);
202
+ return;
203
+ }
204
+
205
+ existing.matchedFiles = dedupe([...existing.matchedFiles, ...check.matchedFiles]);
206
+ existing.reasons = dedupe([...existing.reasons, ...check.reasons]);
207
+ };
208
+
209
+ const createSuiteCheck = ({
210
+ configRoot,
211
+ files,
212
+ id,
213
+ reason,
214
+ scope,
215
+ source,
216
+ suite,
217
+ }: {
218
+ configRoot: string;
219
+ files: string[];
220
+ id: string;
221
+ reason: string;
222
+ scope?: TVerificationCheckScope;
223
+ source: 'builtin' | 'config';
224
+ suite: TVerificationSuiteConfig;
225
+ }): TChangedVerificationCheck | undefined => {
226
+ const command = getSuiteCommand(suite);
227
+ if (hasFilesPlaceholder(command) && files.length === 0) return undefined;
228
+
229
+ return {
230
+ command: expandCommand(command, files),
231
+ cwd: getSuiteCwd(suite, configRoot),
232
+ id,
233
+ matchedFiles: files,
234
+ reasons: [reason],
235
+ scope: scope || getSuiteScope(suite),
236
+ source,
237
+ };
238
+ };
239
+
240
+ const addConfigSuiteCheck = ({
241
+ config,
242
+ configRoot,
243
+ files,
244
+ id,
245
+ reason,
246
+ run,
247
+ scope,
248
+ selectedChecks,
249
+ skippedChecks,
250
+ }: {
251
+ config: TVerificationConfig;
252
+ configRoot: string;
253
+ files: string[];
254
+ id: string;
255
+ reason: string;
256
+ run: string;
257
+ scope?: TVerificationCheckScope;
258
+ selectedChecks: TChangedVerificationCheck[];
259
+ skippedChecks: TChangedVerificationSkippedCheck[];
260
+ }) => {
261
+ const suite = config.suites?.[run];
262
+ if (!suite) {
263
+ skippedChecks.push({ id: `${id}:${run}`, matchedFiles: files, reason: `Unknown verification suite "${run}".` });
264
+ return;
265
+ }
266
+
267
+ const check = createSuiteCheck({
268
+ configRoot,
269
+ files,
270
+ id: `${id}:${run}`,
271
+ reason,
272
+ scope,
273
+ source: 'config',
274
+ suite,
275
+ });
276
+
277
+ if (!check) {
278
+ skippedChecks.push({ id: `${id}:${run}`, matchedFiles: files, reason: `Suite "${run}" requires matched files.` });
279
+ return;
280
+ }
281
+
282
+ addCheck({ check, selectedChecks });
283
+ };
284
+
285
+ const getDocsOnlyReason = (config: TVerificationConfig) => {
286
+ if (config.docsOnly === false) return undefined;
287
+ if (typeof config.docsOnly === 'object' && config.docsOnly.reason) return config.docsOnly.reason;
288
+ return defaultDocsOnlyReason;
289
+ };
290
+
291
+ export const buildChangedVerificationPlan = ({
292
+ changedFiles,
293
+ configSearchDir,
294
+ cwd,
295
+ }: TBuildChangedVerificationPlanOptions): TChangedVerificationPlan => {
296
+ const gitRoot = resolveGitRoot(cwd);
297
+ const files = dedupe((changedFiles || discoverChangedFiles({ cwd })).map(normalizePath)).filter((filepath) =>
298
+ fileExistsInRoot(gitRoot, filepath),
299
+ );
300
+ const { config, filepath: configFilepath, root: configRoot } = loadVerificationConfig(configSearchDir || cwd);
301
+ const selectedChecks: TChangedVerificationCheck[] = [];
302
+ const skippedChecks: TChangedVerificationSkippedCheck[] = [];
303
+ const docsOnly = files.length > 0 && files.every(isDocsOnlyFile);
304
+
305
+ for (const entry of config.always || []) {
306
+ const suite = config.suites?.[entry] || entry;
307
+ const check = createSuiteCheck({
308
+ configRoot,
309
+ files,
310
+ id: `always:${entry}`,
311
+ reason: 'Configured always-run verification.',
312
+ scope: 'static',
313
+ source: 'config',
314
+ suite,
315
+ });
316
+
317
+ if (check) addCheck({ check, selectedChecks });
318
+ }
319
+
320
+ if (docsOnly) {
321
+ const reason = getDocsOnlyReason(config);
322
+ if (reason) skippedChecks.push({ id: 'builtin:docs-only', matchedFiles: files, reason });
323
+ }
324
+
325
+ const changedTestFiles = files.filter(isTestFile);
326
+ if (changedTestFiles.length > 0) {
327
+ const check = createSuiteCheck({
328
+ configRoot: gitRoot,
329
+ files: changedTestFiles,
330
+ id: 'builtin:changed-tests',
331
+ reason: 'Changed test files should run directly.',
332
+ scope: 'targeted',
333
+ source: 'builtin',
334
+ suite: 'npx vitest run {files}',
335
+ });
336
+ if (check) addCheck({ check, selectedChecks });
337
+ }
338
+
339
+ const changedSourceFiles = docsOnly ? [] : files.filter(isRelatedSourceFile);
340
+ if (changedSourceFiles.length > 0) {
341
+ const check = createSuiteCheck({
342
+ configRoot: gitRoot,
343
+ files: changedSourceFiles,
344
+ id: 'builtin:related-tests',
345
+ reason: 'Changed source files should run related tests.',
346
+ scope: 'targeted',
347
+ source: 'builtin',
348
+ suite: 'npx vitest related {files}',
349
+ });
350
+ if (check) addCheck({ check, selectedChecks });
351
+ }
352
+
353
+ for (const rule of config.rules || []) {
354
+ const matchedFiles = matchFiles(files, rule.match);
355
+ if (matchedFiles.length === 0) continue;
356
+
357
+ for (const run of rule.run) {
358
+ addConfigSuiteCheck({
359
+ config,
360
+ configRoot,
361
+ files: matchedFiles,
362
+ id: rule.id,
363
+ reason: rule.reason,
364
+ run,
365
+ scope: rule.scope,
366
+ selectedChecks,
367
+ skippedChecks,
368
+ });
369
+ }
370
+ }
371
+
372
+ return {
373
+ changedFiles: files,
374
+ ...(configFilepath ? { configFilepath } : {}),
375
+ configRoot,
376
+ docsOnly,
377
+ gitRoot,
378
+ selectedChecks,
379
+ skippedChecks,
380
+ };
381
+ };
382
+
383
+ const runShellCommand = (check: TChangedVerificationCheck) =>
384
+ new Promise<TChangedVerificationExecution>((resolve) => {
385
+ const startedAt = Date.now();
386
+ const child = cp.spawn(check.command, [], {
387
+ cwd: check.cwd,
388
+ shell: true,
389
+ stdio: 'inherit',
390
+ });
391
+
392
+ child.on('error', () => {
393
+ resolve({
394
+ checkId: check.id,
395
+ command: check.command,
396
+ cwd: check.cwd,
397
+ durationMs: Date.now() - startedAt,
398
+ exitCode: 1,
399
+ signal: null,
400
+ status: 'failed',
401
+ });
402
+ });
403
+ child.on('exit', (exitCode, signal) => {
404
+ resolve({
405
+ checkId: check.id,
406
+ command: check.command,
407
+ cwd: check.cwd,
408
+ durationMs: Date.now() - startedAt,
409
+ exitCode,
410
+ signal,
411
+ status: exitCode === 0 && signal === null ? 'passed' : 'failed',
412
+ });
413
+ });
414
+ });
415
+
416
+ export const runChangedVerification = async ({
417
+ base,
418
+ changedFiles,
419
+ configSearchDir,
420
+ cwd,
421
+ dryRun = false,
422
+ onPlan,
423
+ staged = false,
424
+ }: TRunChangedVerificationOptions): Promise<TChangedVerificationResult> => {
425
+ const files = changedFiles || discoverChangedFiles({ base, cwd, staged });
426
+ const plan = buildChangedVerificationPlan({ changedFiles: files, configSearchDir, cwd });
427
+ const executions: TChangedVerificationExecution[] = [];
428
+
429
+ if (!dryRun) {
430
+ onPlan?.(plan);
431
+ for (const check of plan.selectedChecks) executions.push(await runShellCommand(check));
432
+ }
433
+
434
+ const failedChecks = executions.filter((execution) => execution.status === 'failed').length;
435
+
436
+ return {
437
+ ...plan,
438
+ dryRun,
439
+ executions,
440
+ result: {
441
+ failedChecks,
442
+ ok: failedChecks === 0,
443
+ selectedChecks: plan.selectedChecks.length,
444
+ },
445
+ };
446
+ };
447
+
448
+ export const renderChangedVerificationPlan = (plan: TChangedVerificationPlan) =>
449
+ [
450
+ 'Changed Verification Plan',
451
+ `- config=${plan.configFilepath || 'none'}`,
452
+ `- changedFiles=${plan.changedFiles.length}`,
453
+ `- selectedChecks=${plan.selectedChecks.length}`,
454
+ `- skippedChecks=${plan.skippedChecks.length}`,
455
+ ...plan.selectedChecks.map(
456
+ (check) =>
457
+ `- [${check.scope}] ${check.id} cwd=${path.relative(plan.gitRoot, check.cwd) || '.'} command=${check.command} reason=${check.reasons.join('; ')}`,
458
+ ),
459
+ ...plan.skippedChecks.map((check) => `- [skipped] ${check.id} reason=${check.reason}`),
460
+ ].join('\n');
@@ -7,8 +7,6 @@ if (typeof window === 'undefined') throw new Error(`This file shouldn't be loade
7
7
 
8
8
  window.dev && require('preact/debug');
9
9
 
10
- // Core
11
- import { CoreError } from '@common/errors';
12
10
  import type { Layout } from '@common/router';
13
11
  import { createDialog } from '@client/components/Dialog/Manager';
14
12
 
@@ -53,6 +51,21 @@ const isIgnorableBrowserErrorMessage = (message: string) =>
53
51
  message === 'ResizeObserver loop completed with undelivered notifications.' ||
54
52
  message === 'ResizeObserver loop limit exceeded';
55
53
 
54
+ export const getClientErrorMessage = (error: unknown, fallbackMessage = 'Unknown client error'): string => {
55
+ if (error instanceof Error && error.message) return error.message;
56
+ if (typeof error === 'string' && error.trim()) return error;
57
+ if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string' && error.message)
58
+ return error.message;
59
+
60
+ return fallbackMessage;
61
+ };
62
+
63
+ export const normalizeClientError = (error: unknown, fallbackMessage?: string): Error => {
64
+ if (error instanceof Error) return error;
65
+
66
+ return new Error(getClientErrorMessage(error, fallbackMessage));
67
+ };
68
+
56
69
  /*----------------------------------
57
70
  - CLASS
58
71
  ----------------------------------*/
@@ -88,8 +101,7 @@ export default abstract class Application {
88
101
  public bindErrorHandlers() {
89
102
  // Impossible de recup le stacktrace ...
90
103
  window.addEventListener('unhandledrejection', (e) => {
91
- const error = new Error(e.reason); // How to get stacktrace ?
92
- this.handleError(error);
104
+ this.handleError(e.reason);
93
105
  });
94
106
 
95
107
  window.onerror = (message, file, line, col, stacktrace) => {
@@ -109,7 +121,12 @@ export default abstract class Application {
109
121
  };
110
122
  }
111
123
 
112
- public abstract handleError(error: CoreError | Error): void;
124
+ public handleError(error: unknown, fallbackMessage?: any): string | void {
125
+ const normalizedError = normalizeClientError(error, fallbackMessage);
126
+ console.error(normalizedError);
127
+
128
+ return getClientErrorMessage(error, fallbackMessage);
129
+ }
113
130
 
114
131
  public abstract handleUpdate(): void;
115
132
 
@@ -194,7 +194,7 @@ export default class ClientRouter<
194
194
  } = {};
195
195
 
196
196
  public async registerRoutes() {
197
- const loaders = appRoutes as TRoutesLoaders;
197
+ const loaders = appRoutes as unknown as TRoutesLoaders;
198
198
  let currentRoute: TUnresolvedRoute | undefined;
199
199
  debug && console.log(LogPrefix, `Indexing routes and finding the current route from ssr data:`, this.context);
200
200
 
@@ -109,7 +109,7 @@ export default class ApiClient implements ApiClientService {
109
109
  .then((data: TObjetDonnees) => {
110
110
  this.set(data);
111
111
  })
112
- .catch((error: Error) => {
112
+ .catch((error: unknown) => {
113
113
  this.app.handleError(error);
114
114
  });
115
115
  }
@@ -270,7 +270,7 @@ export default class ApiClient implements ApiClientService {
270
270
  );
271
271
 
272
272
  // API Error hook
273
- this.app.handleError(e as Error);
273
+ this.app.handleError(e);
274
274
 
275
275
  throw e;
276
276
  }