proteum 2.4.4 → 2.5.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 (73) hide show
  1. package/README.md +60 -55
  2. package/agents/project/AGENTS.md +112 -31
  3. package/agents/project/CODING_STYLE.md +2 -2
  4. package/agents/project/app-root/AGENTS.md +1 -3
  5. package/agents/project/client/AGENTS.md +1 -1
  6. package/agents/project/client/pages/AGENTS.md +21 -9
  7. package/agents/project/diagnostics.md +2 -2
  8. package/agents/project/optimizations.md +1 -1
  9. package/agents/project/root/AGENTS.md +105 -22
  10. package/agents/project/server/routes/AGENTS.md +30 -1
  11. package/agents/project/tests/AGENTS.md +1 -1
  12. package/cli/commands/doctor.ts +54 -3
  13. package/cli/commands/runtime.ts +6 -0
  14. package/cli/commands/worktree.ts +116 -0
  15. package/cli/compiler/artifacts/controllers.ts +16 -15
  16. package/cli/compiler/artifacts/discovery.ts +129 -17
  17. package/cli/compiler/artifacts/routing.ts +0 -5
  18. package/cli/compiler/artifacts/services.ts +253 -76
  19. package/cli/compiler/common/controllers.ts +159 -57
  20. package/cli/compiler/common/generatedRouteModules.ts +457 -363
  21. package/cli/mcp/router.ts +47 -3
  22. package/cli/presentation/commands.ts +25 -15
  23. package/cli/runtime/commands.ts +39 -12
  24. package/cli/runtime/worktreeBootstrap.ts +608 -0
  25. package/cli/scaffold/index.ts +28 -18
  26. package/cli/scaffold/templates.ts +44 -33
  27. package/cli/utils/agents.ts +14 -1
  28. package/client/services/router/index.tsx +23 -3
  29. package/client/services/router/request/api.ts +14 -4
  30. package/common/dev/contractsDoctor.ts +1 -1
  31. package/common/dev/mcpPayloads.ts +8 -1
  32. package/common/env/proteumEnv.ts +14 -2
  33. package/common/router/contracts.ts +1 -1
  34. package/common/router/definitions.ts +177 -0
  35. package/common/router/index.ts +23 -12
  36. package/common/router/pageData.ts +5 -5
  37. package/common/router/register.ts +2 -2
  38. package/common/router/request/api.ts +12 -2
  39. package/docs/agent-routing.md +5 -2
  40. package/docs/diagnostics.md +2 -0
  41. package/docs/mcp.md +6 -3
  42. package/eslint.js +36 -1
  43. package/package.json +1 -1
  44. package/server/app/commands.ts +5 -1
  45. package/server/app/container/console/index.ts +1 -1
  46. package/server/app/controller/index.ts +98 -40
  47. package/server/app/index.ts +92 -1
  48. package/server/app/service/index.ts +5 -1
  49. package/server/index.ts +6 -2
  50. package/server/services/router/index.ts +47 -38
  51. package/server/services/router/response/index.ts +2 -2
  52. package/tests/agents-utils.test.cjs +14 -1
  53. package/tests/cli-mcp-command.test.cjs +84 -0
  54. package/tests/definition-contracts.test.cjs +453 -0
  55. package/tests/dev-transpile-watch.test.cjs +37 -28
  56. package/tests/eslint-rules.test.cjs +39 -1
  57. package/tests/mcp.test.cjs +90 -0
  58. package/tests/worktree-bootstrap.test.cjs +206 -0
  59. package/types/aliases.d.ts +0 -5
  60. package/types/controller-input.test.ts +23 -17
  61. package/types/controller-request-context.test.ts +10 -11
  62. package/cli/commands/migrate.ts +0 -51
  63. package/cli/migrate/pageContract.ts +0 -516
  64. package/docs/migrate-from-2.1.3.md +0 -396
  65. package/scripts/cleanup-generated-controllers.ts +0 -62
  66. package/scripts/fix-reference-app-typing.ts +0 -490
  67. package/scripts/format-router-registrations.ts +0 -119
  68. package/scripts/migrate-explicit-controllers-and-request.ts +0 -423
  69. package/scripts/refactor-client-app-imports.ts +0 -244
  70. package/scripts/refactor-client-pages.ts +0 -587
  71. package/scripts/refactor-server-controllers.ts +0 -471
  72. package/scripts/refactor-server-runtime-aliases.ts +0 -360
  73. package/scripts/restore-client-app-import-files.ts +0 -41
@@ -60,6 +60,7 @@ export type TRouterContext<TRouter extends TServerRouter> =
60
60
  // Request context
61
61
  {
62
62
  app: TServerRouterApplication<TRouter>;
63
+ services: TServerRouterApplication<TRouter>;
63
64
  context: TRouterContext<TRouter>; // = this
64
65
  request: ServerRequest<TRouter>;
65
66
  api: ServerRequest<TRouter>['api'];
@@ -155,14 +156,12 @@ export default class ServerResponse<
155
156
  const contextStore = context.getStore() as
156
157
  | {
157
158
  requestContext?: TRouterContext<TAnyRouter>;
158
- inputSchemaUsed?: boolean;
159
159
  ownerLabel?: string;
160
160
  ownerFilepath?: string;
161
161
  }
162
162
  | undefined;
163
163
  if (contextStore) {
164
164
  contextStore.requestContext = requestContext;
165
- contextStore.inputSchemaUsed = false;
166
165
  contextStore.ownerLabel = getRouteTraceTarget(route as TAnyRoute<TRouterContext<TServerRouter>>);
167
166
  contextStore.ownerFilepath = route.options.filepath || undefined;
168
167
  }
@@ -229,6 +228,7 @@ export default class ServerResponse<
229
228
  const requestContext: TRouterContext<TRouter> = {
230
229
  // Router context
231
230
  app: this.app,
231
+ services: this.app,
232
232
  context: undefined!,
233
233
  request: this.request,
234
234
  response: this,
@@ -104,11 +104,23 @@ test('standalone configure creates tracked instruction files with routing contra
104
104
  assert.match(agentsContent, /\/__proteum\/mcp/);
105
105
  assert.match(agentsContent, /central MCP ready banner/);
106
106
  assert.match(agentsContent, /proteum-mcp-v1/);
107
+ assert.match(agentsContent, /## Explicit App-Building Contract/);
108
+ assert.match(agentsContent, /defineApplication\(\{ services, router, models, commands \}\)/);
109
+ assert.match(agentsContent, /definePageRoute\(\{ path, options, data, render \}\)/);
110
+ assert.match(agentsContent, /defineController\(\{ path, actions \}\)/);
111
+ assert.match(agentsContent, /Never import `@app` in page, route, or controller files/);
112
+ assert.match(agentsContent, /Never call top-level `Router\.page\(\.\.\.\)`/);
107
113
  assert.match(agentsContent, /## Triggered Instruction Reads/);
114
+ assert.match(agentsContent, /Worktree Preflight/);
115
+ assert.match(agentsContent, /npx proteum worktree init --source <source-app-root>/);
116
+ assert.match(agentsContent, /--skip-deps --reason/);
108
117
  assert.match(agentsContent, /Git lifecycle/);
109
118
  assert.match(agentsContent, /read Root contract fallback before any git write/);
119
+ assert.match(agentsContent, /Before git writes after a bug fix, behavior change, decision change, or docs-relevant production change/);
110
120
  assert.match(agentsContent, /add or update focused unit tests/);
111
- assert.match(agentsContent, /read Root contract fallback, `CODING_STYLE\.md`, `tests\/AGENTS\.md`/);
121
+ assert.match(agentsContent, /read Root contract fallback, `DOCUMENTATION\.md`, `CODING_STYLE\.md`, `tests\/AGENTS\.md`/);
122
+ assert.match(agentsContent, /Bug fixes, regressions, incidents, broken public routes, auth\/OAuth failures/);
123
+ assert.match(agentsContent, /docs\/fixes\/YYYY-MM-DD-short-bug-name\.md/);
112
124
  assert.match(agentsContent, /GEO\/SEO\/crawler\/structured-data\/AI-source changes/);
113
125
  assert.match(agentsContent, /MCP-selected previews are enough/);
114
126
  assert.doesNotMatch(agentsContent, /Conventional Commits/);
@@ -234,6 +246,7 @@ test('monorepo configure writes root and app instruction files', () => {
234
246
  assert.match(fs.readFileSync(path.join(monorepoRoot, 'AGENTS.md'), 'utf8'), /## Known Proteum Apps/);
235
247
  assert.match(fs.readFileSync(path.join(monorepoRoot, 'AGENTS.md'), 'utf8'), /apps\/product/);
236
248
  assert.match(fs.readFileSync(path.join(monorepoRoot, 'AGENTS.md'), 'utf8'), /Do not start `npx proteum dev` from this root/);
249
+ assert.match(fs.readFileSync(path.join(monorepoRoot, 'AGENTS.md'), 'utf8'), /Worktree Preflight/);
237
250
  assert.match(fs.readFileSync(path.join(monorepoRoot, 'CODING_STYLE.md'), 'utf8'), /## Source: CODING_STYLE\.md/);
238
251
  assert.match(fs.readFileSync(path.join(monorepoRoot, 'DOCUMENTATION.md'), 'utf8'), /## Source: DOCUMENTATION\.md/);
239
252
  assert.match(fs.readFileSync(path.join(monorepoRoot, 'diagnostics.md'), 'utf8'), /## Source: diagnostics\.md/);
@@ -1,4 +1,5 @@
1
1
  const assert = require('node:assert/strict');
2
+ const crypto = require('node:crypto');
2
3
  const { spawn, spawnSync } = require('node:child_process');
3
4
  const fs = require('node:fs');
4
5
  const http = require('node:http');
@@ -55,6 +56,42 @@ const createProteumApp = (appRoot, { routerPort = 3020 } = {}) => {
55
56
  );
56
57
  };
57
58
 
59
+ const hashFile = (filepath) => crypto.createHash('sha256').update(fs.readFileSync(filepath)).digest('hex');
60
+
61
+ const writeFreshWorktreeBootstrapMarker = (appRoot) => {
62
+ const timestamp = new Date().toISOString();
63
+ fs.mkdirSync(path.join(appRoot, 'node_modules'), { recursive: true });
64
+ writeFile(path.join(appRoot, '.env'), 'PORT=3020\n');
65
+ writeFile(path.join(appRoot, 'package-lock.json'), '{"lockfileVersion":3}\n');
66
+ writeFile(path.join(appRoot, 'AGENTS.md'), '# Agents\n');
67
+
68
+ writeFile(
69
+ path.join(appRoot, '.proteum', 'worktree-bootstrap.json'),
70
+ JSON.stringify(
71
+ {
72
+ version: 1,
73
+ createdAt: timestamp,
74
+ updatedAt: timestamp,
75
+ proteumVersion: require('../package.json').version,
76
+ packageLockHash: hashFile(path.join(appRoot, 'package-lock.json')),
77
+ proteumConfigHash: hashFile(path.join(appRoot, 'proteum.config.ts')),
78
+ agentsHash: hashFile(path.join(appRoot, 'AGENTS.md')),
79
+ env: { present: true, copied: false },
80
+ refresh: { status: 'ok', ranAt: timestamp },
81
+ dependencies: {
82
+ status: 'up-to-date',
83
+ ranAt: timestamp,
84
+ nodeModulesPresent: true,
85
+ packageLockHash: hashFile(path.join(appRoot, 'package-lock.json')),
86
+ },
87
+ runtimeStatus: { status: 'ok', checkedAt: timestamp, summary: 'runtime ok' },
88
+ },
89
+ null,
90
+ 2,
91
+ ),
92
+ );
93
+ };
94
+
58
95
  const listen = async (server, port = 0) =>
59
96
  await new Promise((resolve, reject) => {
60
97
  server.once('error', reject);
@@ -150,6 +187,7 @@ test('top-level help lists the machine-scope mcp router', () => {
150
187
 
151
188
  assert.equal(result.status, 0);
152
189
  assert.match(result.stdout, /proteum mcp\b/);
190
+ assert.match(result.stdout, /proteum worktree\b/);
153
191
  assert.match(result.stdout, /machine-scope MCP router/);
154
192
  });
155
193
 
@@ -287,6 +325,52 @@ test('runtime status manifest guard points to explain manifest', () => {
287
325
  assert.match(payload.nextActions[0].command, /proteum explain --manifest/);
288
326
  });
289
327
 
328
+ test('runtime status blocks unbootstrapped Codex worktrees', () => {
329
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-cli-worktree-block-'));
330
+ const appRoot = path.join(root, '.codex', 'worktrees', 'product');
331
+ createProteumApp(appRoot);
332
+
333
+ const result = spawnSync(process.execPath, [cliBin, 'runtime', 'status'], {
334
+ cwd: appRoot,
335
+ encoding: 'utf8',
336
+ });
337
+ const payload = JSON.parse(result.stdout);
338
+
339
+ assert.equal(result.status, 1);
340
+ assert.equal(payload.ok, false);
341
+ assert.match(payload.summary, /has not completed Proteum worktree bootstrap/);
342
+ assert.match(payload.nextActions[0].command, /proteum worktree init --source <source-app-root>/);
343
+ });
344
+
345
+ test('runtime status allows fresh Codex worktree bootstrap markers and reports stale refresh action', () => {
346
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-cli-worktree-fresh-'));
347
+ const appRoot = path.join(root, '.codex', 'worktrees', 'product');
348
+ createProteumApp(appRoot);
349
+ writeFreshWorktreeBootstrapMarker(appRoot);
350
+
351
+ const fresh = spawnSync(process.execPath, [cliBin, 'runtime', 'status'], {
352
+ cwd: appRoot,
353
+ encoding: 'utf8',
354
+ });
355
+ const freshPayload = JSON.parse(fresh.stdout);
356
+
357
+ assert.equal(fresh.status, 0);
358
+ assert.equal(freshPayload.ok, true);
359
+ assert.equal(freshPayload.data.worktreeBootstrap.state, 'fresh');
360
+
361
+ writeFile(path.join(appRoot, 'package-lock.json'), '{"lockfileVersion":3,"changed":true}\n');
362
+
363
+ const stale = spawnSync(process.execPath, [cliBin, 'runtime', 'status'], {
364
+ cwd: appRoot,
365
+ encoding: 'utf8',
366
+ });
367
+ const stalePayload = JSON.parse(stale.stdout);
368
+
369
+ assert.equal(stale.status, 1);
370
+ assert.equal(stalePayload.ok, false);
371
+ assert.match(stalePayload.nextActions[0].command, /--refresh/);
372
+ });
373
+
290
374
  test('runtime status reports occupied configured port without probing page bodies', async () => {
291
375
  const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-cli-port-'));
292
376
  const otherRoot = path.join(repoRoot, 'apps', 'other');
@@ -0,0 +1,453 @@
1
+ const assert = require('node:assert/strict');
2
+ const fs = require('node:fs');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const { EventEmitter } = require('node:events');
6
+
7
+ const coreRoot = path.join(__dirname, '..');
8
+ require('module-alias').addAliases({
9
+ '@client': path.join(coreRoot, 'client'),
10
+ '@common': path.join(coreRoot, 'common'),
11
+ '@server': path.join(coreRoot, 'server'),
12
+ });
13
+ process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
14
+ process.env.TS_NODE_TRANSPILE_ONLY = '1';
15
+ require('ts-node/register/transpile-only');
16
+
17
+ const {
18
+ indexRouteDefinitions,
19
+ writeGeneratedRouteModule,
20
+ } = require('../cli/compiler/common/generatedRouteModules.ts');
21
+ const { findClientRouteFiles } = require('../cli/compiler/artifacts/discovery.ts');
22
+ const { indexControllers } = require('../cli/compiler/common/controllers.ts');
23
+ const {
24
+ defineAction,
25
+ defineController,
26
+ runControllerAction,
27
+ schema,
28
+ } = require('../server/app/controller/index.ts');
29
+ const Service = require('../server/app/service/index.ts').default;
30
+ const { expressHandler, registerRouteDefinition } = require('../common/router/definitions.ts');
31
+ const { parseProteumEnvConfig } = require('../common/env/proteumEnv.ts');
32
+
33
+ const createTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-definition-contracts-'));
34
+
35
+ const writeFile = (filepath, content) => {
36
+ fs.mkdirSync(path.dirname(filepath), { recursive: true });
37
+ fs.writeFileSync(filepath, content);
38
+ };
39
+
40
+ const loadServiceArtifactsForAppRoot = (appRoot) => {
41
+ const Module = require('node:module');
42
+ const modulePath = require.resolve('../cli/compiler/artifacts/services.ts');
43
+ const originalLoad = Module._load;
44
+ const appStub = { paths: { root: appRoot }, identity: { identifier: 'TestApp' }, containerServices: [] };
45
+ const cliStub = { paths: { core: { root: coreRoot } } };
46
+
47
+ delete require.cache[modulePath];
48
+ Module._load = function patchedLoad(request, parent, isMain) {
49
+ if (parent?.filename === modulePath && request === '../../app') {
50
+ return { __esModule: true, default: appStub };
51
+ }
52
+ if (parent?.filename === modulePath && request === '../..') {
53
+ return { __esModule: true, default: cliStub };
54
+ }
55
+
56
+ return originalLoad.call(this, request, parent, isMain);
57
+ };
58
+
59
+ try {
60
+ return require(modulePath);
61
+ } finally {
62
+ Module._load = originalLoad;
63
+ }
64
+ };
65
+
66
+ const withProteumEnv = (env, run) => {
67
+ const previous = {};
68
+ for (const key of Object.keys(env)) previous[key] = process.env[key];
69
+
70
+ Object.assign(process.env, env);
71
+
72
+ try {
73
+ return run();
74
+ } finally {
75
+ for (const key of Object.keys(env)) {
76
+ if (previous[key] === undefined) delete process.env[key];
77
+ else process.env[key] = previous[key];
78
+ }
79
+ }
80
+ };
81
+
82
+ test('route indexer reads explicit page definitions with static metadata', () => {
83
+ const root = createTempDir();
84
+ const filepath = path.join(root, 'client/pages/index.tsx');
85
+
86
+ writeFile(
87
+ filepath,
88
+ `import { definePageRoute } from '@common/router/definitions';
89
+
90
+ const routePath = '/demo';
91
+
92
+ export default definePageRoute({
93
+ path: routePath,
94
+ options: { auth: false },
95
+ data: null,
96
+ render: () => null,
97
+ });
98
+ `,
99
+ );
100
+
101
+ const [definition] = indexRouteDefinitions({ side: 'client', sourceFilepath: filepath });
102
+
103
+ assert.equal(definition.methodName, 'page');
104
+ assert.equal(definition.path, '/demo');
105
+ assert.equal(definition.targetResolution, 'static-expression');
106
+ assert.equal(definition.hasData, false);
107
+ assert.deepEqual(definition.normalizedOptionKeys, ['auth']);
108
+ });
109
+
110
+ test('router port override updates absolute runtime URLs', () => {
111
+ const root = createTempDir();
112
+
113
+ const config = withProteumEnv(
114
+ {
115
+ ENV_NAME: 'local',
116
+ ENV_PROFILE: 'dev',
117
+ PORT: '3010',
118
+ URL: 'http://localhost:3010',
119
+ URL_INTERNAL: 'http://localhost:3010',
120
+ },
121
+ () =>
122
+ parseProteumEnvConfig({
123
+ appDir: root,
124
+ routerPortOverride: 3100,
125
+ }),
126
+ );
127
+
128
+ assert.equal(config.router.port, 3100);
129
+ assert.equal(config.router.currentDomain, 'http://localhost:3100');
130
+ assert.equal(config.router.internalUrl, 'http://localhost:3100');
131
+ });
132
+
133
+ test('route wrapper imports explicit definitions without lifting source helpers', () => {
134
+ const root = createTempDir();
135
+ const sourceFilepath = path.join(root, 'client/pages/index.tsx');
136
+ const outputFilepath = path.join(root, '.proteum/client/route-modules/client/pages/index.tsx');
137
+
138
+ writeFile(
139
+ sourceFilepath,
140
+ `import { definePageRoute } from '@common/router/definitions';
141
+
142
+ const helper = 'must stay in the source module';
143
+
144
+ export default definePageRoute({
145
+ path: '/wrapped',
146
+ options: {},
147
+ data: null,
148
+ render: () => helper,
149
+ });
150
+ `,
151
+ );
152
+
153
+ writeGeneratedRouteModule({
154
+ outputFilepath,
155
+ runtime: 'client',
156
+ side: 'client',
157
+ sourceFilepath,
158
+ clientRoute: { chunkId: 'client_pages_index' },
159
+ });
160
+
161
+ const generated = fs.readFileSync(outputFilepath, 'utf8');
162
+
163
+ assert.match(generated, /import __routeDefinition/);
164
+ assert.match(generated, /registerRouteDefinition/);
165
+ assert.doesNotMatch(generated, /must stay in the source module/);
166
+ assert.doesNotMatch(generated, /const \{ Router/);
167
+ });
168
+
169
+ test('route discovery includes explicit route definitions', () => {
170
+ const root = createTempDir();
171
+ const routeFilepath = path.join(root, 'client/pages/index.tsx');
172
+ const helperFilepath = path.join(root, 'client/pages/helpers.tsx');
173
+
174
+ writeFile(
175
+ routeFilepath,
176
+ `import { definePageRoute } from '@common/router/definitions';
177
+
178
+ export default definePageRoute({
179
+ path: '/',
180
+ options: {},
181
+ data: null,
182
+ render: () => null,
183
+ });
184
+ `,
185
+ );
186
+ writeFile(
187
+ helperFilepath,
188
+ `export const helper = true;
189
+ `,
190
+ );
191
+
192
+ assert.deepEqual(findClientRouteFiles(path.join(root, 'client/pages')), [routeFilepath]);
193
+ });
194
+
195
+ test('route indexer rejects legacy contextual route imports and top-level router calls', () => {
196
+ const root = createTempDir();
197
+ const filepath = path.join(root, 'server/routes/legacy.ts');
198
+
199
+ writeFile(
200
+ filepath,
201
+ `import { Router } from '@app';
202
+
203
+ Router.get('/legacy', {}, async () => ({ ok: true }));
204
+ `,
205
+ );
206
+
207
+ assert.throws(
208
+ () => indexRouteDefinitions({ side: 'server', sourceFilepath: filepath }),
209
+ /imports @app/,
210
+ );
211
+ });
212
+
213
+ test('route indexer rejects runtime metadata expressions', () => {
214
+ const root = createTempDir();
215
+ const filepath = path.join(root, 'client/pages/dynamic.tsx');
216
+
217
+ writeFile(
218
+ filepath,
219
+ `import { definePageRoute } from '@common/router/definitions';
220
+ import runtimeConfig from '../runtimeConfig';
221
+
222
+ export default definePageRoute({
223
+ path: runtimeConfig.path,
224
+ options: {},
225
+ data: null,
226
+ render: () => null,
227
+ });
228
+ `,
229
+ );
230
+
231
+ assert.throws(
232
+ () => indexRouteDefinitions({ side: 'client', sourceFilepath: filepath }),
233
+ /definePageRoute path must be a serializable static literal or const-only expression/,
234
+ );
235
+ });
236
+
237
+ test('server route factories keep app references inside runtime registration', () => {
238
+ const root = createTempDir();
239
+ const filepath = path.join(root, 'server/routes/api.ts');
240
+
241
+ writeFile(
242
+ filepath,
243
+ `import { defineServerRoute, defineServerRoutes } from '@common/router/definitions';
244
+
245
+ const base = '/api';
246
+
247
+ export default defineServerRoutes(({ Users }) => [
248
+ defineServerRoute({
249
+ method: 'GET',
250
+ path: base + '/users',
251
+ options: {},
252
+ handler: async () => Users.list(),
253
+ }),
254
+ ]);
255
+ `,
256
+ );
257
+
258
+ const [definition] = indexRouteDefinitions({ side: 'server', sourceFilepath: filepath });
259
+
260
+ assert.equal(definition.methodName, 'get');
261
+ assert.equal(definition.path, '/api/users');
262
+ assert.equal(definition.targetResolution, 'static-expression');
263
+ });
264
+
265
+ test('route definitions register through explicit router registrar', () => {
266
+ const calls = [];
267
+
268
+ registerRouteDefinition(
269
+ {
270
+ registerRouteDefinition: (definition, metadata) => {
271
+ calls.push({ definition, metadata });
272
+ },
273
+ },
274
+ { kind: 'server', method: 'GET', path: '/ready', options: {}, handler: () => true },
275
+ { id: 'route-ready' },
276
+ );
277
+
278
+ assert.equal(calls.length, 1);
279
+ assert.equal(calls[0].definition.path, '/ready');
280
+ assert.equal(calls[0].metadata.id, 'route-ready');
281
+ assert.throws(
282
+ () =>
283
+ registerRouteDefinition(
284
+ { get: () => true },
285
+ { kind: 'server', method: 'GET', path: '/ready', options: {}, handler: () => true },
286
+ ),
287
+ /registerRouteDefinition/,
288
+ );
289
+ });
290
+
291
+ test('expressHandler preserves next-based middleware behavior', async () => {
292
+ const response = new EventEmitter();
293
+ let called = false;
294
+
295
+ const handler = expressHandler((request, _response, next, context) => {
296
+ assert.equal(request.url, '/health');
297
+ assert.equal(context.marker, 'ctx');
298
+ called = true;
299
+ next();
300
+ });
301
+
302
+ await handler({
303
+ marker: 'ctx',
304
+ request: {
305
+ req: { url: '/health' },
306
+ res: response,
307
+ },
308
+ });
309
+
310
+ assert.equal(called, true);
311
+ });
312
+
313
+ test('controller definitions parse input through explicit actions', () => {
314
+ const controller = defineController({
315
+ path: 'Example',
316
+ actions: {
317
+ Save: defineAction({
318
+ input: schema.object({ name: schema.string() }),
319
+ handler: ({ input }) => ({ greeting: `Hello ${input.name}` }),
320
+ }),
321
+ },
322
+ });
323
+
324
+ const result = runControllerAction(controller.actions.Save, {
325
+ app: {},
326
+ request: { data: { name: 'Proteum' } },
327
+ });
328
+
329
+ assert.deepEqual(result, { greeting: 'Hello Proteum' });
330
+ });
331
+
332
+ test('service model accessor prefers explicit Models service without recursing through inherited app getter', () => {
333
+ class ModelConsumer extends Service {}
334
+
335
+ const app = Object.create(Service.prototype);
336
+ app.app = app;
337
+ app.Models = { client: { model: true } };
338
+
339
+ const service = new ModelConsumer(app, {}, app);
340
+
341
+ assert.deepEqual(service.models, { model: true });
342
+ });
343
+
344
+ test('controller action context reads Models service without recursing through inherited app getter', () => {
345
+ const app = Object.create(Service.prototype);
346
+ app.app = app;
347
+ app.Models = { client: { model: true } };
348
+
349
+ const controller = defineController({
350
+ path: 'Example',
351
+ actions: {
352
+ ReadModels: defineAction({
353
+ handler: ({ models }) => models,
354
+ }),
355
+ },
356
+ });
357
+
358
+ const result = runControllerAction(controller.actions.ReadModels, {
359
+ app,
360
+ request: { data: {} },
361
+ });
362
+
363
+ assert.deepEqual(result, { model: true });
364
+ });
365
+
366
+ test('controller indexer rejects legacy controller classes', () => {
367
+ const root = createTempDir();
368
+ const controllersRoot = path.join(root, 'server/controllers');
369
+ const filepath = path.join(controllersRoot, 'Legacy.ts');
370
+
371
+ writeFile(
372
+ filepath,
373
+ `import Controller from '@server/app/controller';
374
+
375
+ export default class Legacy extends Controller {
376
+ public Test() {
377
+ return true;
378
+ }
379
+ }
380
+ `,
381
+ );
382
+
383
+ assert.throws(
384
+ () => indexControllers([{ importPrefix: '@/server/controllers/', root: controllersRoot }]),
385
+ /legacy controller class/,
386
+ );
387
+ });
388
+
389
+ test('service artifact parser reads defineApplication router factories', () => {
390
+ const root = createTempDir();
391
+ const filepath = path.join(root, 'server/index.ts');
392
+ const { parseAppBootstrapSource } = loadServiceArtifactsForAppRoot(root);
393
+
394
+ writeFile(
395
+ filepath,
396
+ `import { defineApplication } from '@server/app';
397
+ import Router from '@server/services/router';
398
+ import SchemaRouter from '@server/services/schema/router';
399
+
400
+ export default defineApplication({
401
+ router: (app) => new Router(
402
+ app,
403
+ {
404
+ plugins: {
405
+ schema: new SchemaRouter({}, app),
406
+ },
407
+ },
408
+ app,
409
+ ),
410
+ });
411
+ `,
412
+ );
413
+
414
+ const bootstrap = parseAppBootstrapSource(filepath);
415
+
416
+ assert.deepEqual(bootstrap.rootServices.map((service) => service.registeredName), ['Router']);
417
+ assert.deepEqual(bootstrap.routerPlugins.map((service) => service.registeredName), ['schema']);
418
+ });
419
+
420
+ test('service artifact parser reads named defineApplication router factories', () => {
421
+ const root = createTempDir();
422
+ const filepath = path.join(root, 'server/index.ts');
423
+ const { parseAppBootstrapSource } = loadServiceArtifactsForAppRoot(root);
424
+
425
+ writeFile(
426
+ filepath,
427
+ `import { defineApplication } from '@server/app';
428
+ import Router from '@server/services/router';
429
+ import SchemaRouter from '@server/services/schema/router';
430
+
431
+ const createRouter = (app) => new Router(
432
+ app,
433
+ {
434
+ plugins: {
435
+ schema: new SchemaRouter({}, app),
436
+ },
437
+ },
438
+ app,
439
+ );
440
+
441
+ const App = defineApplication({
442
+ router: createRouter,
443
+ });
444
+
445
+ export default App;
446
+ `,
447
+ );
448
+
449
+ const bootstrap = parseAppBootstrapSource(filepath);
450
+
451
+ assert.deepEqual(bootstrap.rootServices.map((service) => service.registeredName), ['Router']);
452
+ assert.deepEqual(bootstrap.routerPlugins.map((service) => service.registeredName), ['schema']);
453
+ });