@ismaelsoilet/synapsejs 1.1.1 → 1.3.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/bin/synapse.ts CHANGED
@@ -534,7 +534,11 @@ async function main() {
534
534
  }
535
535
 
536
536
  case 'test': {
537
- const report = await runSliceOracles(root);
537
+ const enableGate =
538
+ process.argv.includes('--gate') ||
539
+ fs.existsSync(path.join(root, '.jev.json')) ||
540
+ Boolean(process.env.JEV_ACTIVE || process.env.JEV_PROVIDER || process.env.TYPESAFE_API_KEY);
541
+ const report = await runSliceOracles(root, { enableJevTriage: enableGate });
538
542
 
539
543
  process.stdout.write(`${JSON.stringify({ operation: 'PBT_ORACLE_TEST_SUITE', ...report }, null, 2)}\n`);
540
544
  process.exit(report.status === 'PASS' ? 0 : 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ismaelsoilet/synapsejs",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "Bun-only fullstack framework where a feature is a single contiguous file",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -50,6 +50,7 @@
50
50
  "access": "public"
51
51
  },
52
52
  "dependencies": {
53
+ "@ismaelsoilet/jev-harness": "^0.2.1",
53
54
  "@sinclair/typebox": "^0.34.40",
54
55
  "fast-check": "^3.23.2",
55
56
  "postgres": "^3.4.9",
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import type { Result } from '../core/machine-types';
3
+ import { useSynapseContext } from './context';
3
4
 
4
5
  export interface UseSubscriptionOptions {
5
6
  enabled?: boolean;
@@ -86,17 +87,33 @@ export interface UseActionResult<TPayload, TData, TError> {
86
87
  reset: () => void;
87
88
  }
88
89
 
90
+ export interface UseActionOptions<TData, TError> {
91
+ onSuccess?: (data: TData) => void;
92
+ onError?: (error: TError) => void;
93
+ }
94
+
89
95
  /**
90
96
  * Hook for executing server actions from client UI with reactive state management.
97
+ * If actionFn is not provided, it automatically resolves the slice's action from SynapseContext.
91
98
  */
92
99
  export function useAction<TPayload = unknown, TData = unknown, TError = string>(
93
- actionFn?: (payload: TPayload) => Promise<Result<TData, TError>>
100
+ actionFn?: ((payload: TPayload) => Promise<Result<TData, TError>>) | null,
101
+ options?: UseActionOptions<TData, TError>
94
102
  ): UseActionResult<TPayload, TData, TError> {
95
103
  const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
96
104
  const [data, setData] = useState<TData | null>(null);
97
105
  const [error, setError] = useState<TError | null>(null);
98
106
  const [isSuccess, setIsSuccess] = useState<boolean>(false);
99
107
 
108
+ const ctx = useSynapseContext();
109
+ const resolvedAction =
110
+ actionFn ??
111
+ (ctx.props?.action as ((payload: TPayload) => Promise<Result<TData, TError>>) | undefined) ??
112
+ (ctx.props?.onSubmitAction as ((payload: TPayload) => Promise<Result<TData, TError>>) | undefined);
113
+
114
+ const optionsRef = useRef(options);
115
+ optionsRef.current = options;
116
+
100
117
  const reset = useCallback(() => {
101
118
  setIsSubmitting(false);
102
119
  setData(null);
@@ -106,9 +123,10 @@ export function useAction<TPayload = unknown, TData = unknown, TError = string>(
106
123
 
107
124
  const execute = useCallback(
108
125
  async (payload: TPayload): Promise<Result<TData, TError>> => {
109
- if (!actionFn) {
126
+ if (!resolvedAction) {
110
127
  const errVal = 'NO_ACTION_FUNCTION' as unknown as TError;
111
128
  setError(errVal);
129
+ optionsRef.current?.onError?.(errVal);
112
130
  return { ok: false, error: errVal };
113
131
  }
114
132
 
@@ -117,26 +135,29 @@ export function useAction<TPayload = unknown, TData = unknown, TError = string>(
117
135
  setIsSuccess(false);
118
136
 
119
137
  try {
120
- const result = await actionFn(payload);
138
+ const result = await resolvedAction(payload);
121
139
  if (result.ok) {
122
140
  setData(result.value);
123
141
  setIsSuccess(true);
124
142
  setError(null);
143
+ optionsRef.current?.onSuccess?.(result.value);
125
144
  } else {
126
145
  setError(result.error);
127
146
  setIsSuccess(false);
147
+ optionsRef.current?.onError?.(result.error);
128
148
  }
129
149
  return result;
130
150
  } catch (err: unknown) {
131
151
  const failure = (err instanceof Error ? err.message : String(err)) as unknown as TError;
132
152
  setError(failure);
133
153
  setIsSuccess(false);
154
+ optionsRef.current?.onError?.(failure);
134
155
  return { ok: false, error: failure };
135
156
  } finally {
136
157
  setIsSubmitting(false);
137
158
  }
138
159
  },
139
- [actionFn]
160
+ [resolvedAction]
140
161
  );
141
162
 
142
163
  return {
package/src/client.ts CHANGED
@@ -41,6 +41,7 @@ export {
41
41
  } from './client/context';
42
42
  // Reactive Action & Realtime Subscription Hooks
43
43
  export {
44
+ type UseActionOptions,
44
45
  type UseActionResult,
45
46
  type UseSubscriptionOptions,
46
47
  type UseSubscriptionResult,
@@ -71,13 +71,77 @@ export async function buildVendorBundle(baseDir: string): Promise<Result<BuiltBu
71
71
  `import * as ReactDOM from 'react-dom';`,
72
72
  `import * as ReactDOMClient from 'react-dom/client';`,
73
73
  `import * as SynapseClient from 'synapsejs/client';`,
74
+ `import * as jsxRuntime from 'react/jsx-runtime';`,
75
+ `import * as jsxDevRuntime from 'react/jsx-dev-runtime';`,
74
76
  ``,
75
77
  `export default React;`,
76
78
  `export * from 'react';`,
77
79
  `export * from 'react-dom';`,
78
80
  `export * from 'react-dom/client';`,
79
81
  `export * from 'synapsejs/client';`,
80
- `export { ReactDOM, ReactDOMClient, SynapseClient };`,
82
+ `export * from 'react/jsx-runtime';`,
83
+ `export * from 'react/jsx-dev-runtime';`,
84
+ `const { hydrateRoot, createRoot } = ReactDOMClient;`,
85
+ `const { jsx, jsxs } = (jsxRuntime as unknown as Record<string, unknown>);`,
86
+ `const { jsxDEV } = (jsxDevRuntime as unknown as Record<string, unknown>);`,
87
+ `const {`,
88
+ ` useState,`,
89
+ ` useEffect,`,
90
+ ` useCallback,`,
91
+ ` useMemo,`,
92
+ ` useRef,`,
93
+ ` useContext,`,
94
+ ` useReducer,`,
95
+ ` useId,`,
96
+ ` useLayoutEffect,`,
97
+ ` useTransition,`,
98
+ ` useDeferredValue,`,
99
+ ` createContext,`,
100
+ ` createElement,`,
101
+ ` cloneElement,`,
102
+ ` isValidElement,`,
103
+ ` Fragment,`,
104
+ ` Component,`,
105
+ ` PureComponent,`,
106
+ ` memo,`,
107
+ ` forwardRef,`,
108
+ ` Children,`,
109
+ ` startTransition`,
110
+ `} = (React as unknown as Record<string, unknown>);`,
111
+ `export {`,
112
+ ` ReactDOM,`,
113
+ ` ReactDOMClient,`,
114
+ ` SynapseClient,`,
115
+ ` jsxRuntime,`,
116
+ ` jsxDevRuntime,`,
117
+ ` hydrateRoot,`,
118
+ ` createRoot,`,
119
+ ` jsx,`,
120
+ ` jsxs,`,
121
+ ` jsxDEV,`,
122
+ ` useState,`,
123
+ ` useEffect,`,
124
+ ` useCallback,`,
125
+ ` useMemo,`,
126
+ ` useRef,`,
127
+ ` useContext,`,
128
+ ` useReducer,`,
129
+ ` useId,`,
130
+ ` useLayoutEffect,`,
131
+ ` useTransition,`,
132
+ ` useDeferredValue,`,
133
+ ` createContext,`,
134
+ ` createElement,`,
135
+ ` cloneElement,`,
136
+ ` isValidElement,`,
137
+ ` Fragment,`,
138
+ ` Component,`,
139
+ ` PureComponent,`,
140
+ ` memo,`,
141
+ ` forwardRef,`,
142
+ ` Children,`,
143
+ ` startTransition`,
144
+ `};`,
81
145
  ``
82
146
  ].join('\n');
83
147
 
@@ -194,12 +258,51 @@ export async function buildClientBundle(
194
258
  const entryPath = path.join(entryDir, `${target.domain}-${target.name}.tsx`);
195
259
  const modulePath = path.relative(entryDir, path.join(outDir, clientArtifact.fileName)).split(path.sep).join('/');
196
260
 
261
+ // Detect optional root and domain layouts for isomorphic hydration
262
+ let rootLayoutPath: string | undefined;
263
+ let domainLayoutPath: string | undefined;
264
+
265
+ let currentDir = path.dirname(target.filePath);
266
+ let slicesDir: string | null = null;
267
+ while (currentDir && currentDir !== baseDir && currentDir !== path.dirname(currentDir)) {
268
+ if (path.basename(currentDir) === 'slices') {
269
+ slicesDir = currentDir;
270
+ break;
271
+ }
272
+ currentDir = path.dirname(currentDir);
273
+ }
274
+
275
+ if (slicesDir) {
276
+ const rootCandidates = [path.join(slicesDir, '_layout.tsx'), path.join(slicesDir, 'layout.tsx')];
277
+ for (const cand of rootCandidates) {
278
+ if (fs.existsSync(cand)) {
279
+ const rel = path.relative(entryDir, cand).split(path.sep).join('/');
280
+ rootLayoutPath = rel.startsWith('.') ? rel : `./${rel}`;
281
+ break;
282
+ }
283
+ }
284
+
285
+ const domainDir = path.dirname(target.filePath);
286
+ if (domainDir !== slicesDir) {
287
+ const domainCandidates = [path.join(domainDir, '_layout.tsx'), path.join(domainDir, 'layout.tsx')];
288
+ for (const cand of domainCandidates) {
289
+ if (fs.existsSync(cand)) {
290
+ const rel = path.relative(entryDir, cand).split(path.sep).join('/');
291
+ domainLayoutPath = rel.startsWith('.') ? rel : `./${rel}`;
292
+ break;
293
+ }
294
+ }
295
+ }
296
+ }
297
+
197
298
  fs.writeFileSync(
198
299
  entryPath,
199
300
  clientEntrySource({
200
301
  componentName: componentExport,
201
302
  clientModulePath: `./${modulePath}`,
202
- rpcPath: target.rpcPath
303
+ rpcPath: target.rpcPath,
304
+ rootLayoutPath,
305
+ domainLayoutPath
203
306
  }),
204
307
  'utf-8'
205
308
  );
@@ -224,7 +327,15 @@ export async function buildClientBundle(
224
327
  naming: '[name].js',
225
328
  minify: production,
226
329
  external: vendorSplit
227
- ? ['react', 'react-dom', 'react-dom/client', 'synapsejs/client', '@ismaelsoilet/synapsejs/client']
330
+ ? [
331
+ 'react',
332
+ 'react/jsx-runtime',
333
+ 'react/jsx-dev-runtime',
334
+ 'react-dom',
335
+ 'react-dom/client',
336
+ 'synapsejs/client',
337
+ '@ismaelsoilet/synapsejs/client'
338
+ ]
228
339
  : [],
229
340
  define: { 'process.env.NODE_ENV': production ? '"production"' : '"development"' }
230
341
  });
@@ -6,6 +6,7 @@
6
6
  * Preserves Locality of Behavior (LoB): each slice owns its own table contracts!
7
7
  */
8
8
 
9
+ import { type AbortGateResult, shouldAbortTrajectory } from '@ismaelsoilet/jev-harness';
9
10
  import * as fs from 'fs';
10
11
  import * as path from 'path';
11
12
  import * as ts from 'typescript';
@@ -34,6 +35,7 @@ export interface MigrationReport {
34
35
  code?: SlicesDirErrorCode;
35
36
  message?: string;
36
37
  candidates?: string[];
38
+ abortSignal?: AbortGateResult;
37
39
  }
38
40
 
39
41
  export interface BidirectionalDdl {
@@ -404,13 +406,28 @@ export async function runSliceMigrations(
404
406
  }
405
407
  }
406
408
 
409
+ let abortSignal: AbortGateResult | undefined;
410
+ if (hasFailure) {
411
+ try {
412
+ const failureDetails = migrations
413
+ .filter((m) => m.status === 'FAILED')
414
+ .map((m) => `Slice ${m.slice}: ${m.error}`)
415
+ .join('\n');
416
+ abortSignal = await shouldAbortTrajectory(
417
+ `Apply sliceSchema migrations across discovered slices in ${baseDir}`,
418
+ failureDetails
419
+ );
420
+ } catch {}
421
+ }
422
+
407
423
  return {
408
424
  status: hasFailure ? 'FAIL' : 'PASS',
409
425
  totalDiscovered: migrations.length,
410
426
  appliedCount,
411
427
  skippedCount,
412
428
  statementsApplied: statementsAppliedTotal,
413
- migrations
429
+ migrations,
430
+ abortSignal
414
431
  };
415
432
  } finally {
416
433
  if (isPostgres) {
@@ -9,6 +9,7 @@
9
9
  * Nothing here may report PASS when no invariant was executed.
10
10
  */
11
11
 
12
+ import { type TestTriageResult, triageTestFailure } from '@ismaelsoilet/jev-harness';
12
13
  import * as fs from 'fs';
13
14
  import * as path from 'path';
14
15
  import { findSliceFiles, resolveSlicesDir, type SlicesDirErrorCode } from './slice-discovery';
@@ -42,6 +43,7 @@ export interface OracleReport {
42
43
  code?: OracleErrorCode;
43
44
  message?: string;
44
45
  candidates?: string[];
46
+ triage?: TestTriageResult;
45
47
  }
46
48
 
47
49
  export interface JunitSuite {
@@ -154,6 +156,8 @@ export function oracleWrapperSource(importPath: string, sliceName: string): stri
154
156
  export interface OracleRunOptions {
155
157
  /** Injected by tests: runs a command and returns its exit code and output. */
156
158
  spawn?: (args: string[], cwd: string) => Promise<{ exitCode: number; output: string }>;
159
+ /** Enable Jev System One semantic test triage on failures. Default: true */
160
+ enableJevTriage?: boolean;
157
161
  }
158
162
 
159
163
  async function defaultSpawn(args: string[], cwd: string): Promise<{ exitCode: number; output: string }> {
@@ -241,6 +245,12 @@ export async function runSliceOracles(
241
245
  );
242
246
 
243
247
  if (!fs.existsSync(reportPath)) {
248
+ let triageResult: TestTriageResult | undefined;
249
+ if (options.enableJevTriage !== false) {
250
+ try {
251
+ triageResult = await triageTestFailure(output.trim() || `O runner Bun test falhou com exit ${exitCode}`);
252
+ } catch {}
253
+ }
244
254
  return {
245
255
  status: 'FAIL',
246
256
  totalSlices: wrappers.size,
@@ -249,7 +259,8 @@ export async function runSliceOracles(
249
259
  passedCases: 0,
250
260
  results: [],
251
261
  code: 'RUNNER_FAILED',
252
- message: `O runner não produziu relatório (exit ${exitCode}). ${output.trim().slice(-400)}`.trim()
262
+ message: `O runner não produziu relatório (exit ${exitCode}). ${output.trim().slice(-400)}`.trim(),
263
+ triage: triageResult
253
264
  };
254
265
  }
255
266
 
@@ -268,6 +279,12 @@ export async function runSliceOracles(
268
279
  const passedSlices = results.filter((item) => item.passed).length;
269
280
 
270
281
  if (totalCases === 0) {
282
+ let triageResult: TestTriageResult | undefined;
283
+ if (options.enableJevTriage !== false) {
284
+ try {
285
+ triageResult = await triageTestFailure(`Nenhum invariante foi executado em ${results.length} fatia(s).`);
286
+ } catch {}
287
+ }
271
288
  return {
272
289
  status: 'FAIL',
273
290
  slicesDir: path.relative(root, slicesDir),
@@ -277,17 +294,34 @@ export async function runSliceOracles(
277
294
  passedCases: 0,
278
295
  results,
279
296
  code: 'NO_CASES',
280
- message: `Nenhum invariante foi executado em ${results.length} fatia(s).`
297
+ message: `Nenhum invariante foi executado em ${results.length} fatia(s).`,
298
+ triage: triageResult
281
299
  };
282
300
  }
283
301
 
302
+ const isPass = passedSlices === results.length;
303
+ let triageResult: TestTriageResult | undefined;
304
+ if (!isPass && options.enableJevTriage !== false) {
305
+ try {
306
+ const failedSummaries = results
307
+ .filter((r) => !r.passed)
308
+ .flatMap((r) =>
309
+ r.cases.filter((c) => !c.passed).map((c) => `[${r.slice}] ${c.name}: ${c.message || 'assertion failed'}`)
310
+ )
311
+ .join('\n');
312
+ const failureTrace = failedSummaries || output.trim() || 'Slice oracle invariants failed';
313
+ triageResult = await triageTestFailure(failureTrace);
314
+ } catch {}
315
+ }
316
+
284
317
  return {
285
- status: passedSlices === results.length ? 'PASS' : 'FAIL',
318
+ status: isPass ? 'PASS' : 'FAIL',
286
319
  slicesDir: path.relative(root, slicesDir),
287
320
  totalSlices: results.length,
288
321
  passedSlices,
289
322
  totalCases,
290
323
  passedCases,
291
- results
324
+ results,
325
+ triage: triageResult
292
326
  };
293
327
  }
@@ -61,7 +61,7 @@ import {
61
61
  // ============================================================================
62
62
  export const ${inputSchemaName} = Type.Object({
63
63
  ${tbProps}
64
- });
64
+ }, { additionalProperties: true });
65
65
  export type ${inputTypeName} = Static<typeof ${inputSchemaName}>;
66
66
 
67
67
  // DDL Schema Declarativo da Fatia (Auto-Migrado pelo Synapse)
@@ -211,7 +211,7 @@ export const ${inputSchemaName} = Type.Object({
211
211
  name: Type.String({ minLength: 2, maxLength: 100 }),
212
212
  email: Type.String({ format: 'email' }),
213
213
  metadata: Type.Optional(Type.String())
214
- });
214
+ }, { additionalProperties: true });
215
215
  export type ${inputTypeName} = Static<typeof ${inputSchemaName}>;
216
216
 
217
217
  // DDL Schema Declarativo da Fatia (Auto-Migrado pelo Synapse)
@@ -88,7 +88,7 @@ export const ${names.schemaName} = Type.Object({
88
88
  query: Type.Optional(Type.String({ maxLength: 60 })),
89
89
  limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
90
90
  offset: Type.Optional(Type.Integer({ minimum: 0 }))
91
- });
91
+ }, { additionalProperties: true });
92
92
  export type ${names.inputName} = Static<typeof ${names.schemaName}>;
93
93
 
94
94
  // ============================================================================
@@ -236,7 +236,7 @@ export const ${names.schemaName} = Type.Object({
236
236
  query: Type.Optional(Type.String({ maxLength: 60 })),
237
237
  limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
238
238
  offset: Type.Optional(Type.Integer({ minimum: 0 }))
239
- });
239
+ }, { additionalProperties: true });
240
240
  export type ${names.inputName} = Static<typeof ${names.schemaName}>;
241
241
 
242
242
  // A tabela pertence a outra fatia desta mesma feature.
@@ -450,7 +450,7 @@ function updateTemplate(names: TemplateNames): string {
450
450
  export const ${names.schemaName} = Type.Object({
451
451
  id: Type.String({ minLength: 1 }),
452
452
  name: Type.Optional(Type.String({ minLength: 2, maxLength: 120 }))
453
- });
453
+ }, { additionalProperties: true });
454
454
  export type ${names.inputName} = Static<typeof ${names.schemaName}>;
455
455
 
456
456
  // ============================================================================
@@ -657,7 +657,7 @@ function deleteTemplate(names: TemplateNames): string {
657
657
  // ============================================================================
658
658
  export const ${names.schemaName} = Type.Object({
659
659
  id: Type.String({ minLength: 1 })
660
- });
660
+ }, { additionalProperties: true });
661
661
  export type ${names.inputName} = Static<typeof ${names.schemaName}>;
662
662
 
663
663
  // ============================================================================
@@ -806,7 +806,7 @@ import { storeSession } from 'synapsejs/client';
806
806
  export const ${names.schemaName} = Type.Object({
807
807
  email: Type.String({ minLength: 3, maxLength: 200 }),
808
808
  password: Type.String({ minLength: 8, maxLength: 200 })
809
- });
809
+ }, { additionalProperties: true });
810
810
  export type ${names.inputName} = Static<typeof ${names.schemaName}>;
811
811
 
812
812
  // ============================================================================
@@ -1075,7 +1075,7 @@ import {
1075
1075
  export const ${names.schemaName} = Type.Object({
1076
1076
  code: Type.String({ minLength: 1, maxLength: 500 }),
1077
1077
  state: Type.Optional(Type.String({ maxLength: 500 }))
1078
- });
1078
+ }, { additionalProperties: true });
1079
1079
  export type ${names.inputName} = Static<typeof ${names.schemaName}>;
1080
1080
 
1081
1081
  // ============================================================================
@@ -1353,7 +1353,7 @@ import { signSessionToken } from 'synapsejs';
1353
1353
  export const ${names.schemaName} = Type.Object({
1354
1354
  userId: Type.String({ minLength: 1 }),
1355
1355
  code: Type.String({ minLength: 6, maxLength: 6 })
1356
- });
1356
+ }, { additionalProperties: true });
1357
1357
  export type ${names.inputName} = Static<typeof ${names.schemaName}>;
1358
1358
 
1359
1359
  // ============================================================================
package/src/core/index.ts CHANGED
@@ -12,3 +12,4 @@ export * from './session-context';
12
12
  export * from './session-token';
13
13
  export * from './sqlite-client';
14
14
  export * from './storage';
15
+ export * from './validation';
@@ -0,0 +1,57 @@
1
+ /**
2
+ * SynapseJS - Schema Validation Utilities
3
+ *
4
+ * Provides structured, human-readable validation for TypeBox contracts.
5
+ * Rather than returning an opaque 'INVALID_SCHEMA', it extracts the exact
6
+ * paths and failure reasons to provide actionable machine and developer diagnostics.
7
+ */
8
+
9
+ import type { Static, TSchema } from '@sinclair/typebox';
10
+ import { Value } from '@sinclair/typebox/value';
11
+ import { Err, Ok, type Result } from './machine-types';
12
+
13
+ export interface ValidationErrorDetail {
14
+ path: string;
15
+ message: string;
16
+ value?: unknown;
17
+ }
18
+
19
+ export interface ValidationFailure {
20
+ code: 'INVALID_SCHEMA';
21
+ message: string;
22
+ errors: ValidationErrorDetail[];
23
+ }
24
+
25
+ /**
26
+ * Validates a payload against a TypeBox schema with detailed error reporting.
27
+ * Returns Ok(validatedValue) or Err with formatted diagnosis.
28
+ */
29
+ export function validateSchema<T extends TSchema>(schema: T, payload: unknown): Result<Static<T>, string> {
30
+ if (Value.Check(schema, payload)) {
31
+ return Ok(payload as Static<T>);
32
+ }
33
+
34
+ const errors: ValidationErrorDetail[] = [];
35
+ try {
36
+ for (const err of Value.Errors(schema, payload)) {
37
+ errors.push({
38
+ path: err.path || '/',
39
+ message: err.message,
40
+ value: err.value
41
+ });
42
+ }
43
+ } catch {
44
+ // Se a extração de erros falhar, mantém fallback seguro
45
+ }
46
+
47
+ if (errors.length === 0) {
48
+ return Err('INVALID_SCHEMA: Falha na validação do contrato de entrada.');
49
+ }
50
+
51
+ const formattedDetails = errors
52
+ .slice(0, 3)
53
+ .map((e) => `campo "${e.path}": ${e.message}`)
54
+ .join('; ');
55
+
56
+ return Err(`INVALID_SCHEMA: ${formattedDetails}`);
57
+ }
package/src/mcp/server.ts CHANGED
@@ -6,6 +6,16 @@
6
6
  * directly to AI Agents (Cursor, Claude Code, Windsurf, Antigravity) via JSON-RPC 2.0 over stdio.
7
7
  */
8
8
 
9
+ import {
10
+ type AbortGateResult,
11
+ modulateReasoningEffort,
12
+ type ReasoningEffortResult,
13
+ shouldAbortTrajectory,
14
+ type TestTriageResult,
15
+ triageTestFailure,
16
+ type VerificationResult,
17
+ verifyStepCompletion
18
+ } from '@ismaelsoilet/jev-harness';
9
19
  import * as fs from 'fs';
10
20
  import * as path from 'path';
11
21
  import * as readline from 'readline';
@@ -80,7 +90,7 @@ export class SynapseMcpServer {
80
90
  },
81
91
  serverInfo: {
82
92
  name: 'synapse-mcp',
83
- version: '1.1.0'
93
+ version: '1.2.0'
84
94
  }
85
95
  }
86
96
  };
@@ -201,6 +211,67 @@ export class SynapseMcpServer {
201
211
  steps: { type: 'number', description: 'Number of recent migrations to rollback (default: 1)' }
202
212
  }
203
213
  }
214
+ },
215
+ {
216
+ name: 'synapse_test_gate',
217
+ description:
218
+ 'Execute Fast-Check PBT oracles and immediately triage failures with Jev System One semantic test-gate to prevent LLM token waste',
219
+ inputSchema: {
220
+ type: 'object',
221
+ properties: {}
222
+ }
223
+ },
224
+ {
225
+ name: 'synapse_abort_check',
226
+ description:
227
+ 'Evaluate plan and error history with Jev System One to detect doomed trajectories, circular loops, or high-risk refactor dead ends',
228
+ inputSchema: {
229
+ type: 'object',
230
+ required: ['plan', 'history'],
231
+ properties: {
232
+ plan: { type: 'string', description: 'Proposed implementation plan or architectural change' },
233
+ history: { type: 'string', description: 'Recent attempt history, error logs, or failure reasons' }
234
+ }
235
+ }
236
+ },
237
+ {
238
+ name: 'synapse_verify_completion',
239
+ description:
240
+ 'Verify slice implementation and test outputs against acceptance criteria using Jev System One before committing work',
241
+ inputSchema: {
242
+ type: 'object',
243
+ required: ['criteria', 'output'],
244
+ properties: {
245
+ criteria: { type: 'string', description: 'Acceptance criteria or requirement specifications' },
246
+ output: {
247
+ type: 'string',
248
+ description: 'Actual implementation summary, test output, or slice behavior'
249
+ }
250
+ }
251
+ }
252
+ },
253
+ {
254
+ name: 'synapse_reasoning_effort',
255
+ description:
256
+ 'Dynamically modulate agent reasoning effort (Astra-Jev) to low/medium/high based on step context (lowering effort for mechanical commands like split/migrate/skeleton)',
257
+ inputSchema: {
258
+ type: 'object',
259
+ required: ['context'],
260
+ properties: {
261
+ context: {
262
+ type: 'string',
263
+ description: 'Immediate command or task context, e.g. "synapse split", "git commit"'
264
+ },
265
+ provider: {
266
+ type: 'string',
267
+ description: 'Target LLM provider, e.g. "deepseek", "anthropic", "openai"'
268
+ },
269
+ sessionContextTokens: {
270
+ type: 'number',
271
+ description: 'Estimated session context token count to protect prompt cache'
272
+ }
273
+ }
274
+ }
204
275
  }
205
276
  ]
206
277
  }
@@ -503,6 +574,29 @@ export class SynapseMcpServer {
503
574
  steps: args.steps
504
575
  });
505
576
  contentText = JSON.stringify(report, null, 2);
577
+ } else if (toolName === 'synapse_test_gate') {
578
+ const report = await runSliceOracles(this.root, { enableJevTriage: true });
579
+ contentText = JSON.stringify(report, null, 2);
580
+ } else if (toolName === 'synapse_abort_check') {
581
+ const plan = String(args.plan || '');
582
+ const history = String(args.history || '');
583
+ const result: AbortGateResult = await shouldAbortTrajectory(plan, history);
584
+ contentText = JSON.stringify(result, null, 2);
585
+ } else if (toolName === 'synapse_verify_completion') {
586
+ const criteria = String(args.criteria || '');
587
+ const output = String(args.output || '');
588
+ const result: VerificationResult = await verifyStepCompletion(criteria, output);
589
+ contentText = JSON.stringify(result, null, 2);
590
+ } else if (toolName === 'synapse_reasoning_effort') {
591
+ const context = String(args.context || '');
592
+ const provider = args.provider ? String(args.provider) : undefined;
593
+ const sessionContextTokens =
594
+ typeof args.sessionContextTokens === 'number' ? args.sessionContextTokens : undefined;
595
+ const result: ReasoningEffortResult = await modulateReasoningEffort(context, {
596
+ provider,
597
+ sessionContextTokens
598
+ });
599
+ contentText = JSON.stringify(result, null, 2);
506
600
  } else {
507
601
  return {
508
602
  jsonrpc: '2.0',
@@ -17,6 +17,8 @@ export interface ClientEntryOptions {
17
17
  rpcPath: string;
18
18
  rootId?: string;
19
19
  actionPropNames?: string[];
20
+ rootLayoutPath?: string;
21
+ domainLayoutPath?: string;
20
22
  }
21
23
 
22
24
  export const CLIENT_ROOT_ID = 'synapse-root';
@@ -26,14 +28,25 @@ export function clientEntrySource(options: ClientEntryOptions): string {
26
28
  const rootId = options.rootId ?? CLIENT_ROOT_ID;
27
29
  const propNames = options.actionPropNames ?? ['onSubmitAction', 'action'];
28
30
  const actionWires = propNames
29
- .map((name) => ` ${name}: (payload: unknown) => rpcCall(${JSON.stringify(options.rpcPath)}, payload)`)
31
+ .map((name) => ` ${name}: (payload: unknown) => rpcCall(${JSON.stringify(options.rpcPath)}, payload)`)
30
32
  .join(',\n');
31
33
 
32
- return [
34
+ const layoutImports: string[] = [];
35
+ if (options.rootLayoutPath) {
36
+ layoutImports.push(`import * as RootLayoutModule from ${JSON.stringify(options.rootLayoutPath)};`);
37
+ }
38
+ if (options.domainLayoutPath) {
39
+ layoutImports.push(`import * as DomainLayoutModule from ${JSON.stringify(options.domainLayoutPath)};`);
40
+ }
41
+
42
+ const hasLayouts = Boolean(options.rootLayoutPath || options.domainLayoutPath);
43
+
44
+ const lines = [
33
45
  `// [SYNAPSE-JS GENERATED CLIENT ENTRY] hydrates the slice in the browser`,
34
46
  `import React from 'react';`,
35
47
  `import { hydrateRoot } from 'react-dom/client';`,
36
- `import { rpcCall } from 'synapsejs/client';`,
48
+ `import { rpcCall, SynapseProvider } from 'synapsejs/client';`,
49
+ ...layoutImports,
37
50
  `import { ${options.componentName} } from ${JSON.stringify(options.clientModulePath)};`,
38
51
  ``,
39
52
  `export function hydrate() {`,
@@ -41,19 +54,49 @@ export function clientEntrySource(options: ClientEntryOptions): string {
41
54
  ` const props = (scope[${JSON.stringify(CLIENT_PROPS_GLOBAL)}] ?? {}) as Record<string, unknown>;`,
42
55
  ` const root = document.getElementById(${JSON.stringify(rootId)});`,
43
56
  ` if (root) {`,
44
- ` hydrateRoot(`,
45
- ` root,`,
46
- ` React.createElement(${options.componentName}, {`,
47
- ` ...props,`,
57
+ ` const sliceElement = React.createElement(${options.componentName}, {`,
58
+ ` ...props,`,
48
59
  `${actionWires}`,
49
- ` })`,
50
- ` );`,
51
- ` }`,
52
- `}`,
53
- ``,
54
- `hydrate();`,
55
- ``
56
- ].join('\n');
60
+ ` });`
61
+ ];
62
+
63
+ if (hasLayouts) {
64
+ if (options.domainLayoutPath) {
65
+ lines.push(
66
+ ` const DomainLayout = (DomainLayoutModule as any).default || (DomainLayoutModule as any).Layout || null;`
67
+ );
68
+ }
69
+ if (options.rootLayoutPath) {
70
+ lines.push(
71
+ ` const RootLayout = (RootLayoutModule as any).default || (RootLayoutModule as any).Layout || null;`
72
+ );
73
+ }
74
+ let treeExpr = 'sliceElement';
75
+ if (options.domainLayoutPath) {
76
+ treeExpr = `(typeof DomainLayout === 'function' ? React.createElement(DomainLayout, { ...props }, ${treeExpr}) : ${treeExpr})`;
77
+ }
78
+ if (options.rootLayoutPath) {
79
+ treeExpr = `(typeof RootLayout === 'function' ? React.createElement(RootLayout, { ...props }, ${treeExpr}) : ${treeExpr})`;
80
+ }
81
+ lines.push(` const treeWithLayout = ${treeExpr};`);
82
+ lines.push(` hydrateRoot(`);
83
+ lines.push(` root,`);
84
+ lines.push(` React.createElement(SynapseProvider, { props, session: props.session as any }, treeWithLayout)`);
85
+ lines.push(` );`);
86
+ } else {
87
+ lines.push(` hydrateRoot(`);
88
+ lines.push(` root,`);
89
+ lines.push(` React.createElement(SynapseProvider, { props, session: props.session as any }, sliceElement)`);
90
+ lines.push(` );`);
91
+ }
92
+
93
+ lines.push(` }`);
94
+ lines.push(`}`);
95
+ lines.push(``);
96
+ lines.push(`hydrate();`);
97
+ lines.push(``);
98
+
99
+ return lines.join('\n');
57
100
  }
58
101
 
59
102
  /**
@@ -356,7 +356,8 @@ export class SynapseServer {
356
356
  for (const cand of layoutCandidates) {
357
357
  if (fs.existsSync(cand)) {
358
358
  try {
359
- const layoutMod = await import(cand);
359
+ const candStat = fs.statSync(cand);
360
+ const layoutMod = await import(`${cand}?t=${candStat.mtimeMs}`);
360
361
  this.layoutComponent = layoutMod.default || layoutMod.Layout || null;
361
362
  break;
362
363
  } catch (e) {
@@ -380,7 +381,8 @@ export class SynapseServer {
380
381
  for (const cand of domainCandidates) {
381
382
  if (fs.existsSync(cand)) {
382
383
  try {
383
- const domainMod = await import(cand);
384
+ const candStat = fs.statSync(cand);
385
+ const domainMod = await import(`${cand}?t=${candStat.mtimeMs}`);
384
386
  const domainComp = domainMod.default || domainMod.Layout || null;
385
387
  if (domainComp) {
386
388
  this.domainLayouts.set(domain, domainComp);
@@ -399,7 +401,8 @@ export class SynapseServer {
399
401
  }
400
402
 
401
403
  try {
402
- const mod = await import(file);
404
+ const fileStat = fs.existsSync(file) ? fs.statSync(file) : null;
405
+ const mod = await import(fileStat ? `${file}?t=${fileStat.mtimeMs}` : file);
403
406
  let actionFn: any = null;
404
407
  let componentFn: any = null;
405
408
  let componentExport: string | undefined;
@@ -624,10 +627,12 @@ export class SynapseServer {
624
627
  return null;
625
628
  }
626
629
 
627
- const signature = String(fs.statSync(slice.filePath).mtimeMs);
630
+ const stat = fs.existsSync(slice.filePath) ? fs.statSync(slice.filePath) : null;
631
+ const signature = stat ? String(stat.mtimeMs) : 'missing';
628
632
  const cached = this.clientBundles.get(slice.key);
629
633
  if (cached?.builtFor === signature) {
630
- return cached.url;
634
+ const version = stat ? `?v=${Math.floor(stat.mtimeMs)}` : '';
635
+ return `${cached.url}${version}`;
631
636
  }
632
637
 
633
638
  // `synapse build` já empacotou esta fatia e o arquivo não mudou desde então:
@@ -639,7 +644,8 @@ export class SynapseServer {
639
644
  const prebuilt = freshBundleFrom(this.clientManifest, slice.key, Number(signature));
640
645
  if (prebuilt) {
641
646
  this.clientBundles.set(slice.key, { url: prebuilt, builtFor: signature });
642
- return prebuilt;
647
+ const version = stat ? `?v=${Math.floor(stat.mtimeMs)}` : '';
648
+ return `${prebuilt}${version}`;
643
649
  }
644
650
 
645
651
  const built = await buildClientBundle(
@@ -660,7 +666,8 @@ export class SynapseServer {
660
666
 
661
667
  this.clientBundles.set(slice.key, { url: built.value.url, builtFor: signature });
662
668
 
663
- return built.value.url;
669
+ const version = stat ? `?v=${Math.floor(stat.mtimeMs)}` : '';
670
+ return `${built.value.url}${version}`;
664
671
  }
665
672
 
666
673
  /**
@@ -696,7 +703,8 @@ export class SynapseServer {
696
703
  propsJson: string,
697
704
  clientUrl: string | null,
698
705
  meta?: SliceMetadata,
699
- locale = 'pt-BR'
706
+ locale = 'pt-BR',
707
+ hasLayout = false
700
708
  ): string {
701
709
  const safeTitle = escapeHtml(title);
702
710
  const safeSliceName = escapeHtml(sliceName);
@@ -729,6 +737,44 @@ export class SynapseServer {
729
737
  }
730
738
  const metaSection = metaTags.length > 0 ? `\n${metaTags.join('\n')}` : '';
731
739
 
740
+ if (hasLayout) {
741
+ return `<!DOCTYPE html>
742
+ <html lang="${escapeHtml(locale)}" class="min-h-full">
743
+ <head>
744
+ <meta charset="UTF-8">
745
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
746
+ <title>${pageTitle}</title>${metaSection}
747
+ ${this.renderStylesheets()}
748
+ <script type="importmap">
749
+ {
750
+ "imports": {
751
+ "react": "/_synapse/client/_vendor.js",
752
+ "react/jsx-runtime": "/_synapse/client/_vendor.js",
753
+ "react/jsx-dev-runtime": "/_synapse/client/_vendor.js",
754
+ "react-dom": "/_synapse/client/_vendor.js",
755
+ "react-dom/client": "/_synapse/client/_vendor.js",
756
+ "synapsejs/client": "/_synapse/client/_vendor.js",
757
+ "@ismaelsoilet/synapsejs/client": "/_synapse/client/_vendor.js"
758
+ }
759
+ }
760
+ </script>
761
+ <script type="module" src="/_synapse/client/_vendor.js"></script>
762
+ <style>
763
+ body { font-family: 'Inter', sans-serif; }
764
+ code, pre { font-family: 'JetBrains Mono', monospace; }
765
+ </style>
766
+ </head>
767
+ <body class="min-h-full flex flex-col">
768
+ <div id="synapse-root" class="min-h-full flex-1 flex flex-col">${contentHtml}</div>
769
+
770
+ <script data-synapse-props>globalThis.__SYNAPSE_PROPS__ = ${propsJson};</script>
771
+ ${clientUrl ? `<script data-synapse-client type="module" src="${clientUrl}"></script>` : '<!-- sem componente hidratavel -->'}
772
+
773
+ <script type="module" src="/_synapse/turbo-router.js"></script>
774
+ </body>
775
+ </html>`;
776
+ }
777
+
732
778
  return `<!DOCTYPE html>
733
779
  <html lang="${escapeHtml(locale)}" class="h-full bg-slate-950 text-slate-100">
734
780
  <head>
@@ -740,9 +786,12 @@ ${this.renderStylesheets()}
740
786
  {
741
787
  "imports": {
742
788
  "react": "/_synapse/client/_vendor.js",
789
+ "react/jsx-runtime": "/_synapse/client/_vendor.js",
790
+ "react/jsx-dev-runtime": "/_synapse/client/_vendor.js",
743
791
  "react-dom": "/_synapse/client/_vendor.js",
744
792
  "react-dom/client": "/_synapse/client/_vendor.js",
745
- "synapsejs/client": "/_synapse/client/_vendor.js"
793
+ "synapsejs/client": "/_synapse/client/_vendor.js",
794
+ "@ismaelsoilet/synapsejs/client": "/_synapse/client/_vendor.js"
746
795
  }
747
796
  }
748
797
  </script>
@@ -993,7 +1042,7 @@ ${this.renderStylesheets()}
993
1042
  {
994
1043
  status,
995
1044
  framework: 'SynapseJS',
996
- version: '1.1.0',
1045
+ version: '1.2.0',
997
1046
  uptime: process.uptime(),
998
1047
  database: dbHealthy ? 'connected' : 'disconnected',
999
1048
  ...(dbError ? { databaseError: dbError } : {}),
@@ -1501,6 +1550,7 @@ ${this.renderStylesheets()}
1501
1550
 
1502
1551
  this.logRequest(req, pathname, 200, startedAt, session, 'ssr');
1503
1552
 
1553
+ const hasCustomLayout = Boolean(this.layoutComponent || this.domainLayouts.get(slice.domain));
1504
1554
  const html = this.renderHtmlShell(
1505
1555
  meta?.title || slice.name,
1506
1556
  contentHtml,
@@ -1508,7 +1558,8 @@ ${this.renderStylesheets()}
1508
1558
  serializeClientProps(dataProps),
1509
1559
  clientUrl,
1510
1560
  meta,
1511
- matchedLocale || 'pt-BR'
1561
+ matchedLocale || 'pt-BR',
1562
+ hasCustomLayout
1512
1563
  );
1513
1564
  return new Response(html, {
1514
1565
  headers: { 'Content-Type': 'text/html; charset=utf-8' }
@@ -0,0 +1,6 @@
1
+ {
2
+ "provider": "opencode",
3
+ "model": "typesafe/jev",
4
+ "skip_llm_threshold": 0.65,
5
+ "abort_threshold": 0.70
6
+ }
@@ -12,6 +12,7 @@
12
12
  "mcp": "synapse mcp",
13
13
  "skeleton": "synapse skeleton",
14
14
  "test": "synapse test",
15
+ "test:gate": "synapse test --gate",
15
16
  "new-slice": "synapse new-slice",
16
17
  "split": "synapse split",
17
18
  "build": "synapse build",