@ismaelsoilet/synapsejs 1.8.0 → 1.11.1
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/README.md +98 -6
- package/bin/synapse.ts +291 -241
- package/package.json +2 -2
- package/src/client/components.tsx +459 -1
- package/src/client/context.tsx +23 -3
- package/src/client/error-boundary.tsx +59 -0
- package/src/client/hooks.ts +100 -1
- package/src/client/toast.tsx +219 -0
- package/src/client/upload.ts +153 -0
- package/src/client.ts +37 -2
- package/src/compiler/client-bundler.ts +94 -2
- package/src/compiler/context-bench.ts +9 -1
- package/src/compiler/contract.ts +31 -4
- package/src/compiler/db-schema-generator.ts +39 -8
- package/src/compiler/fields-parser.ts +28 -1
- package/src/compiler/impact-analyzer.ts +31 -1
- package/src/compiler/migration-runner.ts +10 -2
- package/src/compiler/optional-coverage.ts +137 -0
- package/src/compiler/oracle-runner.ts +18 -4
- package/src/compiler/scaffolder.ts +227 -9
- package/src/compiler/slice-discovery.ts +8 -1
- package/src/compiler/slice-splitter.ts +68 -6
- package/src/compiler/slice-templates.ts +234 -10
- package/src/compiler/typescript-api.ts +33 -0
- package/src/core/action-context.ts +101 -15
- package/src/core/config.ts +88 -10
- package/src/core/cron-parser.ts +167 -0
- package/src/core/database-client.ts +103 -6
- package/src/core/index.ts +5 -0
- package/src/core/jobs.ts +37 -0
- package/src/core/oauth-client.ts +322 -0
- package/src/core/path-guard.ts +105 -0
- package/src/core/postgres-client.ts +37 -0
- package/src/core/postgres-queue.ts +76 -9
- package/src/core/query-builder.ts +166 -34
- package/src/core/session-context.ts +45 -4
- package/src/core/session-token.ts +124 -8
- package/src/core/sqlite-client.ts +20 -0
- package/src/core/storage.ts +60 -19
- package/src/core/topics.ts +84 -0
- package/src/core/webhook-signature.ts +151 -0
- package/src/index.ts +2 -1
- package/src/mcp/server.ts +317 -218
- package/src/mcp/tools.ts +329 -0
- package/src/project-status.ts +433 -0
- package/src/runtime/client-entry.ts +11 -5
- package/src/runtime/network-guard.ts +3 -1
- package/src/runtime/queue-engine.ts +167 -5
- package/src/runtime/rate-limiter.ts +14 -4
- package/src/runtime/security-headers.ts +35 -0
- package/src/runtime/server.ts +1174 -236
- package/src/runtime/uploads.ts +199 -19
- package/src/version.ts +9 -2
- package/templates/starter/package.json +2 -2
- package/templates/starter/tsconfig.json +1 -0
|
@@ -8,7 +8,14 @@
|
|
|
8
8
|
import * as fs from 'fs';
|
|
9
9
|
import * as path from 'path';
|
|
10
10
|
import { Err, Ok, type Result } from '../core/machine-types';
|
|
11
|
-
import {
|
|
11
|
+
import { isInsideBase, validatePathSegment } from '../core/path-guard';
|
|
12
|
+
import {
|
|
13
|
+
generateFormFieldsCode,
|
|
14
|
+
generateSqlColumns,
|
|
15
|
+
generateTypeBoxProperties,
|
|
16
|
+
parseFields,
|
|
17
|
+
validateFieldNames
|
|
18
|
+
} from './fields-parser';
|
|
12
19
|
import { resolveSlicesDir, type SlicesDirErrorCode } from './slice-discovery';
|
|
13
20
|
import { generateOperationTemplate, type SliceTemplate } from './slice-templates';
|
|
14
21
|
|
|
@@ -51,10 +58,9 @@ import {
|
|
|
51
58
|
Ok,
|
|
52
59
|
Err,
|
|
53
60
|
MockDatabaseClient,
|
|
54
|
-
type SessionContext
|
|
55
|
-
DataForm,
|
|
56
|
-
Card
|
|
61
|
+
type SessionContext
|
|
57
62
|
} from 'synapsejs';
|
|
63
|
+
import { DataForm, type FormField, Card } from 'synapsejs/client';
|
|
58
64
|
|
|
59
65
|
// ============================================================================
|
|
60
66
|
// 1. CONTRATO DE ENTRADA JIT (TypeBox)
|
|
@@ -121,7 +127,7 @@ export interface ${pascal}TriggerProps {
|
|
|
121
127
|
onSubmitAction?: (payload: unknown) => Promise<${outputTypeName}>;
|
|
122
128
|
}
|
|
123
129
|
|
|
124
|
-
const FORM_FIELDS = ${formFields};
|
|
130
|
+
const FORM_FIELDS: FormField[] = ${formFields};
|
|
125
131
|
|
|
126
132
|
export function ${triggerName}({ onSubmitAction }: ${pascal}TriggerProps) {
|
|
127
133
|
const [feedback, setFeedback] = useState<string | null>(null);
|
|
@@ -415,7 +421,31 @@ export const sliceTests = {
|
|
|
415
421
|
`;
|
|
416
422
|
}
|
|
417
423
|
|
|
418
|
-
export type ScaffoldErrorCode =
|
|
424
|
+
export type ScaffoldErrorCode =
|
|
425
|
+
| SlicesDirErrorCode
|
|
426
|
+
| 'SLICE_EXISTS'
|
|
427
|
+
| 'WRITE_FAILED'
|
|
428
|
+
| 'PARTIAL_WRITE'
|
|
429
|
+
| 'INVALID_PATH_SEGMENT'
|
|
430
|
+
| 'INVALID_FIELD_NAME';
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* One description per scaffolding failure code. The `Record` type makes a
|
|
434
|
+
* missing member a compile error, so the documented set cannot drift from the
|
|
435
|
+
* union the functions return.
|
|
436
|
+
*/
|
|
437
|
+
export const SCAFFOLD_FAILURE_CODES: Record<ScaffoldErrorCode, string> = {
|
|
438
|
+
NO_SLICES_DIR: 'no src/slices directory was found; candidates lists every path examined',
|
|
439
|
+
AMBIGUOUS_SLICES_DIR:
|
|
440
|
+
'more than one application owns a slices directory; set SYNAPSE_ROOT or run inside the target application',
|
|
441
|
+
SLICE_EXISTS: 'the target file already exists',
|
|
442
|
+
WRITE_FAILED: 'the filesystem refused the write',
|
|
443
|
+
PARTIAL_WRITE:
|
|
444
|
+
'a domain scaffolding operation failed and one or more files could not be removed; candidates lists what remains',
|
|
445
|
+
INVALID_PATH_SEGMENT:
|
|
446
|
+
'a domain, slice name, shared-module name or project directory is not a single safe path segment',
|
|
447
|
+
INVALID_FIELD_NAME: 'a field name or enum value in --fields is not a plain identifier'
|
|
448
|
+
};
|
|
419
449
|
|
|
420
450
|
export interface ScaffoldError {
|
|
421
451
|
code: ScaffoldErrorCode;
|
|
@@ -448,6 +478,47 @@ function resolveScaffoldTarget(baseDir: string): Result<string, ScaffoldError> {
|
|
|
448
478
|
return Ok(path.join(path.resolve(baseDir), 'src', 'slices'));
|
|
449
479
|
}
|
|
450
480
|
|
|
481
|
+
function segmentRejection(
|
|
482
|
+
kind: 'domain' | 'name',
|
|
483
|
+
value: string | undefined,
|
|
484
|
+
targetKind: string
|
|
485
|
+
): ScaffoldError | null {
|
|
486
|
+
const validated = validatePathSegment(value);
|
|
487
|
+
|
|
488
|
+
if (validated.ok) {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
code: 'INVALID_PATH_SEGMENT',
|
|
494
|
+
message: `O ${kind === 'domain' ? 'domínio' : 'nome'} informado ('${value ?? ''}') não é um segmento de caminho válido para ${targetKind}. Use apenas letras, números, '-', '_' e '.', sem barras e sem '..'.`,
|
|
495
|
+
candidates: []
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Field names are interpolated into generated DDL and generated TypeScript, so
|
|
501
|
+
* they are validated before a single byte is written: the migration runner is
|
|
502
|
+
* never handed a schema derived from an unchecked name.
|
|
503
|
+
*/
|
|
504
|
+
function fieldRejection(fieldsSpec: string | undefined): ScaffoldError | null {
|
|
505
|
+
if (!fieldsSpec?.trim()) {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const validation = validateFieldNames(parseFields(fieldsSpec));
|
|
510
|
+
|
|
511
|
+
if (validation.ok) {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
code: 'INVALID_FIELD_NAME',
|
|
517
|
+
message: `O nome de campo '${validation.error}' não é um identificador válido. Campos devem casar com [A-Za-z_][A-Za-z0-9_]*.`,
|
|
518
|
+
candidates: []
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
451
522
|
export function scaffoldSlice(
|
|
452
523
|
domain: string,
|
|
453
524
|
sliceName: string,
|
|
@@ -461,9 +532,35 @@ export function scaffoldSlice(
|
|
|
461
532
|
return target;
|
|
462
533
|
}
|
|
463
534
|
|
|
535
|
+
const domainRejection = segmentRejection('domain', domain, 'a fatia');
|
|
536
|
+
|
|
537
|
+
if (domainRejection) {
|
|
538
|
+
return Err(domainRejection);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const nameRejection = segmentRejection('name', sliceName, 'a fatia');
|
|
542
|
+
|
|
543
|
+
if (nameRejection) {
|
|
544
|
+
return Err(nameRejection);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const fieldsRejection = fieldRejection(fieldsSpec);
|
|
548
|
+
|
|
549
|
+
if (fieldsRejection) {
|
|
550
|
+
return Err(fieldsRejection);
|
|
551
|
+
}
|
|
552
|
+
|
|
464
553
|
const targetDir = path.join(target.value, domain);
|
|
465
554
|
const targetFile = path.join(targetDir, `${sliceName}.slice.tsx`);
|
|
466
555
|
|
|
556
|
+
if (!isInsideBase(target.value, targetFile)) {
|
|
557
|
+
return Err({
|
|
558
|
+
code: 'INVALID_PATH_SEGMENT',
|
|
559
|
+
message: `O caminho resolvido para a fatia '${sliceName}' sai do diretório de fatias da aplicação.`,
|
|
560
|
+
candidates: [targetFile]
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
467
564
|
if (fs.existsSync(targetFile)) {
|
|
468
565
|
return Err({
|
|
469
566
|
code: 'SLICE_EXISTS',
|
|
@@ -473,11 +570,12 @@ export function scaffoldSlice(
|
|
|
473
570
|
}
|
|
474
571
|
|
|
475
572
|
try {
|
|
476
|
-
fs.mkdirSync(targetDir, { recursive: true });
|
|
477
573
|
const content =
|
|
478
574
|
template === 'create'
|
|
479
575
|
? generateSliceTemplate(domain, sliceName, fieldsSpec)
|
|
480
576
|
: generateOperationTemplate(domain, sliceName, template, fieldsSpec);
|
|
577
|
+
|
|
578
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
481
579
|
fs.writeFileSync(targetFile, content, 'utf-8');
|
|
482
580
|
} catch (err) {
|
|
483
581
|
return Err({
|
|
@@ -525,11 +623,23 @@ export function scaffoldCrud(
|
|
|
525
623
|
export function scaffoldShared(name: string, baseDir: string = process.cwd()): Result<string, ScaffoldError> {
|
|
526
624
|
const cleanName = name.replace(/\.ts$/, '');
|
|
527
625
|
const sharedDir = path.resolve(baseDir, 'src', 'shared');
|
|
528
|
-
|
|
529
|
-
|
|
626
|
+
|
|
627
|
+
const nameRejection = segmentRejection('name', cleanName, 'o módulo compartilhado');
|
|
628
|
+
|
|
629
|
+
if (nameRejection) {
|
|
630
|
+
return Err(nameRejection);
|
|
530
631
|
}
|
|
531
632
|
|
|
532
633
|
const targetFile = path.join(sharedDir, `${cleanName}.ts`);
|
|
634
|
+
|
|
635
|
+
if (!isInsideBase(sharedDir, targetFile)) {
|
|
636
|
+
return Err({
|
|
637
|
+
code: 'INVALID_PATH_SEGMENT',
|
|
638
|
+
message: `O caminho resolvido para o módulo compartilhado '${cleanName}' sai do diretório src/shared da aplicação.`,
|
|
639
|
+
candidates: [targetFile]
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
533
643
|
if (fs.existsSync(targetFile)) {
|
|
534
644
|
return Err({
|
|
535
645
|
code: 'SLICE_EXISTS',
|
|
@@ -563,6 +673,7 @@ export async function executeShared${pascalName}(
|
|
|
563
673
|
`;
|
|
564
674
|
|
|
565
675
|
try {
|
|
676
|
+
fs.mkdirSync(sharedDir, { recursive: true });
|
|
566
677
|
fs.writeFileSync(targetFile, content, 'utf-8');
|
|
567
678
|
return Ok(targetFile);
|
|
568
679
|
} catch (err: unknown) {
|
|
@@ -575,6 +686,113 @@ export async function executeShared${pascalName}(
|
|
|
575
686
|
}
|
|
576
687
|
}
|
|
577
688
|
|
|
689
|
+
/**
|
|
690
|
+
* Scaffolds an entire modular domain directory with domain layout (_layout.tsx),
|
|
691
|
+
* an initial listing slice (listar-<domain>.slice.tsx), and shared module (src/shared/<domain>.ts).
|
|
692
|
+
*/
|
|
693
|
+
export function scaffoldDomain(
|
|
694
|
+
domain: string,
|
|
695
|
+
baseDir: string = process.cwd(),
|
|
696
|
+
fieldsSpec?: string
|
|
697
|
+
): Result<string[], ScaffoldError> {
|
|
698
|
+
const domainRejection = segmentRejection('domain', domain, 'o domínio');
|
|
699
|
+
if (domainRejection) return Err(domainRejection);
|
|
700
|
+
|
|
701
|
+
const target = resolveScaffoldTarget(baseDir);
|
|
702
|
+
if (!target.ok) return Err(target.error);
|
|
703
|
+
|
|
704
|
+
const domainDir = path.join(target.value, domain);
|
|
705
|
+
const layoutFile = path.join(domainDir, '_layout.tsx');
|
|
706
|
+
const domainDirExisted = fs.existsSync(domainDir);
|
|
707
|
+
const createdFiles: string[] = [];
|
|
708
|
+
|
|
709
|
+
const fail = (error: ScaffoldError): Result<string[], ScaffoldError> => {
|
|
710
|
+
const cleanupFailures: string[] = [];
|
|
711
|
+
|
|
712
|
+
for (const file of [...createdFiles].reverse()) {
|
|
713
|
+
try {
|
|
714
|
+
fs.rmSync(file, { force: true });
|
|
715
|
+
} catch {
|
|
716
|
+
cleanupFailures.push(file);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (!domainDirExisted) {
|
|
721
|
+
try {
|
|
722
|
+
fs.rmdirSync(domainDir);
|
|
723
|
+
} catch {
|
|
724
|
+
if (fs.existsSync(domainDir)) {
|
|
725
|
+
cleanupFailures.push(domainDir);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (cleanupFailures.length > 0) {
|
|
731
|
+
return Err({
|
|
732
|
+
code: 'PARTIAL_WRITE',
|
|
733
|
+
message: `${error.code}: ${error.message}; não foi possível remover: ${cleanupFailures.join(', ')}`,
|
|
734
|
+
candidates: cleanupFailures
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
return Err(error);
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
try {
|
|
742
|
+
fs.mkdirSync(domainDir, { recursive: true });
|
|
743
|
+
|
|
744
|
+
// 1. Domain layout if not exists
|
|
745
|
+
if (!fs.existsSync(layoutFile)) {
|
|
746
|
+
const pascalDomain = toPascalCase(domain);
|
|
747
|
+
const layoutContent = `import React, { type ReactNode } from 'react';
|
|
748
|
+
|
|
749
|
+
export default function ${pascalDomain}DomainLayout({ children }: { children: ReactNode; url?: string; session?: any }) {
|
|
750
|
+
return (
|
|
751
|
+
<div className="flex flex-col flex-1 w-full min-h-full">
|
|
752
|
+
<header className="border-b border-slate-800 bg-slate-900/60 backdrop-blur px-6 py-4 flex items-center justify-between">
|
|
753
|
+
<div>
|
|
754
|
+
<div className="text-xs uppercase tracking-wider text-cyan-400 font-semibold">Domínio</div>
|
|
755
|
+
<h2 className="text-xl font-bold text-slate-100 capitalize">${domain}</h2>
|
|
756
|
+
</div>
|
|
757
|
+
<nav className="flex gap-4 text-sm font-medium">
|
|
758
|
+
<a href="/${domain}/listar-${domain}" className="text-slate-300 hover:text-cyan-400 transition-colors">Listagem</a>
|
|
759
|
+
</nav>
|
|
760
|
+
</header>
|
|
761
|
+
<main className="flex-1 p-6 max-w-7xl w-full mx-auto">
|
|
762
|
+
{children}
|
|
763
|
+
</main>
|
|
764
|
+
</div>
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
`;
|
|
768
|
+
fs.writeFileSync(layoutFile, layoutContent, 'utf-8');
|
|
769
|
+
createdFiles.push(layoutFile);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// 2. Initial List Slice
|
|
773
|
+
const listSliceRes = scaffoldSlice(domain, `listar-${domain}`, baseDir, 'list', fieldsSpec);
|
|
774
|
+
if (!listSliceRes.ok) {
|
|
775
|
+
return fail(listSliceRes.error);
|
|
776
|
+
}
|
|
777
|
+
createdFiles.push(listSliceRes.value);
|
|
778
|
+
|
|
779
|
+
// 3. Shared module
|
|
780
|
+
const sharedRes = scaffoldShared(domain, baseDir);
|
|
781
|
+
if (!sharedRes.ok) {
|
|
782
|
+
return fail(sharedRes.error);
|
|
783
|
+
}
|
|
784
|
+
createdFiles.push(sharedRes.value);
|
|
785
|
+
} catch (err) {
|
|
786
|
+
return fail({
|
|
787
|
+
code: 'WRITE_FAILED',
|
|
788
|
+
message: err instanceof Error ? err.message : String(err),
|
|
789
|
+
candidates: [domainDir]
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
return Ok(createdFiles);
|
|
794
|
+
}
|
|
795
|
+
|
|
578
796
|
if (import.meta.main) {
|
|
579
797
|
const domain = process.argv[2] || 'core';
|
|
580
798
|
const name = process.argv[3] || 'example-feature';
|
|
@@ -38,7 +38,14 @@ export function findSliceFiles(dir: string): string[] {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
const results: string[] = [];
|
|
41
|
-
|
|
41
|
+
// `readdirSync` order is filesystem-dependent: on a fresh checkout it can differ from
|
|
42
|
+
// the developer's machine, which made generated artifacts (repo map, schema catalog,
|
|
43
|
+
// split order) drift between the two. Sorting makes every generated file reproducible.
|
|
44
|
+
const entries = fs
|
|
45
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
46
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
47
|
+
|
|
48
|
+
for (const entry of entries) {
|
|
42
49
|
const full = path.join(dir, entry.name);
|
|
43
50
|
if (entry.isDirectory()) {
|
|
44
51
|
results.push(...findSliceFiles(full));
|
|
@@ -40,9 +40,15 @@ export interface SplitArtifact {
|
|
|
40
40
|
export interface SplitResult {
|
|
41
41
|
sliceName: string;
|
|
42
42
|
artifacts: SplitArtifact[];
|
|
43
|
+
lineCount?: number;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
export type SplitErrorCode =
|
|
46
|
+
export type SplitErrorCode =
|
|
47
|
+
| 'SLICE_NOT_FOUND'
|
|
48
|
+
| 'SLICE_IMPORTS_SLICE'
|
|
49
|
+
| 'NO_ROOTS'
|
|
50
|
+
| 'UNPARSED'
|
|
51
|
+
| 'SERVER_VALUE_IN_CLIENT';
|
|
46
52
|
|
|
47
53
|
export interface SplitError {
|
|
48
54
|
code: SplitErrorCode;
|
|
@@ -67,17 +73,25 @@ export interface SplitVerification {
|
|
|
67
73
|
const CLIENT_ROOTS = [...COMPONENT_SUFFIXES];
|
|
68
74
|
const TEST_ONLY_NAMES: readonly string[] = [...TEST_ONLY_EXPORTS];
|
|
69
75
|
|
|
70
|
-
|
|
76
|
+
/** The expressions that must never survive into a client bundle. Exported so the
|
|
77
|
+
* linear-time guarantee can be asserted directly against each of them. */
|
|
78
|
+
export const SERVER_ONLY_PATTERNS: Array<{ label: string; pattern: RegExp }> = [
|
|
71
79
|
// `SELECT ... FROM` e não `SELECT` seguido de qualquer palavra: sem o FROM, a
|
|
72
80
|
// tag HTML `<select id="x">` de um formulário casava como vazamento de SQL.
|
|
73
|
-
|
|
81
|
+
// O quantificador é limitado (uma janela fixa, não `*`): um bundle grande com
|
|
82
|
+
// muitos `SELECT` sem `FROM` fazia o motor retroceder sobre o texto inteiro.
|
|
83
|
+
{ label: 'SELECT statement', pattern: /\bSELECT\s[\w*"`.,\s]{0,4096}?\bFROM\b/i },
|
|
74
84
|
{ label: 'INSERT statement', pattern: /\bINSERT\s+INTO\b/i },
|
|
75
85
|
{ label: 'UPDATE statement', pattern: /\bUPDATE\s+\w+\s+SET\b/i },
|
|
76
86
|
{ label: 'DELETE statement', pattern: /\bDELETE\s+FROM\b/i },
|
|
77
87
|
{ label: 'CREATE TABLE statement', pattern: /\bCREATE\s+TABLE\b/i },
|
|
78
88
|
{ label: 'ALTER TABLE statement', pattern: /\bALTER\s+TABLE\b/i },
|
|
79
89
|
{ label: 'DROP TABLE statement', pattern: /\bDROP\s+TABLE\b/i },
|
|
80
|
-
{
|
|
90
|
+
{
|
|
91
|
+
label: 'database call',
|
|
92
|
+
pattern:
|
|
93
|
+
/\bdb\s*\.\s*(?:query|queryOne|findMany|findOne|insert|update|delete|sql|sqlOne|aggregate|count|exists|transaction)\b/
|
|
94
|
+
},
|
|
81
95
|
{ label: 'sliceSchema symbol', pattern: /\bsliceSchema\b/ },
|
|
82
96
|
{ label: 'test runner import', pattern: /from\s+['"]bun:test['"]/ },
|
|
83
97
|
{ label: 'process.env access', pattern: /\bprocess\s*(?:\.\s*env|\[\s*['"]env['"]\s*\])/ },
|
|
@@ -507,6 +521,20 @@ function unwrapPromiseType(typeText: string): string {
|
|
|
507
521
|
return match ? match[1].trim() : `Awaited<${typeText}>`;
|
|
508
522
|
}
|
|
509
523
|
|
|
524
|
+
/**
|
|
525
|
+
* A wire stub describes a server *function*. A server-owned value that a component
|
|
526
|
+
* references has no wire form, so the caller must fail with a diagnostic instead of
|
|
527
|
+
* emitting a stub that would call the endpoint as if it were an action.
|
|
528
|
+
*/
|
|
529
|
+
function isStubCandidate(entry: DeclEntry): boolean {
|
|
530
|
+
return (
|
|
531
|
+
ts.isFunctionDeclaration(entry.node) ||
|
|
532
|
+
ts.isFunctionExpression(entry.node) ||
|
|
533
|
+
ts.isArrowFunction(entry.node) ||
|
|
534
|
+
ts.isMethodDeclaration(entry.node)
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
510
538
|
function renderRpcStub(entry: DeclEntry, endpoint: string, sourceFile: ts.SourceFile): string {
|
|
511
539
|
const fn = entry.node as ts.FunctionDeclaration;
|
|
512
540
|
const payload = fn.parameters[0];
|
|
@@ -712,6 +740,27 @@ export function splitSlice(sliceFilePath: string, baseDir: string = process.cwd(
|
|
|
712
740
|
const sliceName = path.basename(sliceFilePath, '.slice.tsx');
|
|
713
741
|
const relativePath = path.relative(baseDir, sliceFilePath);
|
|
714
742
|
const endpoint = `/_synapse/rpc/${sliceName}`;
|
|
743
|
+
|
|
744
|
+
const serverValueInClient = stubbedKeys.find((key) => {
|
|
745
|
+
const entry = entries.get(key);
|
|
746
|
+
|
|
747
|
+
return entry ? !isStubCandidate(entry) : false;
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
if (serverValueInClient) {
|
|
751
|
+
const entry = entries.get(serverValueInClient) as DeclEntry;
|
|
752
|
+
|
|
753
|
+
return Err({
|
|
754
|
+
code: 'SERVER_VALUE_IN_CLIENT',
|
|
755
|
+
message:
|
|
756
|
+
`O componente de ${path.basename(sliceFilePath)} referencia '${serverValueInClient}', ` +
|
|
757
|
+
`que é um valor do servidor (${ts.SyntaxKind[entry.node.kind]}) e não uma action: não existe ` +
|
|
758
|
+
`forma de transportá-lo pelo endpoint RPC. Mova o valor para um módulo em src/shared/, ` +
|
|
759
|
+
`importe-o de 'synapsejs/client', ou limite-se a declará-lo no servidor.`,
|
|
760
|
+
candidates: [serverValueInClient]
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
715
764
|
const artifacts: SplitArtifact[] = [];
|
|
716
765
|
const outDir = artifactDirectory(baseDir, sliceName);
|
|
717
766
|
const sourceDir = path.dirname(sliceFilePath);
|
|
@@ -774,7 +823,8 @@ export function splitSlice(sliceFilePath: string, baseDir: string = process.cwd(
|
|
|
774
823
|
)
|
|
775
824
|
});
|
|
776
825
|
|
|
777
|
-
|
|
826
|
+
const lineCount = sourceFile.text.split('\n').length;
|
|
827
|
+
return Ok({ sliceName, artifacts, lineCount });
|
|
778
828
|
}
|
|
779
829
|
|
|
780
830
|
export function writeSplitArtifacts(result: SplitResult, outDir: string): string[] {
|
|
@@ -855,8 +905,20 @@ export function verifySplit(result: SplitResult, outDir: string): SplitVerificat
|
|
|
855
905
|
}
|
|
856
906
|
}
|
|
857
907
|
|
|
908
|
+
if (result.lineCount && result.lineCount > 500) {
|
|
909
|
+
diagnostics.push({
|
|
910
|
+
file: `${result.sliceName}.slice.tsx`,
|
|
911
|
+
line: 1,
|
|
912
|
+
column: 1,
|
|
913
|
+
code: 'WARN_LARGE_SLICE',
|
|
914
|
+
message: `Fatia excede 500 linhas (${result.lineCount} linhas). Para manter alta densidade sem sobrecarregar agentes de IA e humanos, considere decompor este domínio em sub-fatias de intenção de negócio com <SubSlice> e src/shared/ (consulte docs/arquitetura-sistemas-grandes.pt-BR.md).`
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const hasErrors = diagnostics.some((d) => !d.code.startsWith('WARN_'));
|
|
919
|
+
|
|
858
920
|
return {
|
|
859
|
-
status:
|
|
921
|
+
status: !hasErrors && leaks.length === 0 ? 'PASS' : 'FAIL',
|
|
860
922
|
diagnostics,
|
|
861
923
|
leaks
|
|
862
924
|
};
|