@bamboocss/extractor 1.17.1 → 1.17.3

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 (3) hide show
  1. package/dist/index.cjs +146 -53
  2. package/dist/index.mjs +146 -53
  3. package/package.json +2 -2
package/dist/index.cjs CHANGED
@@ -306,76 +306,169 @@ const box = {
306
306
  }
307
307
  };
308
308
  //#endregion
309
- //#region src/evaluate-node.ts
310
- const TsEvalError = Symbol("EvalError");
311
- const cacheMap$2 = /* @__PURE__ */ new WeakMap();
309
+ //#region src/resolve-imported-value.ts
310
+ /**
311
+ * The values an expression borrows from other modules, resolved by reading the imports.
312
+ *
313
+ * A call to a helper in a neighbouring file has to resolve, or its declarations are silently
314
+ * absent from the stylesheet — and for a recipe that is not a partial loss but a different
315
+ * hash and an element with no styles at all.
316
+ *
317
+ * The evaluator can be handed a TypeScript type checker to follow the import itself, and was
318
+ * for a while. That is far more than the job needs: asking for one symbol makes TypeScript
319
+ * bind and check the whole program, every reachable `.d.ts` included. Measured on a 400-file
320
+ * project it cost **+38% CPU and +30% peak RSS**, and because the expense is the checker
321
+ * existing rather than how often it is consulted, it grows with the size of the codebase
322
+ * rather than with the number of style calls — a large project reported a 5.4x extraction
323
+ * slowdown and an OOM in CI.
324
+ *
325
+ * Following an import is two cheap steps this package already had: resolve the specifier to
326
+ * a file (`ts.resolveModuleName`, path lookup, no checker), then read that file's exported
327
+ * declaration. Crossing the import boundary is the only thing the checker was doing here.
328
+ *
329
+ * The evaluator resolves everything *within* a module by walking scopes, so a helper that
330
+ * refers to another binding in its own file needs nothing from us.
331
+ */
332
+ /** How far a chain of helpers importing helpers is followed. */
333
+ const MAX_DEPTH = 4;
334
+ /** Evaluated once per declaration, however many call sites reach it. */
335
+ const evaluatedValues = /* @__PURE__ */ new WeakMap();
312
336
  /**
313
- * Whether a call reaches a function declared outside this project.
337
+ * Local name to the name and declaration it was imported from.
314
338
  *
315
- * Passing a type checker lets the evaluator resolve an identifier to its declaration in
316
- * another module, which is what makes a call to an imported helper resolvable at all a
317
- * style helper in a neighbouring file used to come back unresolvable, and for a recipe that
318
- * is not a partial loss but a different config, a different hash, and an element with no
319
- * styles.
339
+ * Deliberately not cached per file. The nodes are invalidated whenever a file is reloaded —
340
+ * a watch rebuild does exactly that and a cache holding them hands back nodes ts-morph has
341
+ * forgotten, which throws rather than merely going stale. Rebuilding is a syntactic walk of
342
+ * the import statements, and it only happens for an expression that already failed to
343
+ * evaluate, so it is not on the ordinary path.
344
+ */
345
+ const importBindingsFor = (sourceFile) => {
346
+ const bindings = /* @__PURE__ */ new Map();
347
+ for (const declaration of sourceFile.getImportDeclarations()) {
348
+ for (const specifier of declaration.getNamedImports()) {
349
+ const local = (specifier.getAliasNode() ?? specifier.getNameNode()).getText();
350
+ bindings.set(local, {
351
+ declaration,
352
+ exportedName: specifier.getNameNode().getText()
353
+ });
354
+ }
355
+ const defaultImport = declaration.getDefaultImport();
356
+ if (defaultImport) bindings.set(defaultImport.getText(), {
357
+ declaration,
358
+ exportedName: "default"
359
+ });
360
+ }
361
+ return bindings;
362
+ };
363
+ /** What an imported name refers to, or nothing if it cannot be reached safely. */
364
+ const valueForBinding = (binding, ctx, stack, depth, evaluateExpression) => {
365
+ const sourceFile = getModuleSpecifierSourceFile(binding.declaration);
366
+ if (!sourceFile) return;
367
+ if (sourceFile.isInNodeModules()) return;
368
+ const declaration = getExportedVarDeclarationWithName(binding.exportedName, sourceFile, stack, ctx);
369
+ if (!declaration) return;
370
+ if (evaluatedValues.has(declaration)) return { value: evaluatedValues.get(declaration) };
371
+ const initializer = declaration.getInitializer();
372
+ if (!initializer) return;
373
+ const value = evaluateExpression(initializer, stack, ctx, depth + 1);
374
+ if (value === void 0) return;
375
+ evaluatedValues.set(declaration, value);
376
+ return { value };
377
+ };
378
+ /**
379
+ * Bindings for the names this expression takes from other modules, or nothing if it takes
380
+ * none that can be reached.
320
381
  *
321
- * It also means evaluating whatever it resolves to, so the project boundary is where that
322
- * stops. A dependency's code is not ours to run at build time, however pure it looks, and
323
- * declining leaves exactly the behaviour that shipped before the checker was passed.
382
+ * Called only after an evaluation has already failed. Everything that resolves today does so
383
+ * without this, and an expression that reaches an unresolvable import used to be dropped
384
+ * outright so nothing that works pays for it, and nothing that pays for it was working.
324
385
  */
325
- const resolvesWithinProject = (node) => {
326
- if (!ts_morph.Node.isCallExpression(node)) return false;
327
- const symbol = node.getExpression().getSymbol();
328
- if (!symbol) return false;
329
- const declarations = (symbol.getAliasedSymbol() ?? symbol).getDeclarations();
330
- if (!declarations.length) return false;
331
- return !declarations.some((declaration) => declaration.getSourceFile().isInNodeModules());
386
+ const importedEnvironmentFor = (node, ctx, stack, depth, evaluateExpression) => {
387
+ if (depth >= MAX_DEPTH) return;
388
+ const calls = node.getDescendantsOfKind(ts_morph.SyntaxKind.CallExpression);
389
+ if (ts_morph.Node.isCallExpression(node)) calls.unshift(node);
390
+ if (!calls.length) return;
391
+ const bindings = importBindingsFor(node.getSourceFile());
392
+ if (!bindings.size) return;
393
+ let environment;
394
+ for (const call of calls) {
395
+ const callee = call.getExpression();
396
+ const binding = bindings.get(callee.getText());
397
+ if (!binding || environment?.[callee.getText()] !== void 0) continue;
398
+ const resolved = valueForBinding(binding, ctx, stack, depth, evaluateExpression);
399
+ if (!resolved) continue;
400
+ environment ??= {};
401
+ environment[callee.getText()] = resolved.value;
402
+ }
403
+ return environment;
332
404
  };
333
- /** One per project. `getTypeChecker()` is cheap, but this runs per evaluated call. */
334
- const typeCheckers = /* @__PURE__ */ new WeakMap();
335
- const typeCheckerFor = (node) => {
336
- const project = node.getProject();
337
- let checker = typeCheckers.get(project);
338
- if (!checker) {
339
- checker = project.getTypeChecker().compilerObject;
340
- typeCheckers.set(project, checker);
405
+ //#endregion
406
+ //#region src/evaluate-node.ts
407
+ const TsEvalError = Symbol("EvalError");
408
+ const cacheMap$2 = /* @__PURE__ */ new WeakMap();
409
+ /** @see https://github.com/wessberg/ts-evaluator#setting-up-policies */
410
+ const POLICY = {
411
+ console: false,
412
+ deterministic: true,
413
+ io: {
414
+ read: true,
415
+ write: false
416
+ },
417
+ maxOpDuration: 1e3,
418
+ maxOps: Number.POSITIVE_INFINITY,
419
+ network: false,
420
+ process: {
421
+ exit: false,
422
+ spawnChild: false
341
423
  }
342
- return checker;
343
424
  };
344
425
  /**
345
- * Evaluates a node with strict policies restrictions
346
- * @see https://github.com/wessberg/ts-evaluator#setting-up-policies
426
+ * Evaluates a node with strict policy restrictions.
427
+ *
428
+ * No type checker is passed. The evaluator accepts one, and it is the obvious way to let a
429
+ * call reach a helper in another module — but asking TypeScript for a single symbol makes it
430
+ * bind and check the entire program, `.d.ts` files included. That cost is paid by every
431
+ * project on every build, scales with the size of the codebase rather than with the number
432
+ * of style calls, and does not show up in a benchmark small enough to have no program to
433
+ * check. See `resolve-imported-value.ts`, which crosses the import boundary directly for the
434
+ * few expressions that need it.
347
435
  */
348
- const evaluateNode = (node, stack, ctx) => {
436
+ const evaluateNode = (node, stack, ctx, depth = 0) => {
349
437
  if (ctx.flags?.skipEvaluate) return;
350
438
  if (ctx.canEval && !ctx.canEval?.(node, stack)) return;
351
- if (cacheMap$2.has(node)) return cacheMap$2.get(node);
352
- const result = (0, ts_evaluator.evaluate)({
353
- ...resolvesWithinProject(node) ? { typeChecker: typeCheckerFor(node) } : {},
354
- policy: {
355
- deterministic: true,
356
- network: false,
357
- console: false,
358
- maxOps: Number.POSITIVE_INFINITY,
359
- maxOpDuration: 1e3,
360
- io: {
361
- read: true,
362
- write: false
363
- },
364
- process: {
365
- exit: false,
366
- spawnChild: false
367
- }
368
- },
439
+ if (depth === 0 && cacheMap$2.has(node)) return cacheMap$2.get(node);
440
+ const options = {
441
+ policy: { ...POLICY },
369
442
  ...ctx.getEvaluateOptions?.(node, stack),
370
443
  node: node.compilerNode,
371
444
  typescript: ts_morph.ts
372
- });
445
+ };
446
+ let result = (0, ts_evaluator.evaluate)(options);
447
+ /**
448
+ * Retried only on failure, with whatever this expression imports put in scope.
449
+ *
450
+ * The order matters for cost. Everything that resolves today does so on the first attempt,
451
+ * so no working call pays anything for this — and an expression that reaches an
452
+ * unresolvable import was dropped outright before, so nothing that pays for it was
453
+ * working. Resolving imports up front instead would put a walk of every style object on
454
+ * the path of every build.
455
+ */
456
+ if (!result.success) {
457
+ const imported = importedEnvironmentFor(node, ctx, stack, depth, safeEvaluateNode);
458
+ if (imported) result = (0, ts_evaluator.evaluate)({
459
+ ...options,
460
+ environment: {
461
+ ...options.environment,
462
+ extra: imported
463
+ }
464
+ });
465
+ }
373
466
  const expr = result.success ? result.value : TsEvalError;
374
- cacheMap$2.set(node, expr);
467
+ if (depth === 0) cacheMap$2.set(node, expr);
375
468
  return expr;
376
469
  };
377
- const safeEvaluateNode = (node, stack, ctx) => {
378
- const result = evaluateNode(node, stack, ctx);
470
+ const safeEvaluateNode = (node, stack, ctx, depth = 0) => {
471
+ const result = evaluateNode(node, stack, ctx, depth);
379
472
  if (result === TsEvalError) return;
380
473
  return result;
381
474
  };
package/dist/index.mjs CHANGED
@@ -305,76 +305,169 @@ const box = {
305
305
  }
306
306
  };
307
307
  //#endregion
308
- //#region src/evaluate-node.ts
309
- const TsEvalError = Symbol("EvalError");
310
- const cacheMap$2 = /* @__PURE__ */ new WeakMap();
308
+ //#region src/resolve-imported-value.ts
309
+ /**
310
+ * The values an expression borrows from other modules, resolved by reading the imports.
311
+ *
312
+ * A call to a helper in a neighbouring file has to resolve, or its declarations are silently
313
+ * absent from the stylesheet — and for a recipe that is not a partial loss but a different
314
+ * hash and an element with no styles at all.
315
+ *
316
+ * The evaluator can be handed a TypeScript type checker to follow the import itself, and was
317
+ * for a while. That is far more than the job needs: asking for one symbol makes TypeScript
318
+ * bind and check the whole program, every reachable `.d.ts` included. Measured on a 400-file
319
+ * project it cost **+38% CPU and +30% peak RSS**, and because the expense is the checker
320
+ * existing rather than how often it is consulted, it grows with the size of the codebase
321
+ * rather than with the number of style calls — a large project reported a 5.4x extraction
322
+ * slowdown and an OOM in CI.
323
+ *
324
+ * Following an import is two cheap steps this package already had: resolve the specifier to
325
+ * a file (`ts.resolveModuleName`, path lookup, no checker), then read that file's exported
326
+ * declaration. Crossing the import boundary is the only thing the checker was doing here.
327
+ *
328
+ * The evaluator resolves everything *within* a module by walking scopes, so a helper that
329
+ * refers to another binding in its own file needs nothing from us.
330
+ */
331
+ /** How far a chain of helpers importing helpers is followed. */
332
+ const MAX_DEPTH = 4;
333
+ /** Evaluated once per declaration, however many call sites reach it. */
334
+ const evaluatedValues = /* @__PURE__ */ new WeakMap();
311
335
  /**
312
- * Whether a call reaches a function declared outside this project.
336
+ * Local name to the name and declaration it was imported from.
313
337
  *
314
- * Passing a type checker lets the evaluator resolve an identifier to its declaration in
315
- * another module, which is what makes a call to an imported helper resolvable at all a
316
- * style helper in a neighbouring file used to come back unresolvable, and for a recipe that
317
- * is not a partial loss but a different config, a different hash, and an element with no
318
- * styles.
338
+ * Deliberately not cached per file. The nodes are invalidated whenever a file is reloaded —
339
+ * a watch rebuild does exactly that and a cache holding them hands back nodes ts-morph has
340
+ * forgotten, which throws rather than merely going stale. Rebuilding is a syntactic walk of
341
+ * the import statements, and it only happens for an expression that already failed to
342
+ * evaluate, so it is not on the ordinary path.
343
+ */
344
+ const importBindingsFor = (sourceFile) => {
345
+ const bindings = /* @__PURE__ */ new Map();
346
+ for (const declaration of sourceFile.getImportDeclarations()) {
347
+ for (const specifier of declaration.getNamedImports()) {
348
+ const local = (specifier.getAliasNode() ?? specifier.getNameNode()).getText();
349
+ bindings.set(local, {
350
+ declaration,
351
+ exportedName: specifier.getNameNode().getText()
352
+ });
353
+ }
354
+ const defaultImport = declaration.getDefaultImport();
355
+ if (defaultImport) bindings.set(defaultImport.getText(), {
356
+ declaration,
357
+ exportedName: "default"
358
+ });
359
+ }
360
+ return bindings;
361
+ };
362
+ /** What an imported name refers to, or nothing if it cannot be reached safely. */
363
+ const valueForBinding = (binding, ctx, stack, depth, evaluateExpression) => {
364
+ const sourceFile = getModuleSpecifierSourceFile(binding.declaration);
365
+ if (!sourceFile) return;
366
+ if (sourceFile.isInNodeModules()) return;
367
+ const declaration = getExportedVarDeclarationWithName(binding.exportedName, sourceFile, stack, ctx);
368
+ if (!declaration) return;
369
+ if (evaluatedValues.has(declaration)) return { value: evaluatedValues.get(declaration) };
370
+ const initializer = declaration.getInitializer();
371
+ if (!initializer) return;
372
+ const value = evaluateExpression(initializer, stack, ctx, depth + 1);
373
+ if (value === void 0) return;
374
+ evaluatedValues.set(declaration, value);
375
+ return { value };
376
+ };
377
+ /**
378
+ * Bindings for the names this expression takes from other modules, or nothing if it takes
379
+ * none that can be reached.
319
380
  *
320
- * It also means evaluating whatever it resolves to, so the project boundary is where that
321
- * stops. A dependency's code is not ours to run at build time, however pure it looks, and
322
- * declining leaves exactly the behaviour that shipped before the checker was passed.
381
+ * Called only after an evaluation has already failed. Everything that resolves today does so
382
+ * without this, and an expression that reaches an unresolvable import used to be dropped
383
+ * outright so nothing that works pays for it, and nothing that pays for it was working.
323
384
  */
324
- const resolvesWithinProject = (node) => {
325
- if (!Node.isCallExpression(node)) return false;
326
- const symbol = node.getExpression().getSymbol();
327
- if (!symbol) return false;
328
- const declarations = (symbol.getAliasedSymbol() ?? symbol).getDeclarations();
329
- if (!declarations.length) return false;
330
- return !declarations.some((declaration) => declaration.getSourceFile().isInNodeModules());
385
+ const importedEnvironmentFor = (node, ctx, stack, depth, evaluateExpression) => {
386
+ if (depth >= MAX_DEPTH) return;
387
+ const calls = node.getDescendantsOfKind(SyntaxKind.CallExpression);
388
+ if (Node.isCallExpression(node)) calls.unshift(node);
389
+ if (!calls.length) return;
390
+ const bindings = importBindingsFor(node.getSourceFile());
391
+ if (!bindings.size) return;
392
+ let environment;
393
+ for (const call of calls) {
394
+ const callee = call.getExpression();
395
+ const binding = bindings.get(callee.getText());
396
+ if (!binding || environment?.[callee.getText()] !== void 0) continue;
397
+ const resolved = valueForBinding(binding, ctx, stack, depth, evaluateExpression);
398
+ if (!resolved) continue;
399
+ environment ??= {};
400
+ environment[callee.getText()] = resolved.value;
401
+ }
402
+ return environment;
331
403
  };
332
- /** One per project. `getTypeChecker()` is cheap, but this runs per evaluated call. */
333
- const typeCheckers = /* @__PURE__ */ new WeakMap();
334
- const typeCheckerFor = (node) => {
335
- const project = node.getProject();
336
- let checker = typeCheckers.get(project);
337
- if (!checker) {
338
- checker = project.getTypeChecker().compilerObject;
339
- typeCheckers.set(project, checker);
404
+ //#endregion
405
+ //#region src/evaluate-node.ts
406
+ const TsEvalError = Symbol("EvalError");
407
+ const cacheMap$2 = /* @__PURE__ */ new WeakMap();
408
+ /** @see https://github.com/wessberg/ts-evaluator#setting-up-policies */
409
+ const POLICY = {
410
+ console: false,
411
+ deterministic: true,
412
+ io: {
413
+ read: true,
414
+ write: false
415
+ },
416
+ maxOpDuration: 1e3,
417
+ maxOps: Number.POSITIVE_INFINITY,
418
+ network: false,
419
+ process: {
420
+ exit: false,
421
+ spawnChild: false
340
422
  }
341
- return checker;
342
423
  };
343
424
  /**
344
- * Evaluates a node with strict policies restrictions
345
- * @see https://github.com/wessberg/ts-evaluator#setting-up-policies
425
+ * Evaluates a node with strict policy restrictions.
426
+ *
427
+ * No type checker is passed. The evaluator accepts one, and it is the obvious way to let a
428
+ * call reach a helper in another module — but asking TypeScript for a single symbol makes it
429
+ * bind and check the entire program, `.d.ts` files included. That cost is paid by every
430
+ * project on every build, scales with the size of the codebase rather than with the number
431
+ * of style calls, and does not show up in a benchmark small enough to have no program to
432
+ * check. See `resolve-imported-value.ts`, which crosses the import boundary directly for the
433
+ * few expressions that need it.
346
434
  */
347
- const evaluateNode = (node, stack, ctx) => {
435
+ const evaluateNode = (node, stack, ctx, depth = 0) => {
348
436
  if (ctx.flags?.skipEvaluate) return;
349
437
  if (ctx.canEval && !ctx.canEval?.(node, stack)) return;
350
- if (cacheMap$2.has(node)) return cacheMap$2.get(node);
351
- const result = evaluate({
352
- ...resolvesWithinProject(node) ? { typeChecker: typeCheckerFor(node) } : {},
353
- policy: {
354
- deterministic: true,
355
- network: false,
356
- console: false,
357
- maxOps: Number.POSITIVE_INFINITY,
358
- maxOpDuration: 1e3,
359
- io: {
360
- read: true,
361
- write: false
362
- },
363
- process: {
364
- exit: false,
365
- spawnChild: false
366
- }
367
- },
438
+ if (depth === 0 && cacheMap$2.has(node)) return cacheMap$2.get(node);
439
+ const options = {
440
+ policy: { ...POLICY },
368
441
  ...ctx.getEvaluateOptions?.(node, stack),
369
442
  node: node.compilerNode,
370
443
  typescript: ts
371
- });
444
+ };
445
+ let result = evaluate(options);
446
+ /**
447
+ * Retried only on failure, with whatever this expression imports put in scope.
448
+ *
449
+ * The order matters for cost. Everything that resolves today does so on the first attempt,
450
+ * so no working call pays anything for this — and an expression that reaches an
451
+ * unresolvable import was dropped outright before, so nothing that pays for it was
452
+ * working. Resolving imports up front instead would put a walk of every style object on
453
+ * the path of every build.
454
+ */
455
+ if (!result.success) {
456
+ const imported = importedEnvironmentFor(node, ctx, stack, depth, safeEvaluateNode);
457
+ if (imported) result = evaluate({
458
+ ...options,
459
+ environment: {
460
+ ...options.environment,
461
+ extra: imported
462
+ }
463
+ });
464
+ }
372
465
  const expr = result.success ? result.value : TsEvalError;
373
- cacheMap$2.set(node, expr);
466
+ if (depth === 0) cacheMap$2.set(node, expr);
374
467
  return expr;
375
468
  };
376
- const safeEvaluateNode = (node, stack, ctx) => {
377
- const result = evaluateNode(node, stack, ctx);
469
+ const safeEvaluateNode = (node, stack, ctx, depth = 0) => {
470
+ const result = evaluateNode(node, stack, ctx, depth);
378
471
  if (result === TsEvalError) return;
379
472
  return result;
380
473
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bamboocss/extractor",
3
- "version": "1.17.1",
3
+ "version": "1.17.3",
4
4
  "description": "The css extractor for css bamboo",
5
5
  "homepage": "https://bamboocss.com",
6
6
  "license": "MIT",
@@ -35,7 +35,7 @@
35
35
  "dependencies": {
36
36
  "ts-evaluator": "1.2.0",
37
37
  "ts-morph": "28.0.0",
38
- "@bamboocss/shared": "1.17.1"
38
+ "@bamboocss/shared": "1.17.3"
39
39
  },
40
40
  "scripts": {
41
41
  "build": "tsdown src/index.ts --format=cjs,esm --shims --dts",