@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.
Files changed (55) hide show
  1. package/README.md +98 -6
  2. package/bin/synapse.ts +291 -241
  3. package/package.json +2 -2
  4. package/src/client/components.tsx +459 -1
  5. package/src/client/context.tsx +23 -3
  6. package/src/client/error-boundary.tsx +59 -0
  7. package/src/client/hooks.ts +100 -1
  8. package/src/client/toast.tsx +219 -0
  9. package/src/client/upload.ts +153 -0
  10. package/src/client.ts +37 -2
  11. package/src/compiler/client-bundler.ts +94 -2
  12. package/src/compiler/context-bench.ts +9 -1
  13. package/src/compiler/contract.ts +31 -4
  14. package/src/compiler/db-schema-generator.ts +39 -8
  15. package/src/compiler/fields-parser.ts +28 -1
  16. package/src/compiler/impact-analyzer.ts +31 -1
  17. package/src/compiler/migration-runner.ts +10 -2
  18. package/src/compiler/optional-coverage.ts +137 -0
  19. package/src/compiler/oracle-runner.ts +18 -4
  20. package/src/compiler/scaffolder.ts +227 -9
  21. package/src/compiler/slice-discovery.ts +8 -1
  22. package/src/compiler/slice-splitter.ts +68 -6
  23. package/src/compiler/slice-templates.ts +234 -10
  24. package/src/compiler/typescript-api.ts +33 -0
  25. package/src/core/action-context.ts +101 -15
  26. package/src/core/config.ts +88 -10
  27. package/src/core/cron-parser.ts +167 -0
  28. package/src/core/database-client.ts +103 -6
  29. package/src/core/index.ts +5 -0
  30. package/src/core/jobs.ts +37 -0
  31. package/src/core/oauth-client.ts +322 -0
  32. package/src/core/path-guard.ts +105 -0
  33. package/src/core/postgres-client.ts +37 -0
  34. package/src/core/postgres-queue.ts +76 -9
  35. package/src/core/query-builder.ts +166 -34
  36. package/src/core/session-context.ts +45 -4
  37. package/src/core/session-token.ts +124 -8
  38. package/src/core/sqlite-client.ts +20 -0
  39. package/src/core/storage.ts +60 -19
  40. package/src/core/topics.ts +84 -0
  41. package/src/core/webhook-signature.ts +151 -0
  42. package/src/index.ts +2 -1
  43. package/src/mcp/server.ts +317 -218
  44. package/src/mcp/tools.ts +329 -0
  45. package/src/project-status.ts +433 -0
  46. package/src/runtime/client-entry.ts +11 -5
  47. package/src/runtime/network-guard.ts +3 -1
  48. package/src/runtime/queue-engine.ts +167 -5
  49. package/src/runtime/rate-limiter.ts +14 -4
  50. package/src/runtime/security-headers.ts +35 -0
  51. package/src/runtime/server.ts +1174 -236
  52. package/src/runtime/uploads.ts +199 -19
  53. package/src/version.ts +9 -2
  54. package/templates/starter/package.json +2 -2
  55. 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 { generateFormFieldsCode, generateSqlColumns, generateTypeBoxProperties, parseFields } from './fields-parser';
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 = SlicesDirErrorCode | 'SLICE_EXISTS' | 'WRITE_FAILED';
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
- if (!fs.existsSync(sharedDir)) {
529
- fs.mkdirSync(sharedDir, { recursive: true });
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
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
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 = 'SLICE_NOT_FOUND' | 'SLICE_IMPORTS_SLICE' | 'NO_ROOTS' | 'UNPARSED';
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
- const SERVER_ONLY_PATTERNS: Array<{ label: string; pattern: RegExp }> = [
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
- { label: 'SELECT statement', pattern: /\bSELECT\s+[\w*"`][\w*"`.,\s]*\bFROM\b/i },
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
- { label: 'database call', pattern: /\bdb\s*\.\s*(?:query|findMany|findOne|insert|update|delete|sql|sqlOne)\b/ },
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
- return Ok({ sliceName, artifacts });
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: diagnostics.length === 0 && leaks.length === 0 ? 'PASS' : 'FAIL',
921
+ status: !hasErrors && leaks.length === 0 ? 'PASS' : 'FAIL',
860
922
  diagnostics,
861
923
  leaks
862
924
  };