@fictjs/compiler 0.8.0 → 0.9.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/README.md CHANGED
@@ -39,7 +39,17 @@ createFictPlugin({
39
39
 
40
40
  - `dev` (default: `NODE_ENV !== 'production' && NODE_ENV !== 'test'`): enables compiler warnings/diagnostics. Set to `false` to silence warnings.
41
41
  - `onWarn`: custom warning handler (only called when `dev` is enabled).
42
+ - `fineGrainedDom` (default: `true`): emits template-first fine-grained DOM operations for supported JSX.
43
+ - `lazyConditional` (default: `true`): enables control-flow lazy lowering for reactive branch returns where supported.
44
+ - `getterCache` (default: `true`): caches repeated getter reads within the same synchronous block.
45
+ - `optimize` (default: `true`): enables optimizer passes.
46
+ - `optimizeLevel` (default: `'safe'`): conservative algebraic optimization level.
42
47
  - `inlineDerivedMemos` (default: `true`): allow the compiler to inline single-use derived values. Set to `false` for a “strict memo” mode where user-named derived values keep explicit memo accessors (unless `"use no memo"` disables memoization).
48
+ - `strictReactivity` (default: `false`): treat control-flow fallback diagnostics (`FICT-R003`, `FICT-R006`) as build errors. Useful for CI gates that require deterministic fine-grained reactivity without fallback paths.
49
+ - `strictGuarantee` (default: `true`): fail-closed mode for reactivity guarantees. Non-guaranteed reactivity diagnostics (including control-flow fallback and props fallback classes) are treated as hard errors and cannot be suppressed/downgraded.
50
+ - Opt-out: set `strictGuarantee: false` to compile in non-strict mode.
51
+ - CI override: set `FICT_STRICT_GUARANTEE=1` to force-enable `strictGuarantee` even when options request opt-out.
52
+ - Contract fixtures: see `packages/compiler/test/reactivity-guarantee-contract.test.ts` for the maintained guarantee/fallback/unsupported matrix checks.
43
53
  - `emitModuleMetadata`:
44
54
  - `true`: always write adjacent `.fict.meta.json` sidecar files next to source files.
45
55
  - `false`: never write metadata files.
@@ -49,3 +59,12 @@ createFictPlugin({
49
59
  - `reactiveScopes`: function names whose **first callback argument** is treated as a component-like reactive scope.
50
60
  - Only **direct calls** are recognized (e.g., `renderHook(() => ...)` or `utils.renderHook(() => ...)`).
51
61
  - **Aliases/indirect calls** are not recognized (e.g., `const rh = renderHook; rh(() => ...)`).
62
+
63
+ ## Recommended Profiles
64
+
65
+ Use `docs/config-profiles.md` for copy-paste presets:
66
+
67
+ - strict default app profile (`strictGuarantee: true`)
68
+ - CI hard-gate profile
69
+ - migration/benchmark profile (`strictGuarantee: false`)
70
+ - one-shot build profile (`emitModuleMetadata: false`)
package/dist/index.cjs CHANGED
@@ -14241,6 +14241,9 @@ var RUNTIME_ALIASES = {
14241
14241
  hydrateComponent: "hydrateComponent",
14242
14242
  registerResume: "__fictRegisterResume"
14243
14243
  };
14244
+ var RUNTIME_HELPER_MODULES = {
14245
+ keyedList: "@fictjs/runtime/internal/list"
14246
+ };
14244
14247
  var DelegatedEvents = /* @__PURE__ */ new Set([...DelegatedEventNames]);
14245
14248
  var SAFE_FUNCTIONS = /* @__PURE__ */ new Set([
14246
14249
  // Console methods
@@ -24066,17 +24069,19 @@ function collectDeclaredNames(body, t4) {
24066
24069
  function attachHelperImports(ctx, body, t4) {
24067
24070
  if (ctx.helpersUsed.size === 0) return body;
24068
24071
  const declared = collectDeclaredNames(body, t4);
24069
- const specifiers = [];
24072
+ const specifiersByModule = /* @__PURE__ */ new Map();
24070
24073
  for (const name of ctx.helpersUsed) {
24071
24074
  const alias = RUNTIME_ALIASES[name];
24072
24075
  const helper = RUNTIME_HELPERS[name];
24073
24076
  if (alias && helper) {
24074
24077
  if (declared.has(alias)) continue;
24075
- specifiers.push(t4.importSpecifier(t4.identifier(alias), t4.identifier(helper)));
24078
+ const modulePath = RUNTIME_HELPER_MODULES[name] ?? RUNTIME_MODULE;
24079
+ const moduleSpecifiers = specifiersByModule.get(modulePath) ?? [];
24080
+ moduleSpecifiers.push(t4.importSpecifier(t4.identifier(alias), t4.identifier(helper)));
24081
+ specifiersByModule.set(modulePath, moduleSpecifiers);
24076
24082
  }
24077
24083
  }
24078
- if (specifiers.length === 0) return body;
24079
- const importDecl = t4.importDeclaration(specifiers, t4.stringLiteral(RUNTIME_MODULE));
24084
+ if (specifiersByModule.size === 0) return body;
24080
24085
  const helpers = [];
24081
24086
  if (ctx.needsForOfHelper) {
24082
24087
  const itemId = t4.identifier("item");
@@ -24114,7 +24119,15 @@ function attachHelperImports(ctx, body, t4) {
24114
24119
  )
24115
24120
  );
24116
24121
  }
24117
- return [importDecl, ...helpers, ...body];
24122
+ const modulePaths = Array.from(specifiersByModule.keys()).sort((a, b) => {
24123
+ if (a === RUNTIME_MODULE) return -1;
24124
+ if (b === RUNTIME_MODULE) return 1;
24125
+ return a.localeCompare(b);
24126
+ });
24127
+ const importDecls = modulePaths.map(
24128
+ (modulePath) => t4.importDeclaration(specifiersByModule.get(modulePath) ?? [], t4.stringLiteral(modulePath))
24129
+ );
24130
+ return [...importDecls, ...helpers, ...body];
24118
24131
  }
24119
24132
 
24120
24133
  // src/ir/codegen-jsx-keys.ts
@@ -25429,6 +25442,7 @@ function emitResumableEventBinding(targetId, eventName, expr, statements, ctx, c
25429
25442
  // src/ir/codegen-runtime-imports.ts
25430
25443
  var RUNTIME_IMPORT_MODULES = /* @__PURE__ */ new Set([
25431
25444
  RUNTIME_MODULE,
25445
+ ...Object.values(RUNTIME_HELPER_MODULES),
25432
25446
  "@fictjs/runtime",
25433
25447
  "@fictjs/runtime/advanced",
25434
25448
  "fict",
@@ -28568,10 +28582,13 @@ function transformControlFlowReturns(statements, ctx) {
28568
28582
  }
28569
28583
  return false;
28570
28584
  };
28571
- function hasNodeMatch(nodes, predicate) {
28585
+ function hasNodeMatch(nodes, predicate, options) {
28572
28586
  let found = false;
28573
- const visit = (node) => {
28587
+ const visit = (node, isRoot = false) => {
28574
28588
  if (!node || found) return;
28589
+ if (!isRoot && options?.skipNestedFunctions && (t4.isFunctionExpression(node) || t4.isArrowFunctionExpression(node) || t4.isFunctionDeclaration(node) || t4.isObjectMethod(node) || t4.isClassMethod(node))) {
28590
+ return;
28591
+ }
28575
28592
  if (predicate(node)) {
28576
28593
  found = true;
28577
28594
  return;
@@ -28594,17 +28611,134 @@ function transformControlFlowReturns(statements, ctx) {
28594
28611
  }
28595
28612
  };
28596
28613
  for (const node of nodes) {
28597
- visit(node);
28614
+ visit(node, true);
28598
28615
  if (found) return true;
28599
28616
  }
28600
28617
  return found;
28601
28618
  }
28602
28619
  const containsReturnStatement = (nodes) => hasNodeMatch(nodes, (node) => t4.isReturnStatement(node));
28603
- const containsReactiveAccessorRead = (nodes) => hasNodeMatch(nodes, (node) => {
28604
- if (!t4.isCallExpression(node) && !t4.isOptionalCallExpression(node)) return false;
28605
- const callee = node.callee;
28606
- return t4.isIdentifier(callee) && reactiveAccessorNames.has(callee.name);
28607
- });
28620
+ const getMemberRootIdentifier = (expr) => {
28621
+ let current = expr.object;
28622
+ while (t4.isMemberExpression(current) || t4.isOptionalMemberExpression(current)) {
28623
+ current = current.object;
28624
+ }
28625
+ return t4.isIdentifier(current) ? current : null;
28626
+ };
28627
+ const containsReactiveAccessorRead = (nodes, options) => hasNodeMatch(
28628
+ nodes,
28629
+ (node) => {
28630
+ if (t4.isCallExpression(node) || t4.isOptionalCallExpression(node)) {
28631
+ const callee = node.callee;
28632
+ return t4.isIdentifier(callee) && reactiveAccessorNames.has(callee.name);
28633
+ }
28634
+ if (t4.isMemberExpression(node) || t4.isOptionalMemberExpression(node)) {
28635
+ const root = getMemberRootIdentifier(node);
28636
+ return !!(root && reactiveAccessorNames.has(root.name));
28637
+ }
28638
+ return false;
28639
+ },
28640
+ options
28641
+ );
28642
+ const containsReactiveControlFlowRead = (nodes) => hasNodeMatch(
28643
+ nodes,
28644
+ (node) => {
28645
+ if (t4.isIfStatement(node)) {
28646
+ return containsReactiveAccessorRead([node.test], { skipNestedFunctions: true });
28647
+ }
28648
+ if (t4.isSwitchStatement(node)) {
28649
+ return containsReactiveAccessorRead([node.discriminant], { skipNestedFunctions: true });
28650
+ }
28651
+ if (t4.isConditionalExpression(node)) {
28652
+ return containsReactiveAccessorRead([node.test], { skipNestedFunctions: true });
28653
+ }
28654
+ if (t4.isWhileStatement(node) || t4.isDoWhileStatement(node)) {
28655
+ return containsReactiveAccessorRead([node.test], { skipNestedFunctions: true });
28656
+ }
28657
+ if (t4.isForStatement(node)) {
28658
+ const parts = [];
28659
+ if (node.init) parts.push(node.init);
28660
+ if (node.test) parts.push(node.test);
28661
+ if (node.update) parts.push(node.update);
28662
+ return parts.length > 0 && containsReactiveAccessorRead(parts, { skipNestedFunctions: true });
28663
+ }
28664
+ if (t4.isForOfStatement(node) || t4.isForInStatement(node)) {
28665
+ return containsReactiveAccessorRead([node.right], { skipNestedFunctions: true });
28666
+ }
28667
+ return false;
28668
+ },
28669
+ { skipNestedFunctions: true }
28670
+ );
28671
+ const hasRiskyBranchControlFlow = (stmts) => {
28672
+ if (stmts.length === 0) return false;
28673
+ return containsReactiveControlFlowRead(stmts);
28674
+ };
28675
+ const isJSXLikeNode = (node) => !!node && (t4.isJSXElement(node) || t4.isJSXFragment(node));
28676
+ const isStoreSourceExpression = (expr) => {
28677
+ if (t4.isIdentifier(expr)) {
28678
+ return !!ctx.storeVars?.has(expr.name);
28679
+ }
28680
+ if (t4.isMemberExpression(expr) || t4.isOptionalMemberExpression(expr)) {
28681
+ const root = getMemberRootIdentifier(expr);
28682
+ return !!(root && ctx.storeVars?.has(root.name));
28683
+ }
28684
+ return false;
28685
+ };
28686
+ const hasRiskyStoreDestructureRead = (stmt) => {
28687
+ if (t4.isVariableDeclaration(stmt)) {
28688
+ for (const decl of stmt.declarations) {
28689
+ if (!decl.init) continue;
28690
+ const hasPattern = t4.isObjectPattern(decl.id) || t4.isArrayPattern(decl.id);
28691
+ if (!hasPattern) continue;
28692
+ if (isStoreSourceExpression(decl.init)) {
28693
+ return true;
28694
+ }
28695
+ }
28696
+ return false;
28697
+ }
28698
+ if (t4.isExpressionStatement(stmt) && t4.isAssignmentExpression(stmt.expression)) {
28699
+ const assignment = stmt.expression;
28700
+ const isPatternLhs = t4.isObjectPattern(assignment.left) || t4.isArrayPattern(assignment.left);
28701
+ if (!isPatternLhs) return false;
28702
+ return isStoreSourceExpression(assignment.right);
28703
+ }
28704
+ return false;
28705
+ };
28706
+ const hasRiskyBranchPreludeReads = (stmts) => {
28707
+ for (const stmt of stmts) {
28708
+ if (hasRiskyStoreDestructureRead(stmt)) {
28709
+ return true;
28710
+ }
28711
+ if (t4.isReturnStatement(stmt)) {
28712
+ const arg = stmt.argument;
28713
+ if (!arg || isJSXLikeNode(arg)) continue;
28714
+ if (containsReactiveAccessorRead([arg], { skipNestedFunctions: true })) {
28715
+ return true;
28716
+ }
28717
+ continue;
28718
+ }
28719
+ if (containsReactiveAccessorRead([stmt], { skipNestedFunctions: true })) {
28720
+ return true;
28721
+ }
28722
+ }
28723
+ return false;
28724
+ };
28725
+ const hasRiskyImmediateInvocationReads = (stmts) => hasNodeMatch(
28726
+ stmts,
28727
+ (node) => {
28728
+ if (!t4.isCallExpression(node) && !t4.isOptionalCallExpression(node)) return false;
28729
+ const callee = node.callee;
28730
+ if (!t4.isFunctionExpression(callee) && !t4.isArrowFunctionExpression(callee)) {
28731
+ return false;
28732
+ }
28733
+ const bodyNodes = t4.isBlockStatement(callee.body) ? callee.body.body : [callee.body];
28734
+ return containsReactiveAccessorRead(bodyNodes, { skipNestedFunctions: true });
28735
+ },
28736
+ { skipNestedFunctions: true }
28737
+ );
28738
+ const needsTrackedBranchReads = (stmts) => {
28739
+ if (stmts.length === 0) return false;
28740
+ return hasRiskyBranchControlFlow(stmts) || hasRiskyBranchPreludeReads(stmts) || hasRiskyImmediateInvocationReads(stmts);
28741
+ };
28608
28742
  const emitControlFlowFallbackWarning = (node, kind) => {
28609
28743
  const onWarn = ctx.options?.onWarn;
28610
28744
  if (!onWarn) return;
@@ -28673,7 +28807,7 @@ function transformControlFlowReturns(statements, ctx) {
28673
28807
  }
28674
28808
  return found;
28675
28809
  }
28676
- function buildConditionalBindingExpr(testExpr, trueFn, falseFn) {
28810
+ function buildConditionalBindingExpr(testExpr, trueFn, falseFn, options) {
28677
28811
  ctx.helpersUsed.add("conditional");
28678
28812
  ctx.helpersUsed.add("createElement");
28679
28813
  ctx.helpersUsed.add("onDestroy");
@@ -28684,6 +28818,16 @@ function transformControlFlowReturns(statements, ctx) {
28684
28818
  t4.identifier(RUNTIME_ALIASES.createElement),
28685
28819
  falseFn
28686
28820
  ];
28821
+ if (options?.trackBranchReads) {
28822
+ const undefinedExpr = t4.unaryExpression("void", t4.numericLiteral(0));
28823
+ args.push(
28824
+ undefinedExpr,
28825
+ t4.cloneNode(undefinedExpr),
28826
+ t4.objectExpression([
28827
+ t4.objectProperty(t4.identifier("trackBranchReads"), t4.booleanLiteral(true))
28828
+ ])
28829
+ );
28830
+ }
28687
28831
  const bindingCall = t4.callExpression(t4.identifier(RUNTIME_ALIASES.conditional), args);
28688
28832
  return t4.callExpression(
28689
28833
  t4.arrowFunctionExpression(
@@ -28717,7 +28861,13 @@ function transformControlFlowReturns(statements, ctx) {
28717
28861
  const trueFn = buildBranchFunction(consequentStmts);
28718
28862
  const falseFn = alternateStmts ? buildBranchFunction(alternateStmts) : null;
28719
28863
  if (!trueFn || !falseFn) return null;
28720
- return buildConditionalBindingExpr(ifStmt.test, trueFn, falseFn);
28864
+ const shouldTrackBranchReads = needsTrackedBranchReads(consequentStmts) || (alternateStmts ? needsTrackedBranchReads(alternateStmts) : false);
28865
+ return buildConditionalBindingExpr(
28866
+ ifStmt.test,
28867
+ trueFn,
28868
+ falseFn,
28869
+ shouldTrackBranchReads ? { trackBranchReads: true } : void 0
28870
+ );
28721
28871
  }
28722
28872
  function isSupportedSwitchDiscriminant(_expr) {
28723
28873
  return true;
@@ -28810,10 +28960,13 @@ function transformControlFlowReturns(statements, ctx) {
28810
28960
  ),
28811
28961
  []
28812
28962
  );
28963
+ let currentExprNeedsTrackedBranchReads = needsTrackedBranchReads(fallbackStatements);
28813
28964
  for (let i = branches.length - 1; i >= 0; i--) {
28814
28965
  const branch = branches[i];
28815
28966
  const trueFn = buildBranchFunction(branch.statements, { disallowRenderHooks: true });
28816
28967
  if (!trueFn) return null;
28968
+ const trueBranchNeedsTrackedBranchReads = needsTrackedBranchReads(branch.statements);
28969
+ const trackBranchReads = trueBranchNeedsTrackedBranchReads || currentExprNeedsTrackedBranchReads;
28817
28970
  const falseFn = t4.arrowFunctionExpression(
28818
28971
  [],
28819
28972
  t4.blockStatement([t4.returnStatement(currentExpr)])
@@ -28830,7 +28983,13 @@ function transformControlFlowReturns(statements, ctx) {
28830
28983
  (acc, expr) => t4.logicalExpression("||", acc, expr),
28831
28984
  comparisons[0]
28832
28985
  );
28833
- currentExpr = buildConditionalBindingExpr(testExpr, trueFn, falseFn);
28986
+ currentExpr = buildConditionalBindingExpr(
28987
+ testExpr,
28988
+ trueFn,
28989
+ falseFn,
28990
+ trackBranchReads ? { trackBranchReads: true } : void 0
28991
+ );
28992
+ currentExprNeedsTrackedBranchReads = trackBranchReads;
28834
28993
  }
28835
28994
  return t4.callExpression(
28836
28995
  t4.arrowFunctionExpression(
@@ -33179,8 +33338,52 @@ function shouldSuppressWarning(suppressions, code, line) {
33179
33338
  });
33180
33339
  }
33181
33340
  var DEFAULT_ERROR_WARNING_CODES = /* @__PURE__ */ new Set(["FICT-R004"]);
33341
+ var STRICT_REACTIVITY_WARNING_CODES = /* @__PURE__ */ new Set(["FICT-R003", "FICT-R006"]);
33342
+ var STRICT_GUARANTEE_WARNING_CODES = /* @__PURE__ */ new Set([
33343
+ "FICT-P001",
33344
+ "FICT-P002",
33345
+ "FICT-P003",
33346
+ "FICT-P004",
33347
+ "FICT-P005",
33348
+ "FICT-J003",
33349
+ "FICT-S002",
33350
+ "FICT-R001",
33351
+ "FICT-R002",
33352
+ "FICT-R003",
33353
+ "FICT-R006"
33354
+ ]);
33355
+ function readBooleanEnv(name) {
33356
+ const raw = process.env[name];
33357
+ if (!raw) return void 0;
33358
+ const normalized = raw.trim().toLowerCase();
33359
+ if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") {
33360
+ return true;
33361
+ }
33362
+ if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") {
33363
+ return false;
33364
+ }
33365
+ return void 0;
33366
+ }
33367
+ function validateStrictGuaranteeConfig(options, suppressions) {
33368
+ if (!options.strictGuarantee) return;
33369
+ if (suppressions.length > 0) {
33370
+ throw new Error(
33371
+ "strictGuarantee does not allow fict-ignore suppression comments. Remove suppressions to keep fail-closed guarantees."
33372
+ );
33373
+ }
33374
+ if (!options.warningLevels) return;
33375
+ for (const [code, level] of Object.entries(options.warningLevels)) {
33376
+ if (!STRICT_GUARANTEE_WARNING_CODES.has(code)) continue;
33377
+ if (level === "error") continue;
33378
+ throw new Error(
33379
+ `strictGuarantee does not allow downgrading ${code} to "${level}". Remove this warningLevels override.`
33380
+ );
33381
+ }
33382
+ }
33182
33383
  function hasErrorEscalation(options) {
33183
33384
  if (DEFAULT_ERROR_WARNING_CODES.size > 0) return true;
33385
+ if (options.strictGuarantee) return true;
33386
+ if (options.strictReactivity) return true;
33184
33387
  if (options.warningsAsErrors === true) return true;
33185
33388
  if (Array.isArray(options.warningsAsErrors) && options.warningsAsErrors.length > 0) return true;
33186
33389
  if (options.warningLevels) {
@@ -33189,8 +33392,10 @@ function hasErrorEscalation(options) {
33189
33392
  return false;
33190
33393
  }
33191
33394
  function resolveWarningLevel(code, options) {
33395
+ if (options.strictGuarantee && STRICT_GUARANTEE_WARNING_CODES.has(code)) return "error";
33192
33396
  const override = options.warningLevels?.[code];
33193
33397
  if (override) return override;
33398
+ if (options.strictReactivity && STRICT_REACTIVITY_WARNING_CODES.has(code)) return "error";
33194
33399
  if (options.warningsAsErrors === true) return "error";
33195
33400
  if (Array.isArray(options.warningsAsErrors) && options.warningsAsErrors.includes(code)) {
33196
33401
  return "error";
@@ -33204,6 +33409,7 @@ function formatWarningAsError(warning) {
33204
33409
  at ${location}`;
33205
33410
  }
33206
33411
  function createWarningDispatcher(onWarn, suppressions, options, dev) {
33412
+ validateStrictGuaranteeConfig(options, suppressions);
33207
33413
  const hasEscalation = hasErrorEscalation(options);
33208
33414
  if (!dev && !hasEscalation) return () => {
33209
33415
  };
@@ -33309,7 +33515,7 @@ function isDynamicPropertyAccess(node, t4) {
33309
33515
  if (!node.computed) return false;
33310
33516
  return !(t4.isStringLiteral(node.property) || t4.isNumericLiteral(node.property));
33311
33517
  }
33312
- function runWarningPass(programPath, stateBindingIds, stateRootBindingIds, reactiveBindingIds, effectMacroNames, warn, fileName, t4) {
33518
+ function runWarningPass(programPath, stateBindingIds, stateRootBindingIds, reactiveBindingIds, stateMacroNames, memoMacroNames, effectMacroNames, warn, fileName, t4) {
33313
33519
  const hasTrackedBinding = (path2, name, tracked) => {
33314
33520
  const binding = path2.scope.getBinding(name);
33315
33521
  return !!(binding && tracked.has(binding.identifier));
@@ -33324,6 +33530,107 @@ function runWarningPass(programPath, stateBindingIds, stateRootBindingIds, react
33324
33530
  if (!root) return false;
33325
33531
  return hasTrackedBinding(path2, root.name, reactiveBindingIds);
33326
33532
  };
33533
+ const NON_ESCAPING_CALLBACK_METHODS = /* @__PURE__ */ new Set([
33534
+ "map",
33535
+ "forEach",
33536
+ "filter",
33537
+ "some",
33538
+ "every",
33539
+ "find",
33540
+ "findIndex",
33541
+ "findLast",
33542
+ "findLastIndex",
33543
+ "flatMap",
33544
+ "reduce",
33545
+ "reduceRight",
33546
+ "sort",
33547
+ "toSorted",
33548
+ "then",
33549
+ "catch",
33550
+ "finally"
33551
+ ]);
33552
+ const capturedClosureByBinding = /* @__PURE__ */ new Map();
33553
+ const shouldIgnoreIdentifierReference = (idPath) => {
33554
+ if (idPath.parentPath.isMemberExpression({ property: idPath.node }) && !idPath.parent.computed) {
33555
+ return true;
33556
+ }
33557
+ if (idPath.parentPath.isObjectProperty({ key: idPath.node }) && !idPath.parent.computed && !idPath.parent.shorthand) {
33558
+ return true;
33559
+ }
33560
+ return false;
33561
+ };
33562
+ const collectCapturedReactiveNames = (fnPath) => {
33563
+ const captured = /* @__PURE__ */ new Set();
33564
+ fnPath.traverse({
33565
+ Function(inner) {
33566
+ if (inner === fnPath) return;
33567
+ inner.skip();
33568
+ },
33569
+ Identifier(idPath) {
33570
+ if (shouldIgnoreIdentifierReference(idPath)) return;
33571
+ const name = idPath.node.name;
33572
+ const binding = idPath.scope.getBinding(name);
33573
+ if (!binding) return;
33574
+ if (!reactiveBindingIds.has(binding.identifier)) return;
33575
+ if (binding.scope === idPath.scope || binding.scope === fnPath.scope) return;
33576
+ captured.add(name);
33577
+ }
33578
+ });
33579
+ return captured;
33580
+ };
33581
+ const registerClosureCaptureBinding = (fnPath, captured) => {
33582
+ if (captured.size === 0) return;
33583
+ if (fnPath.isFunctionDeclaration() && fnPath.node.id) {
33584
+ const binding = fnPath.parentPath.scope.getBinding(fnPath.node.id.name);
33585
+ if (binding) {
33586
+ capturedClosureByBinding.set(binding.identifier, captured);
33587
+ }
33588
+ return;
33589
+ }
33590
+ if ((fnPath.isFunctionExpression() || fnPath.isArrowFunctionExpression()) && fnPath.parentPath.isVariableDeclarator()) {
33591
+ const id = fnPath.parentPath.node.id;
33592
+ if (!t4.isIdentifier(id)) return;
33593
+ const binding = fnPath.parentPath.scope.getBinding(id.name);
33594
+ if (binding) {
33595
+ capturedClosureByBinding.set(binding.identifier, captured);
33596
+ }
33597
+ return;
33598
+ }
33599
+ if (fnPath.parentPath.isAssignmentExpression({ right: fnPath.node })) {
33600
+ const left = fnPath.parentPath.node.left;
33601
+ if (!t4.isIdentifier(left)) return;
33602
+ const binding = fnPath.parentPath.scope.getBinding(left.name);
33603
+ if (binding) {
33604
+ capturedClosureByBinding.set(binding.identifier, captured);
33605
+ }
33606
+ }
33607
+ };
33608
+ const collectCapturedForArgument = (argPath) => {
33609
+ if (argPath.isArrowFunctionExpression() || argPath.isFunctionExpression()) {
33610
+ const captured2 = collectCapturedReactiveNames(argPath);
33611
+ return captured2.size > 0 ? captured2 : null;
33612
+ }
33613
+ if (!argPath.isIdentifier()) return null;
33614
+ const binding = argPath.scope.getBinding(argPath.node.name);
33615
+ if (!binding) return null;
33616
+ const captured = capturedClosureByBinding.get(binding.identifier);
33617
+ return captured && captured.size > 0 ? captured : null;
33618
+ };
33619
+ const isNonEscapingCallbackHost = (callee) => {
33620
+ const member = t4.isMemberExpression(callee) || t4.isOptionalMemberExpression(callee) ? callee : null;
33621
+ if (!member || member.computed || !t4.isIdentifier(member.property)) return false;
33622
+ return NON_ESCAPING_CALLBACK_METHODS.has(member.property.name);
33623
+ };
33624
+ const emitClosureCaptureWarning = (node, captured) => {
33625
+ const names = Array.from(captured).sort().join(", ");
33626
+ emitWarning(
33627
+ node,
33628
+ "FICT-R005",
33629
+ `Function captures reactive variable(s): ${names}. Pass them as parameters or memoize explicitly to avoid hidden dependencies.`,
33630
+ warn,
33631
+ fileName
33632
+ );
33633
+ };
33327
33634
  const argumentHasReactive = (argPath) => {
33328
33635
  if (argPath.isSpreadElement()) {
33329
33636
  const inner = argPath.get("argument");
@@ -33346,12 +33653,7 @@ function runWarningPass(programPath, stateBindingIds, stateRootBindingIds, react
33346
33653
  path2.skip();
33347
33654
  },
33348
33655
  Identifier(idPath) {
33349
- if (idPath.parentPath.isMemberExpression({ property: idPath.node }) && !idPath.parent.computed) {
33350
- return;
33351
- }
33352
- if (idPath.parentPath.isObjectProperty({ key: idPath.node }) && !idPath.parent.computed && !idPath.parent.shorthand) {
33353
- return;
33354
- }
33656
+ if (shouldIgnoreIdentifierReference(idPath)) return;
33355
33657
  const binding = idPath.scope.getBinding(idPath.node.name);
33356
33658
  if (binding && reactiveBindingIds.has(binding.identifier)) {
33357
33659
  found = true;
@@ -33432,36 +33734,14 @@ function runWarningPass(programPath, stateBindingIds, stateRootBindingIds, react
33432
33734
  }
33433
33735
  },
33434
33736
  Function(path2) {
33435
- const captured = /* @__PURE__ */ new Set();
33436
- path2.traverse(
33437
- {
33438
- Function(inner) {
33439
- if (inner === path2) return;
33440
- inner.skip();
33441
- },
33442
- Identifier(idPath) {
33443
- const name = idPath.node.name;
33444
- const binding = idPath.scope.getBinding(name);
33445
- if (!binding) return;
33446
- if (!reactiveBindingIds.has(binding.identifier)) return;
33447
- if (binding.scope === idPath.scope || binding.scope === path2.scope) return;
33448
- captured.add(name);
33449
- }
33450
- },
33451
- {}
33452
- );
33453
- if (captured.size > 0) {
33454
- emitWarning(
33455
- path2.node,
33456
- "FICT-R005",
33457
- `Function captures reactive variable(s): ${Array.from(captured).join(", ")}. Pass them as parameters or memoize explicitly to avoid hidden dependencies.`,
33458
- warn,
33459
- fileName
33460
- );
33461
- }
33737
+ const captured = collectCapturedReactiveNames(path2);
33738
+ registerClosureCaptureBinding(path2, captured);
33462
33739
  },
33463
33740
  CallExpression(path2) {
33464
- const isEffect = isEffectCall(path2.node, t4, effectMacroNames);
33741
+ const callNode = path2.node;
33742
+ if (isStateCall(callNode, t4, stateMacroNames)) return;
33743
+ if (isMemoCall(callNode, t4, memoMacroNames)) return;
33744
+ const isEffect = isEffectCall(callNode, t4, effectMacroNames);
33465
33745
  if (isEffect) {
33466
33746
  const argPath = path2.get("arguments.0");
33467
33747
  if (argPath?.isFunctionExpression() || argPath?.isArrowFunctionExpression()) {
@@ -33484,7 +33764,7 @@ function runWarningPass(programPath, stateBindingIds, stateRootBindingIds, react
33484
33764
  });
33485
33765
  if (!hasReactiveDependency) {
33486
33766
  emitWarning(
33487
- path2.node,
33767
+ callNode,
33488
33768
  "FICT-E001",
33489
33769
  "Effect has no reactive reads; it will run once. Consider removing $effect or adding dependencies.",
33490
33770
  warn,
@@ -33507,6 +33787,7 @@ function runWarningPass(programPath, stateBindingIds, stateRootBindingIds, react
33507
33787
  const isSafe = calleeName && SAFE_FUNCTIONS.has(calleeName);
33508
33788
  if (isSafe) return;
33509
33789
  const argPaths = path2.get("arguments");
33790
+ const nonEscapingCallbackHost = isNonEscapingCallbackHost(callee);
33510
33791
  for (const argPath of argPaths) {
33511
33792
  if (argPath.isIdentifier() && hasTrackedBinding(argPath, argPath.node.name, stateBindingIds)) {
33512
33793
  continue;
@@ -33522,6 +33803,13 @@ function runWarningPass(programPath, stateBindingIds, stateRootBindingIds, react
33522
33803
  break;
33523
33804
  }
33524
33805
  }
33806
+ if (nonEscapingCallbackHost) return;
33807
+ for (const argPath of argPaths) {
33808
+ const captured = collectCapturedForArgument(argPath);
33809
+ if (!captured) continue;
33810
+ emitClosureCaptureWarning(argPath.node, captured);
33811
+ break;
33812
+ }
33525
33813
  },
33526
33814
  OptionalMemberExpression(path2) {
33527
33815
  if (!path2.node.computed) return;
@@ -34386,6 +34674,8 @@ or extract the nested logic into a custom hook (useXxx).`
34386
34674
  stateBindingIds,
34387
34675
  stateRootBindingIds,
34388
34676
  reactiveBindingIds,
34677
+ stateMacroNames,
34678
+ memoMacroNames,
34389
34679
  effectMacroNames,
34390
34680
  warn,
34391
34681
  fileName,
@@ -34430,13 +34720,17 @@ var createFictPlugin = (0, import_helper_plugin_utils.declare)(
34430
34720
  (api, options = {}) => {
34431
34721
  api.assertVersion(7);
34432
34722
  const t4 = api.types;
34723
+ const strictGuaranteeFromEnv = readBooleanEnv("FICT_STRICT_GUARANTEE") === true;
34433
34724
  const normalizedOptions = {
34434
34725
  ...options,
34435
34726
  fineGrainedDom: options.fineGrainedDom ?? true,
34727
+ lazyConditional: options.lazyConditional ?? true,
34728
+ getterCache: options.getterCache ?? true,
34436
34729
  optimize: options.optimize ?? true,
34437
34730
  optimizeLevel: options.optimizeLevel ?? "safe",
34438
34731
  inlineDerivedMemos: options.inlineDerivedMemos ?? true,
34439
34732
  emitModuleMetadata: options.emitModuleMetadata ?? "auto",
34733
+ strictGuarantee: strictGuaranteeFromEnv || options.strictGuarantee !== false,
34440
34734
  dev: options.dev ?? (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test")
34441
34735
  };
34442
34736
  return {
package/dist/index.d.cts CHANGED
@@ -23,9 +23,9 @@ interface FictCompilerOptions {
23
23
  onWarn?: (warning: CompilerWarning) => void;
24
24
  /** Internal: filename of the module being compiled. */
25
25
  filename?: string;
26
- /** Enable lazy evaluation of conditional derived values (Rule J optimization) */
26
+ /** Enable lazy evaluation of conditional derived values (Rule J optimization). Default: true. */
27
27
  lazyConditional?: boolean;
28
- /** Enable getter caching within the same sync block (Rule L optimization) */
28
+ /** Enable getter caching within the same sync block (Rule L optimization). Default: true. */
29
29
  getterCache?: boolean;
30
30
  /** Emit fine-grained DOM creation/binding code for supported JSX templates */
31
31
  fineGrainedDom?: boolean;
@@ -63,6 +63,19 @@ interface FictCompilerOptions {
63
63
  * Per-warning override. "off" suppresses, "error" throws, "warn" emits.
64
64
  */
65
65
  warningLevels?: Record<string, 'off' | 'warn' | 'error'>;
66
+ /**
67
+ * Strict control-flow reactivity mode.
68
+ * When enabled, control-flow fallback diagnostics (`FICT-R003`, `FICT-R006`)
69
+ * are treated as errors unless explicitly overridden via `warningLevels`.
70
+ */
71
+ strictReactivity?: boolean;
72
+ /**
73
+ * Fail-closed reactivity guarantee mode.
74
+ * When enabled, diagnostics that indicate non-guaranteed reactive behavior are
75
+ * treated as hard errors and cannot be suppressed/downgraded.
76
+ * Default: true.
77
+ */
78
+ strictGuarantee?: boolean;
66
79
  /**
67
80
  * Optional shared module metadata map for cross-module reactive imports.
68
81
  * If omitted, the compiler uses a process-wide cache.
package/dist/index.d.ts CHANGED
@@ -23,9 +23,9 @@ interface FictCompilerOptions {
23
23
  onWarn?: (warning: CompilerWarning) => void;
24
24
  /** Internal: filename of the module being compiled. */
25
25
  filename?: string;
26
- /** Enable lazy evaluation of conditional derived values (Rule J optimization) */
26
+ /** Enable lazy evaluation of conditional derived values (Rule J optimization). Default: true. */
27
27
  lazyConditional?: boolean;
28
- /** Enable getter caching within the same sync block (Rule L optimization) */
28
+ /** Enable getter caching within the same sync block (Rule L optimization). Default: true. */
29
29
  getterCache?: boolean;
30
30
  /** Emit fine-grained DOM creation/binding code for supported JSX templates */
31
31
  fineGrainedDom?: boolean;
@@ -63,6 +63,19 @@ interface FictCompilerOptions {
63
63
  * Per-warning override. "off" suppresses, "error" throws, "warn" emits.
64
64
  */
65
65
  warningLevels?: Record<string, 'off' | 'warn' | 'error'>;
66
+ /**
67
+ * Strict control-flow reactivity mode.
68
+ * When enabled, control-flow fallback diagnostics (`FICT-R003`, `FICT-R006`)
69
+ * are treated as errors unless explicitly overridden via `warningLevels`.
70
+ */
71
+ strictReactivity?: boolean;
72
+ /**
73
+ * Fail-closed reactivity guarantee mode.
74
+ * When enabled, diagnostics that indicate non-guaranteed reactive behavior are
75
+ * treated as hard errors and cannot be suppressed/downgraded.
76
+ * Default: true.
77
+ */
78
+ strictGuarantee?: boolean;
66
79
  /**
67
80
  * Optional shared module metadata map for cross-module reactive imports.
68
81
  * If omitted, the compiler uses a process-wide cache.
package/dist/index.js CHANGED
@@ -14226,6 +14226,9 @@ var RUNTIME_ALIASES = {
14226
14226
  hydrateComponent: "hydrateComponent",
14227
14227
  registerResume: "__fictRegisterResume"
14228
14228
  };
14229
+ var RUNTIME_HELPER_MODULES = {
14230
+ keyedList: "@fictjs/runtime/internal/list"
14231
+ };
14229
14232
  var DelegatedEvents = /* @__PURE__ */ new Set([...DelegatedEventNames]);
14230
14233
  var SAFE_FUNCTIONS = /* @__PURE__ */ new Set([
14231
14234
  // Console methods
@@ -24051,17 +24054,19 @@ function collectDeclaredNames(body, t4) {
24051
24054
  function attachHelperImports(ctx, body, t4) {
24052
24055
  if (ctx.helpersUsed.size === 0) return body;
24053
24056
  const declared = collectDeclaredNames(body, t4);
24054
- const specifiers = [];
24057
+ const specifiersByModule = /* @__PURE__ */ new Map();
24055
24058
  for (const name of ctx.helpersUsed) {
24056
24059
  const alias = RUNTIME_ALIASES[name];
24057
24060
  const helper = RUNTIME_HELPERS[name];
24058
24061
  if (alias && helper) {
24059
24062
  if (declared.has(alias)) continue;
24060
- specifiers.push(t4.importSpecifier(t4.identifier(alias), t4.identifier(helper)));
24063
+ const modulePath = RUNTIME_HELPER_MODULES[name] ?? RUNTIME_MODULE;
24064
+ const moduleSpecifiers = specifiersByModule.get(modulePath) ?? [];
24065
+ moduleSpecifiers.push(t4.importSpecifier(t4.identifier(alias), t4.identifier(helper)));
24066
+ specifiersByModule.set(modulePath, moduleSpecifiers);
24061
24067
  }
24062
24068
  }
24063
- if (specifiers.length === 0) return body;
24064
- const importDecl = t4.importDeclaration(specifiers, t4.stringLiteral(RUNTIME_MODULE));
24069
+ if (specifiersByModule.size === 0) return body;
24065
24070
  const helpers = [];
24066
24071
  if (ctx.needsForOfHelper) {
24067
24072
  const itemId = t4.identifier("item");
@@ -24099,7 +24104,15 @@ function attachHelperImports(ctx, body, t4) {
24099
24104
  )
24100
24105
  );
24101
24106
  }
24102
- return [importDecl, ...helpers, ...body];
24107
+ const modulePaths = Array.from(specifiersByModule.keys()).sort((a, b) => {
24108
+ if (a === RUNTIME_MODULE) return -1;
24109
+ if (b === RUNTIME_MODULE) return 1;
24110
+ return a.localeCompare(b);
24111
+ });
24112
+ const importDecls = modulePaths.map(
24113
+ (modulePath) => t4.importDeclaration(specifiersByModule.get(modulePath) ?? [], t4.stringLiteral(modulePath))
24114
+ );
24115
+ return [...importDecls, ...helpers, ...body];
24103
24116
  }
24104
24117
 
24105
24118
  // src/ir/codegen-jsx-keys.ts
@@ -25414,6 +25427,7 @@ function emitResumableEventBinding(targetId, eventName, expr, statements, ctx, c
25414
25427
  // src/ir/codegen-runtime-imports.ts
25415
25428
  var RUNTIME_IMPORT_MODULES = /* @__PURE__ */ new Set([
25416
25429
  RUNTIME_MODULE,
25430
+ ...Object.values(RUNTIME_HELPER_MODULES),
25417
25431
  "@fictjs/runtime",
25418
25432
  "@fictjs/runtime/advanced",
25419
25433
  "fict",
@@ -28553,10 +28567,13 @@ function transformControlFlowReturns(statements, ctx) {
28553
28567
  }
28554
28568
  return false;
28555
28569
  };
28556
- function hasNodeMatch(nodes, predicate) {
28570
+ function hasNodeMatch(nodes, predicate, options) {
28557
28571
  let found = false;
28558
- const visit = (node) => {
28572
+ const visit = (node, isRoot = false) => {
28559
28573
  if (!node || found) return;
28574
+ if (!isRoot && options?.skipNestedFunctions && (t4.isFunctionExpression(node) || t4.isArrowFunctionExpression(node) || t4.isFunctionDeclaration(node) || t4.isObjectMethod(node) || t4.isClassMethod(node))) {
28575
+ return;
28576
+ }
28560
28577
  if (predicate(node)) {
28561
28578
  found = true;
28562
28579
  return;
@@ -28579,17 +28596,134 @@ function transformControlFlowReturns(statements, ctx) {
28579
28596
  }
28580
28597
  };
28581
28598
  for (const node of nodes) {
28582
- visit(node);
28599
+ visit(node, true);
28583
28600
  if (found) return true;
28584
28601
  }
28585
28602
  return found;
28586
28603
  }
28587
28604
  const containsReturnStatement = (nodes) => hasNodeMatch(nodes, (node) => t4.isReturnStatement(node));
28588
- const containsReactiveAccessorRead = (nodes) => hasNodeMatch(nodes, (node) => {
28589
- if (!t4.isCallExpression(node) && !t4.isOptionalCallExpression(node)) return false;
28590
- const callee = node.callee;
28591
- return t4.isIdentifier(callee) && reactiveAccessorNames.has(callee.name);
28592
- });
28605
+ const getMemberRootIdentifier = (expr) => {
28606
+ let current = expr.object;
28607
+ while (t4.isMemberExpression(current) || t4.isOptionalMemberExpression(current)) {
28608
+ current = current.object;
28609
+ }
28610
+ return t4.isIdentifier(current) ? current : null;
28611
+ };
28612
+ const containsReactiveAccessorRead = (nodes, options) => hasNodeMatch(
28613
+ nodes,
28614
+ (node) => {
28615
+ if (t4.isCallExpression(node) || t4.isOptionalCallExpression(node)) {
28616
+ const callee = node.callee;
28617
+ return t4.isIdentifier(callee) && reactiveAccessorNames.has(callee.name);
28618
+ }
28619
+ if (t4.isMemberExpression(node) || t4.isOptionalMemberExpression(node)) {
28620
+ const root = getMemberRootIdentifier(node);
28621
+ return !!(root && reactiveAccessorNames.has(root.name));
28622
+ }
28623
+ return false;
28624
+ },
28625
+ options
28626
+ );
28627
+ const containsReactiveControlFlowRead = (nodes) => hasNodeMatch(
28628
+ nodes,
28629
+ (node) => {
28630
+ if (t4.isIfStatement(node)) {
28631
+ return containsReactiveAccessorRead([node.test], { skipNestedFunctions: true });
28632
+ }
28633
+ if (t4.isSwitchStatement(node)) {
28634
+ return containsReactiveAccessorRead([node.discriminant], { skipNestedFunctions: true });
28635
+ }
28636
+ if (t4.isConditionalExpression(node)) {
28637
+ return containsReactiveAccessorRead([node.test], { skipNestedFunctions: true });
28638
+ }
28639
+ if (t4.isWhileStatement(node) || t4.isDoWhileStatement(node)) {
28640
+ return containsReactiveAccessorRead([node.test], { skipNestedFunctions: true });
28641
+ }
28642
+ if (t4.isForStatement(node)) {
28643
+ const parts = [];
28644
+ if (node.init) parts.push(node.init);
28645
+ if (node.test) parts.push(node.test);
28646
+ if (node.update) parts.push(node.update);
28647
+ return parts.length > 0 && containsReactiveAccessorRead(parts, { skipNestedFunctions: true });
28648
+ }
28649
+ if (t4.isForOfStatement(node) || t4.isForInStatement(node)) {
28650
+ return containsReactiveAccessorRead([node.right], { skipNestedFunctions: true });
28651
+ }
28652
+ return false;
28653
+ },
28654
+ { skipNestedFunctions: true }
28655
+ );
28656
+ const hasRiskyBranchControlFlow = (stmts) => {
28657
+ if (stmts.length === 0) return false;
28658
+ return containsReactiveControlFlowRead(stmts);
28659
+ };
28660
+ const isJSXLikeNode = (node) => !!node && (t4.isJSXElement(node) || t4.isJSXFragment(node));
28661
+ const isStoreSourceExpression = (expr) => {
28662
+ if (t4.isIdentifier(expr)) {
28663
+ return !!ctx.storeVars?.has(expr.name);
28664
+ }
28665
+ if (t4.isMemberExpression(expr) || t4.isOptionalMemberExpression(expr)) {
28666
+ const root = getMemberRootIdentifier(expr);
28667
+ return !!(root && ctx.storeVars?.has(root.name));
28668
+ }
28669
+ return false;
28670
+ };
28671
+ const hasRiskyStoreDestructureRead = (stmt) => {
28672
+ if (t4.isVariableDeclaration(stmt)) {
28673
+ for (const decl of stmt.declarations) {
28674
+ if (!decl.init) continue;
28675
+ const hasPattern = t4.isObjectPattern(decl.id) || t4.isArrayPattern(decl.id);
28676
+ if (!hasPattern) continue;
28677
+ if (isStoreSourceExpression(decl.init)) {
28678
+ return true;
28679
+ }
28680
+ }
28681
+ return false;
28682
+ }
28683
+ if (t4.isExpressionStatement(stmt) && t4.isAssignmentExpression(stmt.expression)) {
28684
+ const assignment = stmt.expression;
28685
+ const isPatternLhs = t4.isObjectPattern(assignment.left) || t4.isArrayPattern(assignment.left);
28686
+ if (!isPatternLhs) return false;
28687
+ return isStoreSourceExpression(assignment.right);
28688
+ }
28689
+ return false;
28690
+ };
28691
+ const hasRiskyBranchPreludeReads = (stmts) => {
28692
+ for (const stmt of stmts) {
28693
+ if (hasRiskyStoreDestructureRead(stmt)) {
28694
+ return true;
28695
+ }
28696
+ if (t4.isReturnStatement(stmt)) {
28697
+ const arg = stmt.argument;
28698
+ if (!arg || isJSXLikeNode(arg)) continue;
28699
+ if (containsReactiveAccessorRead([arg], { skipNestedFunctions: true })) {
28700
+ return true;
28701
+ }
28702
+ continue;
28703
+ }
28704
+ if (containsReactiveAccessorRead([stmt], { skipNestedFunctions: true })) {
28705
+ return true;
28706
+ }
28707
+ }
28708
+ return false;
28709
+ };
28710
+ const hasRiskyImmediateInvocationReads = (stmts) => hasNodeMatch(
28711
+ stmts,
28712
+ (node) => {
28713
+ if (!t4.isCallExpression(node) && !t4.isOptionalCallExpression(node)) return false;
28714
+ const callee = node.callee;
28715
+ if (!t4.isFunctionExpression(callee) && !t4.isArrowFunctionExpression(callee)) {
28716
+ return false;
28717
+ }
28718
+ const bodyNodes = t4.isBlockStatement(callee.body) ? callee.body.body : [callee.body];
28719
+ return containsReactiveAccessorRead(bodyNodes, { skipNestedFunctions: true });
28720
+ },
28721
+ { skipNestedFunctions: true }
28722
+ );
28723
+ const needsTrackedBranchReads = (stmts) => {
28724
+ if (stmts.length === 0) return false;
28725
+ return hasRiskyBranchControlFlow(stmts) || hasRiskyBranchPreludeReads(stmts) || hasRiskyImmediateInvocationReads(stmts);
28726
+ };
28593
28727
  const emitControlFlowFallbackWarning = (node, kind) => {
28594
28728
  const onWarn = ctx.options?.onWarn;
28595
28729
  if (!onWarn) return;
@@ -28658,7 +28792,7 @@ function transformControlFlowReturns(statements, ctx) {
28658
28792
  }
28659
28793
  return found;
28660
28794
  }
28661
- function buildConditionalBindingExpr(testExpr, trueFn, falseFn) {
28795
+ function buildConditionalBindingExpr(testExpr, trueFn, falseFn, options) {
28662
28796
  ctx.helpersUsed.add("conditional");
28663
28797
  ctx.helpersUsed.add("createElement");
28664
28798
  ctx.helpersUsed.add("onDestroy");
@@ -28669,6 +28803,16 @@ function transformControlFlowReturns(statements, ctx) {
28669
28803
  t4.identifier(RUNTIME_ALIASES.createElement),
28670
28804
  falseFn
28671
28805
  ];
28806
+ if (options?.trackBranchReads) {
28807
+ const undefinedExpr = t4.unaryExpression("void", t4.numericLiteral(0));
28808
+ args.push(
28809
+ undefinedExpr,
28810
+ t4.cloneNode(undefinedExpr),
28811
+ t4.objectExpression([
28812
+ t4.objectProperty(t4.identifier("trackBranchReads"), t4.booleanLiteral(true))
28813
+ ])
28814
+ );
28815
+ }
28672
28816
  const bindingCall = t4.callExpression(t4.identifier(RUNTIME_ALIASES.conditional), args);
28673
28817
  return t4.callExpression(
28674
28818
  t4.arrowFunctionExpression(
@@ -28702,7 +28846,13 @@ function transformControlFlowReturns(statements, ctx) {
28702
28846
  const trueFn = buildBranchFunction(consequentStmts);
28703
28847
  const falseFn = alternateStmts ? buildBranchFunction(alternateStmts) : null;
28704
28848
  if (!trueFn || !falseFn) return null;
28705
- return buildConditionalBindingExpr(ifStmt.test, trueFn, falseFn);
28849
+ const shouldTrackBranchReads = needsTrackedBranchReads(consequentStmts) || (alternateStmts ? needsTrackedBranchReads(alternateStmts) : false);
28850
+ return buildConditionalBindingExpr(
28851
+ ifStmt.test,
28852
+ trueFn,
28853
+ falseFn,
28854
+ shouldTrackBranchReads ? { trackBranchReads: true } : void 0
28855
+ );
28706
28856
  }
28707
28857
  function isSupportedSwitchDiscriminant(_expr) {
28708
28858
  return true;
@@ -28795,10 +28945,13 @@ function transformControlFlowReturns(statements, ctx) {
28795
28945
  ),
28796
28946
  []
28797
28947
  );
28948
+ let currentExprNeedsTrackedBranchReads = needsTrackedBranchReads(fallbackStatements);
28798
28949
  for (let i = branches.length - 1; i >= 0; i--) {
28799
28950
  const branch = branches[i];
28800
28951
  const trueFn = buildBranchFunction(branch.statements, { disallowRenderHooks: true });
28801
28952
  if (!trueFn) return null;
28953
+ const trueBranchNeedsTrackedBranchReads = needsTrackedBranchReads(branch.statements);
28954
+ const trackBranchReads = trueBranchNeedsTrackedBranchReads || currentExprNeedsTrackedBranchReads;
28802
28955
  const falseFn = t4.arrowFunctionExpression(
28803
28956
  [],
28804
28957
  t4.blockStatement([t4.returnStatement(currentExpr)])
@@ -28815,7 +28968,13 @@ function transformControlFlowReturns(statements, ctx) {
28815
28968
  (acc, expr) => t4.logicalExpression("||", acc, expr),
28816
28969
  comparisons[0]
28817
28970
  );
28818
- currentExpr = buildConditionalBindingExpr(testExpr, trueFn, falseFn);
28971
+ currentExpr = buildConditionalBindingExpr(
28972
+ testExpr,
28973
+ trueFn,
28974
+ falseFn,
28975
+ trackBranchReads ? { trackBranchReads: true } : void 0
28976
+ );
28977
+ currentExprNeedsTrackedBranchReads = trackBranchReads;
28819
28978
  }
28820
28979
  return t4.callExpression(
28821
28980
  t4.arrowFunctionExpression(
@@ -33164,8 +33323,52 @@ function shouldSuppressWarning(suppressions, code, line) {
33164
33323
  });
33165
33324
  }
33166
33325
  var DEFAULT_ERROR_WARNING_CODES = /* @__PURE__ */ new Set(["FICT-R004"]);
33326
+ var STRICT_REACTIVITY_WARNING_CODES = /* @__PURE__ */ new Set(["FICT-R003", "FICT-R006"]);
33327
+ var STRICT_GUARANTEE_WARNING_CODES = /* @__PURE__ */ new Set([
33328
+ "FICT-P001",
33329
+ "FICT-P002",
33330
+ "FICT-P003",
33331
+ "FICT-P004",
33332
+ "FICT-P005",
33333
+ "FICT-J003",
33334
+ "FICT-S002",
33335
+ "FICT-R001",
33336
+ "FICT-R002",
33337
+ "FICT-R003",
33338
+ "FICT-R006"
33339
+ ]);
33340
+ function readBooleanEnv(name) {
33341
+ const raw = process.env[name];
33342
+ if (!raw) return void 0;
33343
+ const normalized = raw.trim().toLowerCase();
33344
+ if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") {
33345
+ return true;
33346
+ }
33347
+ if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") {
33348
+ return false;
33349
+ }
33350
+ return void 0;
33351
+ }
33352
+ function validateStrictGuaranteeConfig(options, suppressions) {
33353
+ if (!options.strictGuarantee) return;
33354
+ if (suppressions.length > 0) {
33355
+ throw new Error(
33356
+ "strictGuarantee does not allow fict-ignore suppression comments. Remove suppressions to keep fail-closed guarantees."
33357
+ );
33358
+ }
33359
+ if (!options.warningLevels) return;
33360
+ for (const [code, level] of Object.entries(options.warningLevels)) {
33361
+ if (!STRICT_GUARANTEE_WARNING_CODES.has(code)) continue;
33362
+ if (level === "error") continue;
33363
+ throw new Error(
33364
+ `strictGuarantee does not allow downgrading ${code} to "${level}". Remove this warningLevels override.`
33365
+ );
33366
+ }
33367
+ }
33167
33368
  function hasErrorEscalation(options) {
33168
33369
  if (DEFAULT_ERROR_WARNING_CODES.size > 0) return true;
33370
+ if (options.strictGuarantee) return true;
33371
+ if (options.strictReactivity) return true;
33169
33372
  if (options.warningsAsErrors === true) return true;
33170
33373
  if (Array.isArray(options.warningsAsErrors) && options.warningsAsErrors.length > 0) return true;
33171
33374
  if (options.warningLevels) {
@@ -33174,8 +33377,10 @@ function hasErrorEscalation(options) {
33174
33377
  return false;
33175
33378
  }
33176
33379
  function resolveWarningLevel(code, options) {
33380
+ if (options.strictGuarantee && STRICT_GUARANTEE_WARNING_CODES.has(code)) return "error";
33177
33381
  const override = options.warningLevels?.[code];
33178
33382
  if (override) return override;
33383
+ if (options.strictReactivity && STRICT_REACTIVITY_WARNING_CODES.has(code)) return "error";
33179
33384
  if (options.warningsAsErrors === true) return "error";
33180
33385
  if (Array.isArray(options.warningsAsErrors) && options.warningsAsErrors.includes(code)) {
33181
33386
  return "error";
@@ -33189,6 +33394,7 @@ function formatWarningAsError(warning) {
33189
33394
  at ${location}`;
33190
33395
  }
33191
33396
  function createWarningDispatcher(onWarn, suppressions, options, dev) {
33397
+ validateStrictGuaranteeConfig(options, suppressions);
33192
33398
  const hasEscalation = hasErrorEscalation(options);
33193
33399
  if (!dev && !hasEscalation) return () => {
33194
33400
  };
@@ -33294,7 +33500,7 @@ function isDynamicPropertyAccess(node, t4) {
33294
33500
  if (!node.computed) return false;
33295
33501
  return !(t4.isStringLiteral(node.property) || t4.isNumericLiteral(node.property));
33296
33502
  }
33297
- function runWarningPass(programPath, stateBindingIds, stateRootBindingIds, reactiveBindingIds, effectMacroNames, warn, fileName, t4) {
33503
+ function runWarningPass(programPath, stateBindingIds, stateRootBindingIds, reactiveBindingIds, stateMacroNames, memoMacroNames, effectMacroNames, warn, fileName, t4) {
33298
33504
  const hasTrackedBinding = (path2, name, tracked) => {
33299
33505
  const binding = path2.scope.getBinding(name);
33300
33506
  return !!(binding && tracked.has(binding.identifier));
@@ -33309,6 +33515,107 @@ function runWarningPass(programPath, stateBindingIds, stateRootBindingIds, react
33309
33515
  if (!root) return false;
33310
33516
  return hasTrackedBinding(path2, root.name, reactiveBindingIds);
33311
33517
  };
33518
+ const NON_ESCAPING_CALLBACK_METHODS = /* @__PURE__ */ new Set([
33519
+ "map",
33520
+ "forEach",
33521
+ "filter",
33522
+ "some",
33523
+ "every",
33524
+ "find",
33525
+ "findIndex",
33526
+ "findLast",
33527
+ "findLastIndex",
33528
+ "flatMap",
33529
+ "reduce",
33530
+ "reduceRight",
33531
+ "sort",
33532
+ "toSorted",
33533
+ "then",
33534
+ "catch",
33535
+ "finally"
33536
+ ]);
33537
+ const capturedClosureByBinding = /* @__PURE__ */ new Map();
33538
+ const shouldIgnoreIdentifierReference = (idPath) => {
33539
+ if (idPath.parentPath.isMemberExpression({ property: idPath.node }) && !idPath.parent.computed) {
33540
+ return true;
33541
+ }
33542
+ if (idPath.parentPath.isObjectProperty({ key: idPath.node }) && !idPath.parent.computed && !idPath.parent.shorthand) {
33543
+ return true;
33544
+ }
33545
+ return false;
33546
+ };
33547
+ const collectCapturedReactiveNames = (fnPath) => {
33548
+ const captured = /* @__PURE__ */ new Set();
33549
+ fnPath.traverse({
33550
+ Function(inner) {
33551
+ if (inner === fnPath) return;
33552
+ inner.skip();
33553
+ },
33554
+ Identifier(idPath) {
33555
+ if (shouldIgnoreIdentifierReference(idPath)) return;
33556
+ const name = idPath.node.name;
33557
+ const binding = idPath.scope.getBinding(name);
33558
+ if (!binding) return;
33559
+ if (!reactiveBindingIds.has(binding.identifier)) return;
33560
+ if (binding.scope === idPath.scope || binding.scope === fnPath.scope) return;
33561
+ captured.add(name);
33562
+ }
33563
+ });
33564
+ return captured;
33565
+ };
33566
+ const registerClosureCaptureBinding = (fnPath, captured) => {
33567
+ if (captured.size === 0) return;
33568
+ if (fnPath.isFunctionDeclaration() && fnPath.node.id) {
33569
+ const binding = fnPath.parentPath.scope.getBinding(fnPath.node.id.name);
33570
+ if (binding) {
33571
+ capturedClosureByBinding.set(binding.identifier, captured);
33572
+ }
33573
+ return;
33574
+ }
33575
+ if ((fnPath.isFunctionExpression() || fnPath.isArrowFunctionExpression()) && fnPath.parentPath.isVariableDeclarator()) {
33576
+ const id = fnPath.parentPath.node.id;
33577
+ if (!t4.isIdentifier(id)) return;
33578
+ const binding = fnPath.parentPath.scope.getBinding(id.name);
33579
+ if (binding) {
33580
+ capturedClosureByBinding.set(binding.identifier, captured);
33581
+ }
33582
+ return;
33583
+ }
33584
+ if (fnPath.parentPath.isAssignmentExpression({ right: fnPath.node })) {
33585
+ const left = fnPath.parentPath.node.left;
33586
+ if (!t4.isIdentifier(left)) return;
33587
+ const binding = fnPath.parentPath.scope.getBinding(left.name);
33588
+ if (binding) {
33589
+ capturedClosureByBinding.set(binding.identifier, captured);
33590
+ }
33591
+ }
33592
+ };
33593
+ const collectCapturedForArgument = (argPath) => {
33594
+ if (argPath.isArrowFunctionExpression() || argPath.isFunctionExpression()) {
33595
+ const captured2 = collectCapturedReactiveNames(argPath);
33596
+ return captured2.size > 0 ? captured2 : null;
33597
+ }
33598
+ if (!argPath.isIdentifier()) return null;
33599
+ const binding = argPath.scope.getBinding(argPath.node.name);
33600
+ if (!binding) return null;
33601
+ const captured = capturedClosureByBinding.get(binding.identifier);
33602
+ return captured && captured.size > 0 ? captured : null;
33603
+ };
33604
+ const isNonEscapingCallbackHost = (callee) => {
33605
+ const member = t4.isMemberExpression(callee) || t4.isOptionalMemberExpression(callee) ? callee : null;
33606
+ if (!member || member.computed || !t4.isIdentifier(member.property)) return false;
33607
+ return NON_ESCAPING_CALLBACK_METHODS.has(member.property.name);
33608
+ };
33609
+ const emitClosureCaptureWarning = (node, captured) => {
33610
+ const names = Array.from(captured).sort().join(", ");
33611
+ emitWarning(
33612
+ node,
33613
+ "FICT-R005",
33614
+ `Function captures reactive variable(s): ${names}. Pass them as parameters or memoize explicitly to avoid hidden dependencies.`,
33615
+ warn,
33616
+ fileName
33617
+ );
33618
+ };
33312
33619
  const argumentHasReactive = (argPath) => {
33313
33620
  if (argPath.isSpreadElement()) {
33314
33621
  const inner = argPath.get("argument");
@@ -33331,12 +33638,7 @@ function runWarningPass(programPath, stateBindingIds, stateRootBindingIds, react
33331
33638
  path2.skip();
33332
33639
  },
33333
33640
  Identifier(idPath) {
33334
- if (idPath.parentPath.isMemberExpression({ property: idPath.node }) && !idPath.parent.computed) {
33335
- return;
33336
- }
33337
- if (idPath.parentPath.isObjectProperty({ key: idPath.node }) && !idPath.parent.computed && !idPath.parent.shorthand) {
33338
- return;
33339
- }
33641
+ if (shouldIgnoreIdentifierReference(idPath)) return;
33340
33642
  const binding = idPath.scope.getBinding(idPath.node.name);
33341
33643
  if (binding && reactiveBindingIds.has(binding.identifier)) {
33342
33644
  found = true;
@@ -33417,36 +33719,14 @@ function runWarningPass(programPath, stateBindingIds, stateRootBindingIds, react
33417
33719
  }
33418
33720
  },
33419
33721
  Function(path2) {
33420
- const captured = /* @__PURE__ */ new Set();
33421
- path2.traverse(
33422
- {
33423
- Function(inner) {
33424
- if (inner === path2) return;
33425
- inner.skip();
33426
- },
33427
- Identifier(idPath) {
33428
- const name = idPath.node.name;
33429
- const binding = idPath.scope.getBinding(name);
33430
- if (!binding) return;
33431
- if (!reactiveBindingIds.has(binding.identifier)) return;
33432
- if (binding.scope === idPath.scope || binding.scope === path2.scope) return;
33433
- captured.add(name);
33434
- }
33435
- },
33436
- {}
33437
- );
33438
- if (captured.size > 0) {
33439
- emitWarning(
33440
- path2.node,
33441
- "FICT-R005",
33442
- `Function captures reactive variable(s): ${Array.from(captured).join(", ")}. Pass them as parameters or memoize explicitly to avoid hidden dependencies.`,
33443
- warn,
33444
- fileName
33445
- );
33446
- }
33722
+ const captured = collectCapturedReactiveNames(path2);
33723
+ registerClosureCaptureBinding(path2, captured);
33447
33724
  },
33448
33725
  CallExpression(path2) {
33449
- const isEffect = isEffectCall(path2.node, t4, effectMacroNames);
33726
+ const callNode = path2.node;
33727
+ if (isStateCall(callNode, t4, stateMacroNames)) return;
33728
+ if (isMemoCall(callNode, t4, memoMacroNames)) return;
33729
+ const isEffect = isEffectCall(callNode, t4, effectMacroNames);
33450
33730
  if (isEffect) {
33451
33731
  const argPath = path2.get("arguments.0");
33452
33732
  if (argPath?.isFunctionExpression() || argPath?.isArrowFunctionExpression()) {
@@ -33469,7 +33749,7 @@ function runWarningPass(programPath, stateBindingIds, stateRootBindingIds, react
33469
33749
  });
33470
33750
  if (!hasReactiveDependency) {
33471
33751
  emitWarning(
33472
- path2.node,
33752
+ callNode,
33473
33753
  "FICT-E001",
33474
33754
  "Effect has no reactive reads; it will run once. Consider removing $effect or adding dependencies.",
33475
33755
  warn,
@@ -33492,6 +33772,7 @@ function runWarningPass(programPath, stateBindingIds, stateRootBindingIds, react
33492
33772
  const isSafe = calleeName && SAFE_FUNCTIONS.has(calleeName);
33493
33773
  if (isSafe) return;
33494
33774
  const argPaths = path2.get("arguments");
33775
+ const nonEscapingCallbackHost = isNonEscapingCallbackHost(callee);
33495
33776
  for (const argPath of argPaths) {
33496
33777
  if (argPath.isIdentifier() && hasTrackedBinding(argPath, argPath.node.name, stateBindingIds)) {
33497
33778
  continue;
@@ -33507,6 +33788,13 @@ function runWarningPass(programPath, stateBindingIds, stateRootBindingIds, react
33507
33788
  break;
33508
33789
  }
33509
33790
  }
33791
+ if (nonEscapingCallbackHost) return;
33792
+ for (const argPath of argPaths) {
33793
+ const captured = collectCapturedForArgument(argPath);
33794
+ if (!captured) continue;
33795
+ emitClosureCaptureWarning(argPath.node, captured);
33796
+ break;
33797
+ }
33510
33798
  },
33511
33799
  OptionalMemberExpression(path2) {
33512
33800
  if (!path2.node.computed) return;
@@ -34371,6 +34659,8 @@ or extract the nested logic into a custom hook (useXxx).`
34371
34659
  stateBindingIds,
34372
34660
  stateRootBindingIds,
34373
34661
  reactiveBindingIds,
34662
+ stateMacroNames,
34663
+ memoMacroNames,
34374
34664
  effectMacroNames,
34375
34665
  warn,
34376
34666
  fileName,
@@ -34415,13 +34705,17 @@ var createFictPlugin = declare(
34415
34705
  (api, options = {}) => {
34416
34706
  api.assertVersion(7);
34417
34707
  const t4 = api.types;
34708
+ const strictGuaranteeFromEnv = readBooleanEnv("FICT_STRICT_GUARANTEE") === true;
34418
34709
  const normalizedOptions = {
34419
34710
  ...options,
34420
34711
  fineGrainedDom: options.fineGrainedDom ?? true,
34712
+ lazyConditional: options.lazyConditional ?? true,
34713
+ getterCache: options.getterCache ?? true,
34421
34714
  optimize: options.optimize ?? true,
34422
34715
  optimizeLevel: options.optimizeLevel ?? "safe",
34423
34716
  inlineDerivedMemos: options.inlineDerivedMemos ?? true,
34424
34717
  emitModuleMetadata: options.emitModuleMetadata ?? "auto",
34718
+ strictGuarantee: strictGuaranteeFromEnv || options.strictGuarantee !== false,
34425
34719
  dev: options.dev ?? (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test")
34426
34720
  };
34427
34721
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fictjs/compiler",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Babel plugin for Fict Compiler",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -48,7 +48,7 @@
48
48
  "@types/babel__helper-plugin-utils": "^7.10.3",
49
49
  "@types/babel__traverse": "^7.28.0",
50
50
  "tsup": "^8.5.1",
51
- "@fictjs/runtime": "0.8.0"
51
+ "@fictjs/runtime": "0.9.0"
52
52
  },
53
53
  "scripts": {
54
54
  "build": "tsup src/index.ts --format cjs,esm --dts",