@ata-project/unplugin 0.5.0 → 0.7.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
@@ -151,6 +151,7 @@ Other sources (`.json` without the `.schema` suffix, `.js`, `.ts`) produce
151
151
  | `root` | from the bundler | Where the globs resolve from. Vite's root, Webpack's and Rspack's `context` and esbuild's `absWorkingDir` are read; Rollup and Rolldown need it passed. |
152
152
  | `alias` | Vite's `resolve.alias` | Import aliases inside `.ts` schema files. tsconfig `paths` work without configuration. |
153
153
  | `compileAway` | `true` | Replace `new Validator(schema)` in your code with a validator compiled at build time, where that gives the same results. See below. Needs ata-validator 1.36.0; with an older version the runtime stays, and a warning is printed only if you set the option yourself. |
154
+ | `sharedErrors` | `'auto'` | Where a compiled validator's error detail comes from. A compact module carries its verdict and its schema and takes its errors from `ata-validator/error-runtime`, which the bundle holds once. `'auto'` scans the project's sources at build start (skipping `node_modules`, build output and test files), adds up what every replaceable call would save, and uses the runtime when the total pays for it; a file the scan did not see decides alone, which takes a schema of several hundred properties. Measured on projects of 1 to 40 SchemaStore schemas, no bundle came out bigger than with `false`, and 40 schemas went from 724 KB to 405 KB gzipped, on ata-validator 1.51.0. `true` writes every schema that allows it compact, and `false` never. The scan adds little to the build, measured in separate processes with esbuild: 84 to 85 ms for three validators, 681 to 715 ms for forty. Errors and verdicts are the same either way; a rejected document costs more, since its errors come from an interpreted walk. Needs ata-validator 1.50.0; with an older one every module stays self-contained, as before. |
154
155
 
155
156
  ## compileAway: keep `new Validator`, drop the compiler
156
157
 
@@ -179,7 +180,8 @@ away this way.
179
180
 
180
181
  Where the code only ever calls `isValidObject()` or `isValidJSON()`, the plugin
181
182
  uses a smaller wrapper without the error machinery (needs ata-validator 1.40.0):
182
- a small app that only asks for a boolean bundles to 2.1 KB gzipped.
183
+ a small app that only asks for a boolean bundles to 2.6 KB gzipped, against 12.1 KB with
184
+ the full wrapper and 102.0 KB for the runtime, on 0.5.0 with ata-validator 1.40.0.
183
185
 
184
186
  The replacement answers `validate()`, `isValidObject()`, `validateJSON()` and
185
187
  `isValidJSON()` as a `Validator` with default options does: the same verdicts,
@@ -194,7 +196,11 @@ otherwise:
194
196
  - `Validator` is a named import from `ata-validator`;
195
197
  - the schema argument is an object literal, a top-level `const` bound to one,
196
198
  the default import of a relative `.json` file, or `defineSchema(...)` around
197
- one of those;
199
+ one of those; calls to the `t` builder from `ata-validator/t` whose arguments are
200
+ such values are run at build time, since the builder returns plain JSON Schema,
201
+ and are marked pure so a schema only the replaced call used leaves the bundle
202
+ with the builder (`t.refine` and `t.recursive`, which take a function, stay on
203
+ the runtime);
198
204
  - there is no second argument, or it is `{ useDefaults: false }` (or
199
205
  `{ useDefaults: true }`), written as a literal or a top-level `const`; that
200
206
  one option needs ata-validator 1.37.0, and older versions keep such calls on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ata-project/unplugin",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Build-time JSON Schema for Vite, Webpack, Rollup, Rolldown, esbuild and Rspack. compileAway takes the runtime compiler out of the bundle.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -79,7 +79,7 @@
79
79
  "devDependencies": {
80
80
  "@ata-project/keywords": "^0.3.3",
81
81
  "@rspack/core": "^1.7.0",
82
- "ata-validator": "^1.40.0",
82
+ "ata-validator": "^1.51.0",
83
83
  "esbuild": "^0.28.0",
84
84
  "rolldown": "^1.2.8",
85
85
  "rollup": "^4.60.0",
@@ -24,11 +24,15 @@
24
24
 
25
25
  import fs from 'node:fs'
26
26
  import path from 'node:path'
27
+ import zlib from 'node:zlib'
27
28
  import { parse } from '@babel/parser'
28
29
  import MagicString from 'magic-string'
29
30
 
30
31
  const METHODS = new Set(['validate', 'isValidObject', 'validateJSON', 'isValidJSON'])
31
32
  const NOT_STATIC = Symbol('not static')
33
+ // The builders of ata-validator/t that take only values. refine and
34
+ // recursive take a function and stay on the runtime.
35
+ const T_PURE = new Set(['string', 'number', 'integer', 'boolean', 'null', 'literal', 'const', 'enum', 'array', 'tuple', 'record', 'object', 'optional', 'union', 'intersect', 'ref', 'any', 'unknown', 'never', 'pick', 'omit', 'partial', 'required', 'composite'])
32
36
 
33
37
  // Keys that hold child nodes. Types are left out on purpose: they are erased
34
38
  // before anything runs, so a name used in a type annotation says nothing about
@@ -134,9 +138,30 @@ function staticValue(node, ctx, seen = new Set()) {
134
138
  seen.delete(node.name)
135
139
  return v
136
140
  }
137
- case 'CallExpression':
141
+ case 'CallExpression': {
138
142
  if (node.callee.type === 'Identifier' && node.callee.name === ctx.defineSchema && node.arguments.length === 1) return staticValue(node.arguments[0], ctx, seen)
143
+ // `t.object(...)` and the rest of the builder from ata-validator/t. It
144
+ // returns plain JSON Schema and holds no state, so a call whose
145
+ // arguments the build can read gives here what it gives at run time.
146
+ // The builders that take a function are left out.
147
+ const c = node.callee
148
+ if (ctx.t && ctx.tName && c.type === 'MemberExpression' && !c.computed && c.object.type === 'Identifier' &&
149
+ c.object.name === ctx.tName && ctx.declared.get(ctx.tName) === 1 && c.property.type === 'Identifier' && T_PURE.has(c.property.name) &&
150
+ typeof ctx.t[c.property.name] === 'function') {
151
+ const args = []
152
+ for (const a of node.arguments) {
153
+ if (a.type === 'SpreadElement') return NOT_STATIC
154
+ const v = staticValue(a, ctx, seen)
155
+ if (v === NOT_STATIC) return NOT_STATIC
156
+ args.push(v)
157
+ }
158
+ let v
159
+ try { v = ctx.t[c.property.name](...args) } catch { return NOT_STATIC }
160
+ ctx.tCalls.add(node)
161
+ return v
162
+ }
139
163
  return NOT_STATIC
164
+ }
140
165
  }
141
166
  return NOT_STATIC
142
167
  }
@@ -198,8 +223,84 @@ const verdictOnly = (methods) => [...methods].every((m) => VERDICT_METHODS.has(m
198
223
 
199
224
  // A verdict-only module hands out isValid alone, so the error function inside
200
225
  // it is unreferenced and the bundler drops it.
226
+ // A compact module takes its errors from this runtime, which a bundle holds
227
+ // once. A module is written compact only when it alone saves more than the
228
+ // runtime adds, so the choice never makes a bundle bigger, whatever else the
229
+ // bundle holds and in whatever order files are transformed. The bar is the
230
+ // module's gzipped saving as this transform sees it (unminified source), and
231
+ // it was set from minified esbuild bundles of one schema each: the runtime
232
+ // paid for itself from about 22000 bytes of that saving (a 300-property
233
+ // schema lost 1826 bytes at 19430, a 400-property one gained 1937 at 25127).
234
+ // Below it, a schema stays self-contained; `sharedErrors: true` writes every
235
+ // module compact, for an application with many schemas.
236
+ const ERROR_RUNTIME = 'ata-validator/error-runtime'
237
+ const ERROR_RUNTIME_COST = 24000
238
+ const RUNTIME_IMPORT = /^import \{ createErrors as _ataCreateErrors(?:, compileSafe as _ataCompileSafe)? \} from [^\n]*\n/m
239
+
240
+ // The scan and the transform weigh the same modules, and the module sources
241
+ // come back as the same strings from the session's compile cache: each is
242
+ // gzipped once per build (clearSizes runs at build start).
243
+ const sizes = new Map()
244
+ // The scan parses every source the transform parses again: once per build.
245
+ const asts = new Map()
246
+ function gzipSize(src) {
247
+ let n = sizes.get(src)
248
+ if (n === undefined) { n = zlib.gzipSync(src).length; if (sizes.size > 4096) sizes.clear(); sizes.set(src, n) }
249
+ return n
250
+ }
251
+ export function clearSizes() { sizes.clear(); asts.clear() }
252
+
253
+ // The compact module when it is the choice for `mode`, else null:
254
+ // true wherever the schema allows it
255
+ // 'smaller' where it is smaller than the self-contained module (the build
256
+ // scan found that the application as a whole pays for the runtime)
257
+ // 'auto' where this module alone pays for the runtime
258
+ // false never
259
+ // With `collect`, nothing is chosen: the saving is recorded for the scan.
260
+ function compactModuleFor(schema, full, ata, mode, collect) {
261
+ if (mode === false || !ata.compiledSharedErrors) return null
262
+ let src = null
263
+ try { src = ata.compiledModuleFor(schema, { format: 'esm', errorRuntime: ERROR_RUNTIME }) } catch { src = null }
264
+ if (!src || !RUNTIME_IMPORT.test(src)) return null
265
+ if (mode === true) return src
266
+ // The scan collects the pairs and weighs them all at once, in parallel
267
+ // (sharedErrorsPay); the transform finds the sizes cached.
268
+ if (collect) { collect.push([full, src]); return null }
269
+ const saving = gzipSize(full) - gzipSize(src)
270
+ if (mode === 'smaller') return saving > 0 ? src : null
271
+ return saving > ERROR_RUNTIME_COST ? src : null
272
+ }
273
+
274
+ // Whether an application pays for the shared runtime: the saving of every
275
+ // replaceable call in its sources, against the runtime's cost. `sources` is a
276
+ // list of [code, id]. A call the transform would not replace adds nothing.
277
+ // Gzipping a module of a hundred kilobytes is the scan's largest cost; the
278
+ // asynchronous zlib runs on libuv's thread pool, so the modules of a build
279
+ // are weighed in parallel rather than one after the other.
280
+ function gzipSizeAsync(src) {
281
+ const n = sizes.get(src)
282
+ if (n !== undefined) return Promise.resolve(n)
283
+ return new Promise((resolve, reject) => zlib.gzip(src, (err, out) => {
284
+ if (err) { reject(err); return }
285
+ if (sizes.size > 4096) sizes.clear()
286
+ sizes.set(src, out.length)
287
+ resolve(out.length)
288
+ }))
289
+ }
290
+
291
+ export async function sharedErrorsPay(sources, ata) {
292
+ const collect = []
293
+ for (const [code, id] of sources) {
294
+ try { compileAway(code, id, ata, { sharedErrors: 'auto', collect }) } catch {}
295
+ }
296
+ const savings = await Promise.all(collect.map(async ([full, compact]) => (await gzipSizeAsync(full)) - (await gzipSizeAsync(compact))))
297
+ let saving = 0
298
+ for (const x of savings) if (x > 0) saving += x
299
+ return { saving, pays: saving > ERROR_RUNTIME_COST, calls: collect.length }
300
+ }
301
+
201
302
  function inlineModule(src, index, verdict) {
202
- const body = src.split('\n').filter((l) => !/^export\s/.test(l)).join('\n')
303
+ const body = src.replace(RUNTIME_IMPORT, '').split('\n').filter((l) => !/^export\s/.test(l)).join('\n')
203
304
  return `const __ataCompiled${index} = (() => {\n${body}\nreturn ${verdict ? '{ isValid }' : '{ validate, isValid }'};\n})();\n`
204
305
  }
205
306
 
@@ -218,16 +319,26 @@ function compiledOptionsArg(node, ctx, supported) {
218
319
  return opts.useDefaults === false ? '{"useDefaults":false}' : ''
219
320
  }
220
321
 
221
- export function compileAway(code, id, ata) {
322
+ export function compileAway(code, id, ata, options) {
323
+ const sharedErrors = options && options.sharedErrors !== undefined ? options.sharedErrors : 'auto'
324
+ const collect = options && Array.isArray(options.collect) ? options.collect : null
222
325
  const { compiledModuleFor, compiledSchemaFor } = ata
223
326
  const supportedOptions = Array.isArray(ata.compiledOptions) ? ata.compiledOptions : []
224
327
  if (!code.includes('ata-validator')) return null
225
- let ast
226
- try {
227
- ast = parse(code, { sourceType: 'module', plugins: ['typescript', 'jsx'], errorRecovery: false })
228
- } catch {
229
- return null
328
+ // The scan keeps the tree it parsed for the transform, which takes it and
329
+ // lets it go, so a long dev session holds no old versions of a file.
330
+ let ast = asts.get(code)
331
+ if (ast !== undefined) {
332
+ if (!collect) asts.delete(code)
333
+ } else {
334
+ try {
335
+ ast = parse(code, { sourceType: 'module', plugins: ['typescript', 'jsx'], errorRecovery: false })
336
+ } catch {
337
+ ast = null
338
+ }
339
+ if (collect) asts.set(code, ast)
230
340
  }
341
+ if (ast === null) return null
231
342
  let validatorName = null
232
343
  let importDecl = null
233
344
  let defineSchema = null
@@ -244,6 +355,15 @@ export function compileAway(code, id, ata) {
244
355
  }
245
356
  }
246
357
  if (!validatorName) return null
358
+ let tName = null
359
+ for (const stmt of ast.program.body) {
360
+ if (stmt.type !== 'ImportDeclaration' || stmt.source.value !== 'ata-validator/t' || stmt.importKind === 'type') continue
361
+ for (const sp of stmt.specifiers) {
362
+ if (sp.type !== 'ImportSpecifier' || sp.importKind === 'type') continue
363
+ const imported = sp.imported.type === 'Identifier' ? sp.imported.name : sp.imported.value
364
+ if (imported === 't') tName = sp.local.name
365
+ }
366
+ }
247
367
  let withKeywords = null
248
368
  for (const stmt of ast.program.body) {
249
369
  if (stmt.type !== 'ImportDeclaration' || stmt.source.value !== '@ata-project/keywords' || stmt.importKind === 'type') continue
@@ -256,12 +376,14 @@ export function compileAway(code, id, ata) {
256
376
 
257
377
  const { declared, refs } = scan(ast)
258
378
  if (declared.get(validatorName) !== 1) return null
259
- const ctx = { declared, refs, constants: constantsOf(ast, id), defineSchema, withKeywords, extendChecks: ata.compiledExtendChecks === true }
379
+ const ctx = { declared, refs, constants: constantsOf(ast, id), defineSchema, withKeywords, extendChecks: ata.compiledExtendChecks === true, t: ata.t || null, tName, tCalls: new Set() }
260
380
 
261
381
  const s = new MagicString(code)
262
382
  const modules = []
263
383
  const done = new Set()
264
384
  const wrappers = { full: false, verdict: false }
385
+ let usesRuntime = false
386
+ let usesSafeRe = false
265
387
  for (const r of refs) {
266
388
  if (r.node.name !== validatorName) continue
267
389
  const expr = r.parent
@@ -281,6 +403,10 @@ export function compileAway(code, id, ata) {
281
403
  if (!src) continue
282
404
  const index = modules.length
283
405
  const verdict = ata.compiledVerdict === true && verdictOnly(binding.methods)
406
+ // Code that never reads errors gets the verdict wrapper and no errors at
407
+ // all, so a compact module would only add the runtime.
408
+ const compact = verdict ? null : compactModuleFor(schema, src, ata, sharedErrors, collect)
409
+ if (compact) { src = compact; usesRuntime = true; if (compact.includes('_ataCompileSafe')) usesSafeRe = true }
284
410
  modules.push(inlineModule(src, index, verdict))
285
411
  const wrap = verdict ? '__ataFromCompiledVerdict' : '__ataFromCompiled'
286
412
  if (verdict) wrappers.verdict = true; else wrappers.full = true
@@ -310,8 +436,15 @@ export function compileAway(code, id, ata) {
310
436
  const clause = [def ? text(def) : null, named.length ? `{ ${named.map(text).join(', ')} }` : null].filter(Boolean).join(', ')
311
437
  s.overwrite(importDecl.start, importDecl.end, `import ${clause} from ${code.slice(importDecl.source.start, importDecl.source.end)}`)
312
438
  }
439
+ // The builder calls read here are pure, and marked so: a schema constant
440
+ // only the replaced call used, and the builder with it, can then leave the
441
+ // bundle.
442
+ for (const call of ctx.tCalls) s.appendLeft(call.start, '/*#__PURE__*/ ')
313
443
  if (wrappers.verdict) s.prepend(`import { fromCompiledVerdict as __ataFromCompiledVerdict } from 'ata-validator/compiled-verdict';\n`)
314
444
  if (wrappers.full) s.prepend(`import { fromCompiled as __ataFromCompiled } from 'ata-validator/compiled';\n`)
445
+ // compileSafe is the pattern engine a compact module with a pattern the
446
+ // platform RegExp does not run takes from the runtime instead of embedding.
447
+ if (usesRuntime) s.prepend(`import { createErrors as _ataCreateErrors${usesSafeRe ? ', compileSafe as _ataCompileSafe' : ''} } from '${ERROR_RUNTIME}';\n`)
315
448
  s.appendLeft(lastImportEnd, '\n' + modules.join(''))
316
449
  return { code: s.toString(), map: s.generateMap({ hires: true, source: id, includeContent: true }), replaced: done.size }
317
450
  }
package/src/core.js CHANGED
@@ -74,6 +74,12 @@ async function loadAta() {
74
74
  // withKeywords from @ata-project/keywords registers one; from 1.40.0. Asked
75
75
  // of a wrapper itself, so an ata-validator without it keeps such calls on
76
76
  // the runtime.
77
+ // The ata-validator/t builder, run at build time on calls whose arguments
78
+ // the build can read.
79
+ let t = null
80
+ if (canCompileAway) {
81
+ try { const m = await import('ata-validator/t'); t = (m.default ?? m).t ?? m.t ?? null } catch { t = null }
82
+ }
77
83
  let compiledExtendChecks = false
78
84
  if (canCompileAway) {
79
85
  try {
@@ -82,8 +88,16 @@ async function loadAta() {
82
88
  compiledExtendChecks = typeof fromCompiled({ validate: () => ({ valid: true, errors: [] }), isValid: () => true }, {}, { useDefaults: false })._extendChecks === 'function'
83
89
  } catch { compiledExtendChecks = false }
84
90
  }
91
+ // Compact modules, from ata-validator 1.50.0: the verdict and the schema,
92
+ // with errors from ata-validator/error-runtime, which the bundle then holds
93
+ // once for every compact module in it.
94
+ let compiledSharedErrors = false
95
+ if (canCompileAway) {
96
+ try { compiledSharedErrors = typeof (await import('ata-validator/error-runtime')).createErrors === 'function' } catch { compiledSharedErrors = false }
97
+ }
85
98
  return {
86
99
  ...api,
100
+ compiledSharedErrors,
87
101
  toStandaloneModule: build.toStandaloneModule,
88
102
  compiledModuleFor: canCompileAway ? build.compiledModuleFor : null,
89
103
  compiledSchemaFor: canCompileAway ? build.compiledSchemaFor : null,
@@ -92,6 +106,7 @@ async function loadAta() {
92
106
  compiledOptions: canCompileAway && Array.isArray(build.compiledOptions) ? build.compiledOptions : [],
93
107
  compiledVerdict,
94
108
  compiledExtendChecks,
109
+ t,
95
110
  }
96
111
  }
97
112
 
package/src/index.d.ts CHANGED
@@ -30,6 +30,16 @@ export interface Options {
30
30
  * Default: `true`; `false` turns it off.
31
31
  */
32
32
  compileAway?: boolean
33
+ /**
34
+ * Where a replaced validator's error detail comes from. `'auto'` (default)
35
+ * scans the project's sources at build start and writes modules compact
36
+ * (their verdict and schema, with errors from `ata-validator/error-runtime`,
37
+ * held once by the bundle) when all of them together save more than the
38
+ * runtime adds; `true` writes every module that can be compact; `false`
39
+ * never.
40
+ * Needs ata-validator 1.50.0.
41
+ */
42
+ sharedErrors?: 'auto' | boolean
33
43
  }
34
44
 
35
45
  export interface CompileResult {
package/src/index.js CHANGED
@@ -10,10 +10,50 @@
10
10
  import path from 'node:path'
11
11
  import { createUnplugin } from 'unplugin'
12
12
  import { createSession, normalizeAlias, compile, __internal } from './core.js'
13
- import { compileAway } from './compile-away.js'
13
+ import fs from 'node:fs'
14
+ import { compileAway, sharedErrorsPay, clearSizes } from './compile-away.js'
14
15
 
15
16
  const SOURCE = /\.[cm]?[jt]sx?$/
16
17
 
18
+ // Source files the application may import, for the sharedErrors scan: what
19
+ // the transform would see, without node_modules, build output, tests and
20
+ // dot-directories. Only files that mention ata-validator are kept.
21
+ const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'out', 'coverage', '.git', '.next', '.nuxt', '.output', '.svelte-kit', '.turbo', '.cache'])
22
+ const TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/
23
+ function scanSources(root) {
24
+ const out = []
25
+ const walk = (dir, depth) => {
26
+ if (depth > 12) return
27
+ let entries
28
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
29
+ for (const e of entries) {
30
+ if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue
31
+ const p = path.join(dir, e.name)
32
+ if (e.isDirectory()) { if (e.name !== '__tests__' && e.name !== 'test' && e.name !== 'tests') walk(p, depth + 1); continue }
33
+ if (!e.isFile() || !SOURCE.test(e.name) || TEST_FILE.test(e.name)) continue
34
+ let code
35
+ try { if (fs.statSync(p).size > 1 << 20) continue; code = fs.readFileSync(p, 'utf8') } catch { continue }
36
+ if (code.includes('ata-validator')) out.push([code, p])
37
+ }
38
+ }
39
+ walk(root, 0)
40
+ return out
41
+ }
42
+
43
+ // The scan and the transform compile the same schemas: one compile each.
44
+ function cachedApi(session, api) {
45
+ if (session.cachedApi && session.cachedApiFor === api) return session.cachedApi
46
+ const memo = new Map()
47
+ const compiledModuleFor = (schema, opts) => {
48
+ const key = JSON.stringify(schema) + '\u0000' + JSON.stringify(opts || {})
49
+ if (!memo.has(key)) memo.set(key, api.compiledModuleFor(schema, opts))
50
+ return memo.get(key)
51
+ }
52
+ session.cachedApi = { ...api, compiledModuleFor }
53
+ session.cachedApiFor = api
54
+ return session.cachedApi
55
+ }
56
+
17
57
  export const unpluginFactory = (userOptions = {}) => {
18
58
  const session = createSession(userOptions)
19
59
 
@@ -25,6 +65,26 @@ export const unpluginFactory = (userOptions = {}) => {
25
65
  const { files, results } = await session.compileAll()
26
66
  const changed = results.filter((r) => r.changed).length
27
67
  session.logger?.info?.(`[unplugin-ata] compiled ${files.length} schema(s), ${changed} file(s) written`)
68
+ // sharedErrors 'auto' decides for the whole application, not file by
69
+ // file: the sources are scanned once here, and the runtime is used when
70
+ // every replaceable call together saves more than it costs.
71
+ session.sharedDecision = undefined
72
+ clearSizes()
73
+ session.scanned = null
74
+ if (userOptions.compileAway !== false && (userOptions.sharedErrors === undefined || userOptions.sharedErrors === 'auto')) {
75
+ const api = await session.api()
76
+ if (api.compiledModuleFor && api.compiledSharedErrors) {
77
+ const sources = scanSources(session.root || process.cwd())
78
+ session.scanned = new Set(sources.map(([, id]) => path.resolve(id)))
79
+ const { pays, saving, calls } = await sharedErrorsPay(sources, cachedApi(session, api))
80
+ // When the whole application does not pay for the runtime, no single
81
+ // call does either: the transform skips the compact attempt. A file
82
+ // the scan did not see (outside the root) decides alone, by the
83
+ // per-module rule, which never makes a bundle bigger either.
84
+ session.sharedDecision = pays ? 'smaller' : false
85
+ if (calls > 0) session.logger?.info?.(`[unplugin-ata] ${calls} validator(s) can share one error runtime, saving ${saving} bytes gzipped against its cost: ${pays ? 'shared' : 'kept self-contained'}`)
86
+ }
87
+ }
28
88
  },
29
89
 
30
90
  async watchChange(id) {
@@ -55,7 +115,9 @@ export const unpluginFactory = (userOptions = {}) => {
55
115
  }
56
116
  return null
57
117
  }
58
- const out = compileAway(code, id.split('?')[0], api)
118
+ const file = id.split('?')[0]
119
+ const scanned = session.sharedDecision !== undefined && session.scanned && session.scanned.has(path.resolve(file))
120
+ const out = compileAway(code, file, cachedApi(session, api), { sharedErrors: scanned ? session.sharedDecision : userOptions.sharedErrors })
59
121
  return out ? { code: out.code, map: out.map } : null
60
122
  },
61
123