@animus-ui/vite-plugin 0.1.9 → 0.1.12

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/dist/index.cjs CHANGED
@@ -6,52 +6,10 @@ let _animus_ui_extract_pipeline = require("@animus-ui/extract/pipeline");
6
6
  let fs = require("fs");
7
7
  let path = require("path");
8
8
  let crypto = require("crypto");
9
- //#region src/constants.ts
10
- /**
11
- * The namespace every virtual module this plugin owns lives under. Exported so
12
- * the `transform` guard that keeps those modules out of source-file handling
13
- * matches on the same string the ids are built from — a rename of the
14
- * namespace cannot leave the guard behind.
15
- */
16
- const VIRTUAL_PREFIX = "virtual:animus/";
17
- const VIRTUAL_CSS_ID = `${VIRTUAL_PREFIX}styles.css`;
18
- const RESOLVED_CSS_ID = `\0${VIRTUAL_CSS_ID}`;
19
- const VIRTUAL_COMPONENTS_ID = `${VIRTUAL_PREFIX}components.js`;
20
- const RESOLVED_COMPONENTS_ID = `\0${VIRTUAL_COMPONENTS_ID}`;
21
- const VIRTUAL_BRIDGE_ID = `${VIRTUAL_PREFIX}hmr-bridge.js`;
22
- const RESOLVED_BRIDGE_ID = `\0${VIRTUAL_BRIDGE_ID}`;
23
- /**
24
- * Browser-addressable URL for the bridge, used as the `src` of the dev
25
- * `<script type="module">` (see index-html.ts). THE explanation of the `/@id/`
26
- * convention lives here; everything else points at it.
27
- *
28
- * Vite reserves `/@id/` for module ids that are not file paths. Its transform
29
- * middleware normalizes such a URL before serving it (`unwrapId`): strip the
30
- * prefix, then decode the `__x00__` placeholder back to a NUL byte. The
31
- * UNPREFIXED specifier is what travels here — `resolveVirtualId` is the hook
32
- * that answers it, and it answers with the `\0` form. (Vite's own `wrapId`
33
- * encodes an already-`\0`-prefixed id as `__x00__`; that form resolves too, but
34
- * it leans on the placeholder convention rather than on this plugin's own
35
- * `resolveId` contract.)
36
- */
37
- const BRIDGE_SCRIPT_SRC = `/@id/${VIRTUAL_BRIDGE_ID}`;
38
- const VIRTUAL_SYSTEM_PROPS_ID = `${VIRTUAL_PREFIX}system-props`;
39
- const RESOLVED_SYSTEM_PROPS_ID = `\0${VIRTUAL_SYSTEM_PROPS_ID}`;
40
- const DEFAULT_EXCLUDE = [
41
- "node_modules",
42
- "dist",
43
- ".test.",
44
- ".spec."
45
- ];
46
- //#endregion
47
9
  //#region src/build-start.ts
48
10
  /**
49
- * buildStart: load the system, discover and ingest sources (local +
50
- * external packages), run whole-project analysis, and log the report.
51
- * `resolveSpecifier` is the bundler seam — Vite's `this.resolve` mapped to
52
- * an absolute id. `emitAsset` (build mode only) is Rollup's `this.emitFile`
53
- * for `asset()` placeholder substitution; absent in dev, where resolved
54
- * files are served via base + `/@fs/` instead.
11
+ * `resolveSpecifier` maps a specifier to an absolute id. `emitAsset` is
12
+ * present in build only, where `asset()` files become Rollup assets.
55
13
  */
56
14
  async function runBuildStart(ctx, resolveSpecifier, emitAsset) {
57
15
  (0, _animus_ui_extract_pipeline.clearEngineCache)(ctx.engineApi);
@@ -70,67 +28,30 @@ async function runBuildStart(ctx, resolveSpecifier, emitAsset) {
70
28
  ctx.log(`System loaded: ${propCount} props, ${groupCount} groups (${Math.round(performance.now() - t0)}ms)`);
71
29
  }
72
30
  t0 = performance.now();
73
- const excludePatterns = ctx.options.exclude ?? DEFAULT_EXCLUDE;
31
+ ctx.excludeMatcher = (0, _animus_ui_extract_pipeline.createExcludeMatcher)(ctx.options.exclude);
32
+ const excludePatterns = ctx.excludeMatcher;
74
33
  ctx.extensionsSet = new Set(ctx.options.extensions ?? _animus_ui_extract_pipeline.DEFAULT_EXTENSIONS);
75
- const shouldHandleMdx = ctx.extensionsSet.has(".mdx");
76
- let mdxMissingDepWarned = false;
77
- const preprocessMdxEntry = async (source, relPath) => {
78
- const result = await (0, _animus_ui_extract_pipeline.preprocessMdx)(source, relPath);
79
- if (result.kind === "missing-dep") {
80
- if (!mdxMissingDepWarned) {
81
- ctx.warn("⚠ .mdx in extensions but @mdx-js/mdx not installed; MDX files skipped");
82
- mdxMissingDepWarned = true;
83
- }
84
- return null;
85
- }
86
- if (result.kind === "error") {
87
- ctx.warn(`⚠ MDX preprocessing failed for ${relPath}: ${result.error}`);
88
- return null;
89
- }
90
- return {
91
- source: result.source,
92
- relPath: relPath + ".tsx"
93
- };
94
- };
95
34
  const filePaths = (0, _animus_ui_extract_pipeline.discoverFiles)(ctx.rootDir, ctx.rootDir, excludePatterns, ctx.extensionsSet);
96
- const fileEntries = [];
35
+ const rawEntries = [];
97
36
  for (const filePath of filePaths) try {
98
- let source = (0, fs.readFileSync)(filePath, "utf-8");
99
- let relPath = (0, path.relative)(ctx.rootDir, filePath);
100
- if (shouldHandleMdx && (0, path.extname)(filePath) === ".mdx") {
101
- const processed = await preprocessMdxEntry(source, relPath);
102
- if (!processed) continue;
103
- source = processed.source;
104
- relPath = processed.relPath;
105
- }
37
+ const source = (0, fs.readFileSync)(filePath, "utf-8");
38
+ const relPath = (0, path.relative)(ctx.rootDir, filePath);
106
39
  const hash = !ctx.isProd ? (0, _animus_ui_extract_pipeline.contentHash)(source) : void 0;
107
- fileEntries.push({
40
+ rawEntries.push({
108
41
  path: relPath,
109
42
  source,
110
43
  hash
111
44
  });
112
- if (!ctx.isProd && hash) ctx.fileCache.set(relPath, {
113
- hash,
114
- source
115
- });
116
45
  } catch {}
117
- const localFileCount = fileEntries.length;
46
+ const localFileCount = rawEntries.length;
118
47
  const packageSpecifiers = (0, _animus_ui_extract_pipeline.extractSystemFilePackages)(ctx.resolvedSystemPath);
119
48
  ctx.externalSourceEntries.clear();
120
- ctx.externalKeyframesScanEntries.clear();
121
49
  const collected = await (0, _animus_ui_extract_pipeline.collectExternalPackageSources)({
122
50
  specifiers: packageSpecifiers,
123
51
  resolveSpecifier,
124
52
  rootDir: ctx.rootDir,
125
53
  extensionsSet: ctx.extensionsSet,
126
- hasEntry: (relPath) => fileEntries.some((e) => e.path === relPath),
127
- preprocessFile: async (source, relPath, absPath) => {
128
- if (shouldHandleMdx && (0, path.extname)(absPath) === ".mdx") return preprocessMdxEntry(source, relPath);
129
- return {
130
- source,
131
- relPath
132
- };
133
- },
54
+ hasEntry: (relPath) => rawEntries.some((entry) => entry.path === relPath),
134
55
  onUnreadable: (relPath, err) => ctx.warn(`skipped unreadable package file ${relPath}: ${String(err)}`)
135
56
  });
136
57
  ctx.packageMap = collected.packageMap;
@@ -139,26 +60,31 @@ async function runBuildStart(ctx, resolveSpecifier, emitAsset) {
139
60
  ctx.externalFileOwners = collected.fileOwners;
140
61
  ctx.enforceIncludeResolution();
141
62
  for (const [specifier, srcEntry] of collected.sourceEntries) ctx.externalSourceEntries.set(specifier, srcEntry);
142
- for (const [specifier, scanEntry] of collected.keyframesScanEntries) ctx.externalKeyframesScanEntries.set(specifier, scanEntry);
143
63
  for (const entry of collected.entries) {
144
64
  const hash = !ctx.isProd ? (0, _animus_ui_extract_pipeline.contentHash)(entry.source) : void 0;
145
- fileEntries.push({
65
+ rawEntries.push({
146
66
  path: entry.path,
147
67
  source: entry.source,
148
68
  hash
149
69
  });
150
- if (!ctx.isProd && hash) ctx.fileCache.set(entry.path, {
151
- hash,
152
- source: entry.source
153
- });
154
70
  }
155
71
  ctx.externalPackageDirs = collected.packageDirs;
156
72
  ctx.registerSystemWatchPaths();
157
- ctx.applyExternalKeyframes();
158
- const packageFileCount = fileEntries.length - localFileCount;
159
- ctx.log(`Discovered ${fileEntries.length} files (${packageFileCount} from packages) (${Math.round(performance.now() - t0)}ms)`);
73
+ const packageFileCount = rawEntries.length - localFileCount;
74
+ ctx.log(`Discovered ${rawEntries.length} files (${packageFileCount} from packages) (${Math.round(performance.now() - t0)}ms)`);
160
75
  t0 = performance.now();
161
- ctx.runAnalysis(fileEntries);
76
+ await ctx.analyzeIngested({
77
+ rawEntries,
78
+ beforeAnalysis: (accepted) => {
79
+ if (!ctx.isProd) ctx.mutateFileCache((cache) => {
80
+ cache.clear();
81
+ for (const entry of accepted.originalEntries) cache.set(entry.path, {
82
+ hash: entry.hash,
83
+ source: entry.source
84
+ });
85
+ });
86
+ }
87
+ });
162
88
  const assetSpecifiers = (0, _animus_ui_extract_pipeline.findAssetSpecifiers)(ctx.globalCss);
163
89
  for (const specifier of assetSpecifiers) {
164
90
  const resolvedPath = await resolveSpecifier(specifier);
@@ -178,17 +104,14 @@ async function runBuildStart(ctx, resolveSpecifier, emitAsset) {
178
104
  ctx.assetPassComplete = true;
179
105
  if (ctx.storedManifest) {
180
106
  const report = ctx.storedManifest.report;
181
- if (report) {
182
- ctx.log(`Extracted ${report.components_extracted}/${report.components_total} components (${Math.round(performance.now() - t0)}ms)`);
183
- ctx.logTimingWaterfall(ctx.storedManifest.timing ?? {});
184
- ctx.log(`Reconciliation: ${report.components_extracted} kept, ${report.variants_eliminated} variants pruned, ${report.states_eliminated} states pruned`);
185
- const details = report.eliminated_details || [];
186
- for (const d of details) if (d.kind === "component") ctx.warn(`⚠ ${d.component} eliminated: ${d.reason}`);
187
- else if (d.kind === "prospective_component") ctx.warn(`⚠ ${d.component} would be eliminated in production: ${d.reason}`);
188
- else if (d.kind === "variant") ctx.warn(`⚠ ${d.component} variant '${d.name}' pruned: ${d.reason}`);
189
- else if (d.kind === "state") ctx.warn(`⚠ ${d.component} state '${d.name}' pruned: ${d.reason}`);
190
- }
191
- ctx.log(`CSS: ${ctx.resolvedComponentCss.length} bytes (${Object.keys(ctx.storedManifest.components || {}).length} components)`);
107
+ ctx.log(`Extracted ${report.components_extracted}/${report.components_total} components (${Math.round(performance.now() - t0)}ms)`);
108
+ ctx.logTimingWaterfall(ctx.storedManifest.timing);
109
+ ctx.log(`Reconciliation: ${report.components_extracted} kept, ${report.variants_eliminated} variants pruned, ${report.states_eliminated} states pruned`);
110
+ for (const d of report.eliminated_details) if (d.kind === "component") ctx.warn(`⚠ ${d.component} eliminated: ${d.reason}`);
111
+ else if (d.kind === "prospective_component") ctx.warn(`⚠ ${d.component} would be eliminated in production: ${d.reason}`);
112
+ else if (d.kind === "variant") ctx.warn(`⚠ ${d.component} variant '${d.name}' pruned: ${d.reason}`);
113
+ else if (d.kind === "state") ctx.warn(`⚠ ${d.component} state '${d.name}' pruned: ${d.reason}`);
114
+ ctx.log(`CSS: ${ctx.resolvedComponentCss.length} bytes (${Object.keys(ctx.storedManifest.components).length} components)`);
192
115
  if (!ctx.isProd && ctx.storedSheets) {
193
116
  const staticSize = (0, _animus_ui_extract_pipeline.assembleStylesheet)({
194
117
  layers: ctx.options.layers,
@@ -210,87 +133,102 @@ async function runBuildStart(ctx, resolveSpecifier, emitAsset) {
210
133
  }
211
134
  //#endregion
212
135
  //#region src/config.ts
213
- /**
214
- * configResolved: capture build mode, root, and logger; resolve Lightning
215
- * CSS targets; harvest resolve.alias into the Rust `pathAliasesJson`
216
- * contract via the shared encoder.
217
- */
218
136
  function applyResolvedConfig(ctx, config) {
219
137
  ctx.isProd = config.command === "build";
138
+ ctx.emissionProd = (0, _animus_ui_extract_pipeline.resolveMode)(ctx.options.mode, () => config.command === "build" ? "production" : "development").mode === "production";
220
139
  ctx.rootDir = config.root;
221
140
  ctx.logger = config.logger;
222
141
  ctx.base = config.base ?? "/";
223
142
  ctx.lcssTargets = (0, _animus_ui_extract_pipeline.resolveLightningTargets)(ctx.options.targets, ctx.rootDir);
224
143
  ctx.log(`Lightning CSS targets resolved (${Object.keys(ctx.lcssTargets).length} browsers)`);
225
- const rawAlias = config.resolve?.alias;
226
- if (rawAlias) {
227
- const pairs = [];
228
- if (Array.isArray(rawAlias)) {
229
- for (const entry of rawAlias) if (typeof entry.find === "string" && typeof entry.replacement === "string") pairs.push({
230
- pattern: entry.find,
231
- target: entry.replacement,
232
- kind: "prefix"
233
- });
234
- } else if (typeof rawAlias === "object" && rawAlias !== null) {
235
- for (const [key, value] of Object.entries(rawAlias)) if (typeof value === "string") pairs.push({
236
- pattern: key,
237
- target: value
238
- });
239
- }
240
- const built = (0, _animus_ui_extract_pipeline.buildPathAliasesJson)(pairs, ctx.rootDir);
241
- if (built) {
242
- ctx.pathAliasesJson = built.json;
243
- ctx.log(`Path aliases forwarded: ${built.count} entries`);
244
- }
144
+ const pairs = [];
145
+ for (const entry of config.resolve.alias) {
146
+ if (entry.find instanceof RegExp) continue;
147
+ pairs.push({
148
+ pattern: entry.find,
149
+ target: entry.replacement,
150
+ kind: "prefix"
151
+ });
152
+ }
153
+ const built = (0, _animus_ui_extract_pipeline.buildPathAliasesJson)(pairs, ctx.rootDir);
154
+ if (built) {
155
+ ctx.pathAliasesJson = built.json;
156
+ ctx.log(`Path aliases forwarded: ${built.count} entries`);
245
157
  }
246
158
  }
247
159
  //#endregion
160
+ //#region src/constants.ts
161
+ /**
162
+ * The namespace every virtual module here lives under; the `transform` guard
163
+ * matches on this same string so a rename cannot strand it.
164
+ */
165
+ const VIRTUAL_PREFIX = "virtual:animus/";
166
+ const VIRTUAL_CSS_ID = `${VIRTUAL_PREFIX}styles.css`;
167
+ const RESOLVED_CSS_ID = `\0${VIRTUAL_CSS_ID}`;
168
+ const VIRTUAL_COMPONENTS_ID = `${VIRTUAL_PREFIX}components.js`;
169
+ const RESOLVED_COMPONENTS_ID = `\0${VIRTUAL_COMPONENTS_ID}`;
170
+ const VIRTUAL_BRIDGE_ID = `${VIRTUAL_PREFIX}hmr-bridge.js`;
171
+ const RESOLVED_BRIDGE_ID = `\0${VIRTUAL_BRIDGE_ID}`;
172
+ /**
173
+ * Vite reserves `/@id/` for module ids that are not file paths and strips the
174
+ * prefix before `resolveId`, so the unprefixed specifier travels here.
175
+ */
176
+ const BRIDGE_SCRIPT_SRC = `/@id/${VIRTUAL_BRIDGE_ID}`;
177
+ const VIRTUAL_SYSTEM_PROPS_ID = `${VIRTUAL_PREFIX}system-props`;
178
+ const RESOLVED_SYSTEM_PROPS_ID = `\0${VIRTUAL_SYSTEM_PROPS_ID}`;
179
+ //#endregion
248
180
  //#region src/hot-update-events.ts
249
181
  /**
250
- * Once-per-file-event coordination across the `hotUpdate` dispatches.
251
- *
252
- * Vite 8 calls `hotUpdate` once per environment for a single file event — the
253
- * client environment first, then every non-client environment (see
254
- * `handleHMRUpdate` in vite/dist/node/chunks/node.js). Analysis work (cache
255
- * mutation, engine re-analysis, geological reset scheduling) must happen
256
- * exactly once per event; module invalidation must happen per environment
257
- * against that environment's own graph.
258
- *
259
- * The client dispatch always claims — it is dispatched first, so nothing can
260
- * have claimed the event before it, and claiming unconditionally keeps a
261
- * same-millisecond `(file, timestamp)` collision from ever costing a client
262
- * analysis. A non-client dispatch claims only when the event carries no
263
- * recorded claim at all, which happens when the plugin is filtered out of the
264
- * client environment.
265
- *
266
- * Keys are retained for a bounded number of recent events so that interleaved
267
- * file events (Vite does not serialize watcher handlers) still find their own
268
- * result.
182
+ * Vite dispatches `hotUpdate` once per environment, client first: analysis
183
+ * runs for one dispatch, invalidation for all, coordinated through these keys.
269
184
  */
270
185
  var HotUpdateEvents = class {
186
+ historyLimit;
271
187
  results = /* @__PURE__ */ new Map();
188
+ /** Keys whose decision aged out of `results`, in eviction order. */
189
+ evicted = /* @__PURE__ */ new Set();
272
190
  constructor(historyLimit = 16) {
273
191
  this.historyLimit = historyLimit;
274
192
  }
275
193
  /** True when this environment dispatch owns the event's analysis work. */
276
194
  claim(environmentName, file, timestamp) {
277
195
  const key = eventKey(file, timestamp);
278
- if (environmentName !== "client" && this.results.has(key)) return false;
196
+ if (environmentName !== "client" && (this.results.has(key) || this.evicted.has(key))) return false;
279
197
  this.results.set(key, { kind: "ignored" });
280
- while (this.results.size > this.historyLimit) {
281
- const oldest = this.results.keys().next().value;
282
- if (oldest === void 0) break;
283
- this.results.delete(oldest);
284
- }
198
+ this.evicted.delete(key);
199
+ this.retireOldest();
285
200
  return true;
286
201
  }
287
202
  /** Publish the owning dispatch's decision to the other environments. */
288
203
  record(file, timestamp, result) {
289
- this.results.set(eventKey(file, timestamp), result);
204
+ const key = eventKey(file, timestamp);
205
+ this.results.set(key, result);
206
+ this.evicted.delete(key);
207
+ this.retireOldest();
290
208
  }
291
- /** The decision for this event — `ignored` when the event was evicted. */
209
+ /**
210
+ * The decision for this event — `evicted` once it aged out of the decision
211
+ * window, `ignored` only when no dispatch ever claimed it.
212
+ */
292
213
  resultOf(file, timestamp) {
293
- return this.results.get(eventKey(file, timestamp)) ?? { kind: "ignored" };
214
+ const key = eventKey(file, timestamp);
215
+ const result = this.results.get(key);
216
+ if (result) return result;
217
+ return this.evicted.has(key) ? { kind: "evicted" } : { kind: "ignored" };
218
+ }
219
+ /** Hold both windows at `historyLimit`, oldest insertion first. */
220
+ retireOldest() {
221
+ while (this.results.size > this.historyLimit) {
222
+ const oldest = this.results.keys().next().value;
223
+ if (oldest === void 0) break;
224
+ this.results.delete(oldest);
225
+ this.evicted.add(oldest);
226
+ }
227
+ while (this.evicted.size > this.historyLimit) {
228
+ const oldest = this.evicted.values().next().value;
229
+ if (oldest === void 0) break;
230
+ this.evicted.delete(oldest);
231
+ }
294
232
  }
295
233
  };
296
234
  /** A path can hold any character but NUL, so NUL is the safe separator. */
@@ -298,17 +236,43 @@ function eventKey(file, timestamp) {
298
236
  return `${file}\u0000${timestamp}`;
299
237
  }
300
238
  //#endregion
301
- //#region src/reset-coalescer.ts
239
+ //#region src/module-invalidation.ts
302
240
  /**
303
- * Coalesces bursts of system-dependency events into single geological
304
- * resets: a quiescence window before starting, at most one reset in
305
- * flight, and exactly one follow-up when requests arrive mid-reset. A
306
- * package build regenerating its dist fires one reset, not one per file.
307
- *
308
- * Timer functions are injected seams — `vi.mock` on node builtins silently
309
- * no-ops under vite-plus-test, so tests drive a manual scheduler instead.
241
+ * Completeness is load-bearing: the file-plan diff is the only invalidation
242
+ * candidate source, so a node shape missed here stays stale for the session.
310
243
  */
244
+ function invalidateFileModules(ctx, relPaths) {
245
+ const server = ctx.devServer;
246
+ if (!server || relPaths.length === 0) return 0;
247
+ const graphs = server.environments ? Object.values(server.environments).map((env) => env?.moduleGraph).filter(Boolean) : [server.moduleGraph].filter(Boolean);
248
+ let total = 0;
249
+ for (const relPath of relPaths) {
250
+ const absPath = (0, path.resolve)(ctx.rootDir, relPath);
251
+ let fileCount = 0;
252
+ for (const graph of graphs) {
253
+ const nodes = /* @__PURE__ */ new Set();
254
+ const byFile = graph.getModulesByFile?.(absPath);
255
+ if (byFile) for (const mod of byFile) nodes.add(mod);
256
+ const byId = graph.getModuleById?.(absPath);
257
+ if (byId) nodes.add(byId);
258
+ for (const mod of nodes) {
259
+ graph.invalidateModule(mod);
260
+ fileCount++;
261
+ }
262
+ }
263
+ if (fileCount > 0) ctx.log(`HMR invalidate: ${relPath} (plan changed, ${fileCount} nodes)`);
264
+ total += fileCount;
265
+ }
266
+ return total;
267
+ }
268
+ //#endregion
269
+ //#region src/reset-coalescer.ts
311
270
  var ResetCoalescer = class {
271
+ run;
272
+ onError;
273
+ quietMs;
274
+ schedule;
275
+ cancel;
312
276
  timer = null;
313
277
  running = false;
314
278
  dirty = false;
@@ -326,11 +290,12 @@ var ResetCoalescer = class {
326
290
  return;
327
291
  }
328
292
  if (this.timer !== null) this.cancel(this.timer);
329
- this.timer = this.schedule(() => {
293
+ this.timer = this.schedule(async () => {
330
294
  this.timer = null;
331
295
  this.running = true;
332
296
  try {
333
- this.run();
297
+ const result = this.run();
298
+ if (result instanceof Promise) await result;
334
299
  } catch (err) {
335
300
  this.onError(err);
336
301
  } finally {
@@ -345,7 +310,6 @@ var ResetCoalescer = class {
345
310
  };
346
311
  //#endregion
347
312
  //#region src/context.ts
348
- /** Pre-load / failed-load defaults — the plugin's historical initial state. */
349
313
  function emptySystemConfig() {
350
314
  return {
351
315
  propConfigJson: "{}",
@@ -359,23 +323,14 @@ function emptySystemConfig() {
359
323
  keyframesJson: null
360
324
  };
361
325
  }
362
- /**
363
- * Reconstruct file entries from cache, including content hashes.
364
- * For unchanged files (hash matches changedPath), sends empty source
365
- * to avoid serializing full source text across the NAPI boundary.
366
- * The engine adapter's `rehydrateFilesJson` refills empty sources from
367
- * this same cache before analyze.
368
- */
369
- function buildFileEntriesFromCache(cache, changedPath) {
370
- const entries = [];
371
- for (const [path$1, { hash, source }] of cache) entries.push({
372
- path: path$1,
373
- source: path$1 === changedPath ? source : "",
374
- hash
375
- });
376
- return entries;
326
+ const analysisChains = /* @__PURE__ */ new WeakMap();
327
+ /** Runs `task` after every analysis transaction already scheduled for this
328
+ * context. Entry points only — a nested call waits on its own chain. */
329
+ function runExclusiveAnalysis(ctx, task) {
330
+ const result = (analysisChains.get(ctx) ?? Promise.resolve()).then(task);
331
+ analysisChains.set(ctx, result.then(() => void 0, () => void 0));
332
+ return result;
377
333
  }
378
- /** Generate the module from the four inputs the context currently holds. */
379
334
  function generateSystemPropsModule(ctx) {
380
335
  return (0, _animus_ui_extract_pipeline.buildSystemPropsModule)({
381
336
  systemPropMapJson: ctx.storedSystemPropMapJson,
@@ -384,54 +339,52 @@ function generateSystemPropsModule(ctx) {
384
339
  transformsSource: ctx.storedTransformsSource
385
340
  });
386
341
  }
387
- /**
388
- * The exact source `virtual:animus/system-props` serves for the current state.
389
- * One definition, so the served bytes and the change decision below can never
390
- * be computed from different inputs.
391
- *
392
- * A real context carries the module already generated (`systemPropsModuleMemo`,
393
- * refreshed wherever the four inputs move), so serving it and deciding whether
394
- * it changed are both reads. Contexts that publish those inputs by hand — the
395
- * behavioral test doubles — carry no memo and generate here instead.
396
- */
342
+ /** Memoized on the inputs themselves, so no writer owes the memo a refresh
343
+ * and a stale memo cannot serve a module from a generation that is gone. */
344
+ const systemPropsModules = /* @__PURE__ */ new WeakMap();
345
+ /** NUL cannot appear in JSON text, so concatenation is injective. */
346
+ function systemPropsModuleKey(ctx) {
347
+ return [
348
+ ctx.storedSystemPropMapJson,
349
+ ctx.system.groupRegistryJson,
350
+ ctx.storedDynamicPropsJson,
351
+ ctx.storedTransformsSource
352
+ ].join("\0");
353
+ }
354
+ /** The exact source `virtual:animus/system-props` serves now — one definition,
355
+ * so the served bytes and the HMR change decision cannot diverge. */
397
356
  function systemPropsModuleSource(ctx) {
398
- return ctx.systemPropsModuleMemo ??= generateSystemPropsModule(ctx);
357
+ const key = systemPropsModuleKey(ctx);
358
+ const memo = systemPropsModules.get(ctx);
359
+ if (memo && memo.key === key) return memo.source;
360
+ const source = generateSystemPropsModule(ctx);
361
+ systemPropsModules.set(ctx, {
362
+ key,
363
+ source
364
+ });
365
+ return source;
399
366
  }
400
- /**
401
- * Drop a deleted (or renamed-away) file from the dev file cache so its
402
- * last-known source stops riding along as a ghost entry on every later
403
- * re-analysis. Both key forms are tried: the plain rootDir-relative path, and
404
- * the `.tsx` suffix MDX sources carry after preprocessing. External package
405
- * entries are rootDir-relative too (with leading `..` segments) and prune the
406
- * same way. Returns whether an entry was actually removed.
407
- */
367
+ /** Drop a deleted or renamed-away raw original from the dev file cache.
368
+ * Generated MDX/Svelte children live only in the published corpus. */
408
369
  function pruneFileCache(cache, rootDir, absPath) {
409
370
  const rel = (0, path.relative)(rootDir, (0, path.resolve)(absPath));
410
- return cache.delete(rel) || cache.delete(rel + ".tsx");
371
+ return cache.delete(rel);
411
372
  }
412
- /**
413
- * Per-plugin-instance state and the pipeline operations over it. Hook
414
- * bodies live in their own modules (build-start, virtual-modules,
415
- * transform, hmr) and receive this context — the plugin factory in
416
- * index.ts only wires Vite hooks to those functions.
417
- *
418
- * A class rather than closure variables so each hook module names exactly
419
- * the state it touches, and the engine store (per-instance, never
420
- * module-level) is explicit.
421
- */
422
373
  var PluginContext = class {
423
374
  options;
424
375
  verbose;
425
- /** Serialized staticCss forced-emission declarations (stable key order). */
426
376
  staticCssJson;
427
377
  isProd = false;
378
+ /** Emission mode (the explicit `mode` option wins over the command); feeds
379
+ * engine devMode and the minify default. Lifecycle stays on `isProd`. */
380
+ emissionProd = false;
428
381
  rootDir = "";
429
382
  logger = null;
430
- /** System-derived config (shared SystemConfig shape). */
431
383
  system = emptySystemConfig();
432
384
  lcssTargets = {};
433
385
  pathAliasesJson = null;
434
386
  extensionsSet;
387
+ excludeMatcher;
435
388
  storedManifest = null;
436
389
  storedManifestJson = "";
437
390
  globalCss = "";
@@ -442,43 +395,43 @@ var PluginContext = class {
442
395
  recordTransformOutput(relativePath, code) {
443
396
  this.transformOutputHashes.set(relativePath, (0, _animus_ui_extract_pipeline.contentHash)(code));
444
397
  }
445
- /**
446
- * The consumer system's OWN keyframes collections, captured at load time
447
- * before any external merge touches `system.keyframesJson`. Every
448
- * `applyExternalKeyframes` merge starts from this baseline, so repeated
449
- * merges (a --watch rebuild's loadSystem + rediscovery, a geological
450
- * reset) never compound prior external state — a removed include's
451
- * keyframes disappear with it.
452
- */
453
- consumerKeyframesJson = null;
454
- /**
455
- * Merge `Keyframes` collections from discovered external package entries
456
- * into the system's collections (keyframes-only carve-out — the consumer
457
- * system stays the singular config authority). Runs after buildStart
458
- * discovery AND after every geological-reset system reload, since a reload
459
- * rebuilds `this.system` from the consumer entry alone. Merges from the
460
- * consumer-only baseline, never from the previously merged value.
461
- */
462
- applyExternalKeyframes() {
463
- if (this.externalKeyframesScanEntries.size === 0) {
464
- this.system.keyframesJson = this.consumerKeyframesJson;
465
- this.externalKeyframesDiagnostics = [];
466
- return;
467
- }
468
- const merge = (0, _animus_ui_extract_pipeline.mergeExternalKeyframes)((entry, root) => this.engineApi().scanKeyframesExports(entry, root), this.consumerKeyframesJson, this.externalKeyframesScanEntries.values(), this.rootDir);
469
- this.system.keyframesJson = merge.keyframesJson;
470
- this.externalKeyframesDiagnostics = merge.diagnostics;
471
- }
472
- /** Discovery-time keyframes diagnostics awaiting the next analysis's
473
- * shared surfacing pass. */
474
- externalKeyframesDiagnostics = [];
398
+ /** Vocabulary witnesses from the sealed system, awaiting the next analysis.
399
+ * Surfacing happens only inside `runProjectAnalysis`. */
400
+ systemVocabularyDiagnostics = [];
475
401
  reverseProvenance = {};
476
402
  storedSystemPropMapJson = "{}";
477
403
  storedDynamicPropsJson = "{}";
478
404
  storedTransformsSource = "{}";
479
- systemPropsModuleMemo = null;
480
405
  fileCache = /* @__PURE__ */ new Map();
406
+ /** Mutation attempts against `fileCache`, for memos that must not key on
407
+ * `size` — a delete plus an unrelated create leaves it unchanged. */
408
+ fileCacheGeneration = 0;
409
+ /** The one mutator of the raw-source cache: every add, replacement and
410
+ * deletion goes through here so no write escapes the counter above. */
411
+ mutateFileCache(mutate) {
412
+ const cache = this.fileCache;
413
+ const result = mutate(cache);
414
+ this.fileCacheGeneration += 1;
415
+ return result;
416
+ }
417
+ /** Generated MDX/Svelte paths never enter `fileCache` — they live only in
418
+ * this corpus's published projection. */
419
+ corpus = (0, _animus_ui_extract_pipeline.createSourceCorpus)({
420
+ engineApi: () => this.engineApi(),
421
+ prefix: "[animus-extract]",
422
+ strict: () => !!this.options.strict,
423
+ warn: (message) => this.warn(message)
424
+ });
481
425
  rawExtensionFallbacks = /* @__PURE__ */ new Set();
426
+ /** The one mutator of a file's fallback state — every raw-serve exit of the
427
+ * transform hook reports here. Production keeps the set empty. The
428
+ * barrier's one-shot withhold release must not route through here: it
429
+ * retires other files' records, no claim about their current serve. */
430
+ recordFallbackState(relativePath, isFallback) {
431
+ if (this.isProd) return;
432
+ if (isFallback) this.rawExtensionFallbacks.add(relativePath);
433
+ else this.rawExtensionFallbacks.delete(relativePath);
434
+ }
482
435
  hotUpdateEvents = new HotUpdateEvents();
483
436
  pendingReloadTimer = null;
484
437
  packageMap = {};
@@ -490,7 +443,6 @@ var PluginContext = class {
490
443
  externalDirOwners = {};
491
444
  externalFileOwners = {};
492
445
  externalSourceEntries = /* @__PURE__ */ new Map();
493
- externalKeyframesScanEntries = /* @__PURE__ */ new Map();
494
446
  externalPackageOutcomes = [];
495
447
  devServer;
496
448
  resolvedSystemPath = null;
@@ -499,13 +451,14 @@ var PluginContext = class {
499
451
  v2Engine = null;
500
452
  v2SentSources = null;
501
453
  v2DriftWarned = false;
502
- /** Single engine choke-point for every native extraction call. */
454
+ /** Every native extraction call goes through this one accessor. */
503
455
  engineApi;
504
456
  constructor(options, engineApiOverride) {
505
457
  this.options = options;
506
458
  this.staticCssJson = (0, _animus_ui_extract_pipeline.serializeStaticCss)(options.staticCss);
507
459
  this.verbose = options.verbose || process.env.ANIMUS_DEBUG === "1" || process.env.ANIMUS_DEBUG === "true";
508
460
  this.extensionsSet = new Set(options.extensions ?? _animus_ui_extract_pipeline.DEFAULT_EXTENSIONS);
461
+ this.excludeMatcher = (0, _animus_ui_extract_pipeline.createExcludeMatcher)(options.exclude);
509
462
  if (engineApiOverride) {
510
463
  this.engineApi = engineApiOverride;
511
464
  return;
@@ -517,8 +470,9 @@ var PluginContext = class {
517
470
  loadNativeEngine: () => require(engineModuleId),
518
471
  rehydrateFilesJson: (filesJsonRaw) => {
519
472
  if (!filesJsonRaw.includes("\"source\":\"\"")) return filesJsonRaw;
520
- const entries = JSON.parse(filesJsonRaw);
521
- for (const entry of entries) if (entry.source === "") entry.source = this.fileCache.get(entry.path)?.source ?? "";
473
+ const entries = (0, _animus_ui_extract_pipeline.parseFilesJson)(filesJsonRaw, "animus-extract");
474
+ const analysisEntries = this.corpus.published.analysisEntries;
475
+ for (const entry of entries) if (entry.source === "") entry.source = analysisEntries.get(entry.path)?.source ?? "";
522
476
  return JSON.stringify(entries);
523
477
  },
524
478
  store: {
@@ -540,12 +494,8 @@ var PluginContext = class {
540
494
  log(msg) {
541
495
  if (this.verbose) (this.logger ?? console).info(`[animus] ${msg}`);
542
496
  }
543
- /**
544
- * Standard-level information — emitted whether or not `verbose` is on.
545
- * Reserved for events a developer must see without opting in, e.g. a file
546
- * created after buildStart being folded into the analysis (openspec:
547
- * hmr-new-file-detection, "New file detection logging").
548
- */
497
+ /** Logs whether or not `verbose` is on — for events a developer must see
498
+ * without opting in. */
549
499
  info(msg) {
550
500
  (this.logger ?? console).info(`[animus] ${msg}`);
551
501
  }
@@ -559,11 +509,8 @@ var PluginContext = class {
559
509
  labelWidth: 15
560
510
  })) this.log(line);
561
511
  }
562
- /**
563
- * Load a SystemInstance via Rust NAPI (rquickjs bundled eval) into
564
- * `this.system`. On failure the previous config is kept (strict mode
565
- * throws instead).
566
- */
512
+ /** Load the system into `this.system`. On failure the previous config is
513
+ * kept; strict mode throws instead. */
567
514
  loadSystem() {
568
515
  this.resolvedSystemPath = (0, path.resolve)(this.rootDir, this.options.system);
569
516
  for (const key of (0, _animus_ui_extract_pipeline.toWatchKeys)(this.resolvedSystemPath)) this.systemDependencyKeys.add(key);
@@ -580,23 +527,14 @@ var PluginContext = class {
580
527
  this.systemDependencyKeys = keys;
581
528
  this.systemDependencyPaths = deps;
582
529
  this.registerSystemWatchPaths();
583
- this.consumerKeyframesJson = this.system.keyframesJson;
584
- this.applyExternalKeyframes();
530
+ this.systemVocabularyDiagnostics = (0, _animus_ui_extract_pipeline.vocabularyWitnessDiagnostics)(this.system.vocabularyWitnessesJson);
585
531
  } catch (e) {
586
532
  if (this.options.strict) throw new Error(`[animus-extract] Failed to load system from ${this.resolvedSystemPath}: ${e}`, { cause: e });
587
533
  console.warn(`[animus-extract] Failed to load system from ${this.resolvedSystemPath}:`, e);
588
534
  }
589
- this.systemPropsModuleMemo = generateSystemPropsModule(this);
590
535
  }
591
- /**
592
- * Run project analysis via the shared `runProjectAnalysis` and update
593
- * all manifest-derived state. Returns whether the analysis PUBLISHED —
594
- * `false` means the previous manifest is still current, and callers that
595
- * advanced the file cache for this attempt must roll that entry back or
596
- * the content-hash gate will suppress the same-content retry forever
597
- * (openspec: dev-transform-coherence, "Failed analyses do not suppress
598
- * equal-content retries"). Strict mode still throws.
599
- */
536
+ /** Runs project analysis and updates every manifest-derived state. Returns
537
+ * false when nothing published, and the caller must roll its cache back. */
600
538
  runAnalysis(fileEntries) {
601
539
  let result;
602
540
  try {
@@ -605,60 +543,75 @@ var PluginContext = class {
605
543
  packageMap: this.packageMap,
606
544
  system: this.system,
607
545
  emitter: {
608
- runtimeImport: "@animus-ui/system",
546
+ runtimeImport: this.options.runtimeImport ?? "@animus-ui/system",
609
547
  cssModuleId: VIRTUAL_CSS_ID
610
548
  },
611
549
  pathAliasesJson: this.pathAliasesJson,
612
550
  staticCssJson: this.staticCssJson,
613
551
  externalDirs: this.externalPackageDirs.map((dir) => (0, path.relative)(this.rootDir, dir)),
614
- devMode: !this.isProd,
552
+ devMode: !this.emissionProd,
615
553
  warn: (m) => this.warn(m),
616
554
  strict: this.options.strict,
617
- extraDiagnostics: this.externalKeyframesDiagnostics
555
+ extraDiagnostics: this.systemVocabularyDiagnostics
618
556
  });
619
557
  } catch (e) {
620
558
  if (this.options.strict) throw new Error(`[animus-extract] analyzeProject failed: ${e}`, { cause: e });
621
559
  console.warn("[animus-extract] analyzeProject failed:", e);
622
560
  return false;
623
561
  }
624
- (0, _animus_ui_extract_pipeline.assertNoErrorDiagnostics)(result.manifest?.diagnostics);
562
+ (0, _animus_ui_extract_pipeline.assertNoErrorDiagnostics)(result.manifest.diagnostics);
563
+ this.assertRuntimeImportSuppliesTerminals(result.manifest);
625
564
  this.storedManifest = result.manifest;
626
565
  this.storedManifestJson = result.manifestJson;
627
- this.storedSystemPropMapJson = JSON.stringify(result.manifest?.system_prop_map ?? {});
628
- this.storedDynamicPropsJson = JSON.stringify(result.manifest?.dynamic_props ?? {});
629
- this.reverseProvenance = result.manifest?.reverse_provenance ?? {};
630
- this.storedSheets = result.manifest?.sheets ?? null;
566
+ this.storedSystemPropMapJson = JSON.stringify(result.manifest.system_prop_map);
567
+ this.storedDynamicPropsJson = JSON.stringify(result.manifest.dynamic_props);
568
+ this.reverseProvenance = result.manifest.reverse_provenance;
569
+ this.storedSheets = result.manifest.sheets;
631
570
  this.globalCss = result.globalCss;
632
571
  this.resolvedComponentCss = result.componentCss;
633
- this.systemPropsModuleMemo = generateSystemPropsModule(this);
634
572
  this.applyAssetSubstitutions();
635
- this.enforceExternalTokenContracts();
636
573
  return true;
637
574
  }
638
- /** Base-prefixed dev URL for an absolute file (Vite mounts /@fs under base). */
575
+ assertRuntimeImportSuppliesTerminals(manifest) {
576
+ const override = this.options.runtimeImport;
577
+ if (!override || override === "@animus-ui/system") return;
578
+ const offenders = [];
579
+ for (const [id, descriptor] of Object.entries(manifest.components)) if (/\bcreateComponent\(|\bcreateComposedFamily\(/.test(descriptor.replacement)) offenders.push(id);
580
+ if (offenders.length === 0) return;
581
+ const shown = offenders.slice(0, 5).join(", ");
582
+ throw new Error(`[animus-extract] runtimeImport '${override}' is valid only when every extracted terminal is .asClass(), but ${offenders.length} component(s) need createComponent/createComposedFamily from the default '@animus-ui/system' runtime: ${shown}` + (offenders.length > 5 ? ", …" : "") + `. Remove the override or convert these terminals to .asClass().`);
583
+ }
584
+ /** The one ingest → quarantine → analyze → publish transaction. The corpus
585
+ * publishes only on success; `beforeAnalysis` runs after quarantine. */
586
+ async analyzeIngested(options) {
587
+ const accepted = await this.corpus.prepare(options?.rawEntries ?? {
588
+ fileCache: this.fileCache,
589
+ externalFileOwners: this.externalFileOwners
590
+ });
591
+ options?.beforeAnalysis?.(accepted);
592
+ const ok = this.runAnalysis(accepted.analysisEntries) !== false;
593
+ if (ok) this.publishSourceIngestion(accepted);
594
+ return {
595
+ ok,
596
+ accepted
597
+ };
598
+ }
599
+ publishSourceIngestion(result) {
600
+ this.corpus.publish(result);
601
+ this.externalFileOwners = (0, _animus_ui_extract_pipeline.projectExternalFileOwners)(result, this.externalFileOwners);
602
+ this.enforceExternalTokenContracts();
603
+ }
639
604
  devFsUrl(absPath) {
640
605
  return `${this.base.endsWith("/") ? this.base.slice(0, -1) : this.base}/@fs${absPath}`;
641
606
  }
642
- /**
643
- * The shared unresolvable-asset policy: fail the build under `strict`,
644
- * else warn and record the identity mapping so the specifier emits
645
- * literally.
646
- */
647
607
  assetFallback(specifier, message, cause) {
648
608
  if (this.options.strict) throw new Error(message, cause === void 0 ? void 0 : { cause });
649
609
  this.warn(message);
650
610
  this.assetUrlBySpecifier.set(specifier, specifier);
651
611
  this.assetResolutionFailures.add(specifier);
652
612
  }
653
- /**
654
- * The one asset-substitution pass per analysis. In dev, a specifier not
655
- * already mapped by buildStart's bundler-resolved pass (a geological
656
- * reset regenerates globalCss from an edited system, which may reference
657
- * NEW assets) is resolved Node-side first — host aliases, then Node,
658
- * then package root; bundler-only resolutions can differ, since the
659
- * plugin hook context is unavailable on this path. Strict semantics
660
- * match buildStart.
661
- */
613
+ /** Resolves asset specifiers a system reload introduced. Node-side only —
614
+ * the plugin hook context is unavailable here, so results can differ. */
662
615
  applyAssetSubstitutions() {
663
616
  if (!this.isProd && this.assetPassComplete) for (const specifier of (0, _animus_ui_extract_pipeline.findAssetSpecifiers)(this.globalCss)) {
664
617
  if (this.assetResolutionFailures.delete(specifier)) this.assetUrlBySpecifier.delete(specifier);
@@ -670,55 +623,46 @@ var PluginContext = class {
670
623
  this.globalCss = (0, _animus_ui_extract_pipeline.substituteAssetPlaceholders)(this.globalCss, this.assetUrlBySpecifier);
671
624
  }
672
625
  resetCoalescer = null;
673
- /**
674
- * Schedule a geological reset through the burst coalescer. N dependency
675
- * events within the quiescence window produce one reset; events during a
676
- * running reset produce exactly one follow-up.
677
- */
678
- requestGeologicalReset(trigger) {
679
- this.log(`HMR geological reset scheduled: ${trigger}`);
680
- this.resetCoalescer ??= new ResetCoalescer(() => this.performGeologicalReset(), (err) => this.geologicalResetFailed(err));
626
+ requestSystemReload(trigger) {
627
+ this.log(`HMR system reload scheduled: ${trigger}`);
628
+ this.resetCoalescer ??= new ResetCoalescer(
629
+ async () => this.performSystemReload(),
630
+ /** The coalescer fires from a bare timer, outside Vite's error
631
+ * handling, so an unhandled strict throw would exit the process. */
632
+ (err) => {
633
+ const display = String(err);
634
+ this.warn(`[animus-extract] system reload failed: ${display}`);
635
+ const message = err instanceof Error ? err.message : String(err);
636
+ const stack = err instanceof Error ? err.stack ?? "" : "";
637
+ this.devServer?.hot?.send({
638
+ type: "error",
639
+ err: {
640
+ message,
641
+ stack,
642
+ plugin: "animus-extract"
643
+ }
644
+ });
645
+ }
646
+ );
681
647
  this.resetCoalescer.request();
682
648
  }
683
- /**
684
- * A failed reset must surface without killing the server: the coalescer
685
- * fires from a bare timer, OUTSIDE Vite's handleHMRUpdate catch, so a
686
- * strict-mode throw (asset resolution, token contracts, system load)
687
- * would otherwise be an unhandled exception that exits the process.
688
- * Strict-in-dev means the error overlay, matching the transform/HMR
689
- * strict paths that Vite itself catches.
690
- */
691
- geologicalResetFailed(err) {
692
- this.warn(`[animus-extract] geological reset failed: ${err}`);
693
- const message = err instanceof Error ? err.message : String(err);
694
- const stack = err instanceof Error ? err.stack ?? "" : "";
695
- this.devServer?.hot?.send({
696
- type: "error",
697
- err: {
698
- message,
699
- stack,
700
- plugin: "animus-extract"
701
- }
702
- });
649
+ async performSystemReload() {
650
+ return runExclusiveAnalysis(this, () => this.performSystemReloadExclusive());
703
651
  }
704
- /**
705
- * The geological reset: reload the system (refreshing the dependency
706
- * set), clear the Rust per-file cache, re-analyze everything with full
707
- * sources, then invalidate the static/component/system-prop virtual
708
- * modules and reload the client.
709
- */
710
- performGeologicalReset() {
652
+ async performSystemReloadExclusive() {
711
653
  const resetStart = performance.now();
712
- this.loadSystem();
713
- (0, _animus_ui_extract_pipeline.clearEngineCache)(this.engineApi);
714
- const fileEntries = [];
715
- for (const [path$2, { hash, source }] of this.fileCache) fileEntries.push({
716
- path: path$2,
717
- source,
718
- hash
719
- });
720
- this.runAnalysis(fileEntries);
721
- this.log(`HMR geological reset complete: ${Math.round(performance.now() - resetStart)}ms`);
654
+ const prevPlans = (0, _animus_ui_extract_pipeline.snapshotFilePlans)(this.storedManifest);
655
+ try {
656
+ this.loadSystem();
657
+ const { ok } = await this.analyzeIngested({ beforeAnalysis: () => (0, _animus_ui_extract_pipeline.clearEngineCache)(this.engineApi) });
658
+ if (!ok) return;
659
+ invalidateFileModules(this, (0, _animus_ui_extract_pipeline.diffFilePlans)(prevPlans, (0, _animus_ui_extract_pipeline.snapshotFilePlans)(this.storedManifest)));
660
+ this.log(`HMR system reload complete: ${Math.round(performance.now() - resetStart)}ms`);
661
+ } finally {
662
+ this.invalidateSystemReloadModules();
663
+ }
664
+ }
665
+ invalidateSystemReloadModules() {
722
666
  const server = this.devServer;
723
667
  if (!server) return;
724
668
  for (const moduleId of [
@@ -731,49 +675,21 @@ var PluginContext = class {
731
675
  }
732
676
  server.hot?.send({ type: "full-reload" });
733
677
  }
734
- /**
735
- * True when the file is a member of the system's evaluated module-file
736
- * set. Tested BEFORE component-scan exclude filters: a system dependency
737
- * edit invalidates the compiler registry regardless of what the user
738
- * excluded from component scanning.
739
- */
740
678
  isSystemDependency(absFile) {
741
679
  return (0, _animus_ui_extract_pipeline.toWatchKeys)(absFile).some((key) => this.systemDependencyKeys.has(key));
742
680
  }
743
- /**
744
- * Ask the dev watcher to watch every loader-reported dependency path.
745
- * Covers system modules unreachable from app imports (workspace package
746
- * config files). Vite's watcher hard-ignores `node_modules`, so
747
- * node_modules-installed system dependencies produce no events — a
748
- * documented limitation; workspace deps resolve to real paths outside
749
- * node_modules and are watchable. No-ops without a dev server.
750
- */
681
+ /** Watch every loader-reported dependency path. Vite hard-ignores
682
+ * node_modules, so dependencies installed there produce no events. */
751
683
  registerSystemWatchPaths() {
752
684
  const watcher = this.devServer?.watcher;
753
685
  if (!watcher) return;
754
686
  if (this.systemDependencyPaths.length > 0) watcher.add(this.systemDependencyPaths);
755
687
  if (this.externalPackageDirs.length > 0) watcher.add(this.externalPackageDirs);
756
688
  }
757
- /**
758
- * Invalidate the component CSS and system-props virtual modules, then
759
- * reload the client. Shared by the two out-of-band re-analysis paths — a
760
- * file created after buildStart (transform) and a file deleted during dev
761
- * (the `hotUpdate` delete event) — which both mutate the cache, re-run
762
- * analysis, and then need the client to pick up the regenerated CSS.
763
- * No-ops outside dev.
764
- *
765
- * `devServer.moduleGraph` is Vite's back-compat mixed graph: its
766
- * `getModuleById` searches the client AND ssr environment graphs and its
767
- * `invalidateModule` invalidates both instances behind the returned node,
768
- * which is exactly the reach this path wants. It stays the seam here.
769
- *
770
- * Both modules are invalidated unconditionally, and deliberately so: the
771
- * appearance or disappearance of a whole component file is not the
772
- * steady-state edit the change-gated path governs, and a client reload does
773
- * NOT rescue a module that was never invalidated — Vite keeps serving its
774
- * cached transform result across reloads (openspec: hmr-new-file-detection,
775
- * "CSS invalidation after new file analysis").
776
- */
689
+ /** Invalidate the component CSS and system-props modules, then reload. The
690
+ * reload alone rescues nothing: Vite keeps serving its cached transform.
691
+ * `devServer.moduleGraph` is the mixed back-compat graph on purpose — it
692
+ * reaches client and ssr at once; per-environment graphs do not. */
777
693
  invalidateExtractedModules() {
778
694
  const server = this.devServer;
779
695
  if (!server) return;
@@ -787,16 +703,8 @@ var PluginContext = class {
787
703
  this.devServer?.hot?.send({ type: "full-reload" });
788
704
  }, 100);
789
705
  }
790
- /**
791
- * The buildStart gate over include resolution
792
- * (external-package-file-discovery: silence is never an outcome). An
793
- * unresolvable `.includes()` specifier warns in non-strict mode and FAILS
794
- * the build under `strict: true`, naming every offending specifier —
795
- * a typo'd include must not ship a build missing its component CSS. A
796
- * stale dist entry under an extended package (first-class-extension D13)
797
- * rides the same seam: a merge against it would silently skew registry
798
- * content the discovered sources no longer match.
799
- */
706
+ /** An unresolvable `.includes()` specifier or a stale dist entry under an
707
+ * extended package warns, and fails the build under `strict`. */
800
708
  enforceIncludeResolution() {
801
709
  for (const message of [(0, _animus_ui_extract_pipeline.unresolvableIncludesMessage)(this.externalPackageOutcomes), (0, _animus_ui_extract_pipeline.staleDistIncludesMessage)(this.externalPackageOutcomes)]) {
802
710
  if (message === null) continue;
@@ -804,14 +712,6 @@ var PluginContext = class {
804
712
  this.warn(message);
805
713
  }
806
714
  }
807
- /**
808
- * The post-analysis gate over cross-source token contracts
809
- * (extraction-diagnostics): a discovered component referencing a token its
810
- * OWN package defines but the consumer theme does not gets the teaching
811
- * error naming the token, component, source package, and the missing
812
- * `createTheme().extend(...)`. Wiring and severity routing live in the
813
- * shared pipeline gate (next-plugin parity by construction).
814
- */
815
715
  enforceExternalTokenContracts() {
816
716
  (0, _animus_ui_extract_pipeline.enforceExternalTokenContracts)({
817
717
  diagnostics: this.storedManifest?.diagnostics,
@@ -824,23 +724,14 @@ var PluginContext = class {
824
724
  });
825
725
  }
826
726
  runSelfVerify() {
827
- const failures = [];
828
- if (Object.keys(this.storedManifest?.components ?? {}).length === 0) failures.push("No component CSS produced — check the system file and its includes list");
829
- for (const { specifier, outcome } of this.externalPackageOutcomes) if (outcome === "empty") failures.push(`include '${specifier}' resolved but discovered no component sources`);
830
- else if (outcome === "unresolvable") failures.push(`include '${specifier}' could not be resolved`);
831
- if (!this.system.variableCss.includes(":root")) failures.push("No :root variable block found in variable CSS");
832
- if (`${this.system.variableCss}\n${this.globalCss}\n${this.resolvedComponentCss}`.includes("__TRANSFORM__")) failures.push("Unresolved __TRANSFORM__ placeholders found in CSS output");
833
- if (this.storedManifest && this.resolvedComponentCss.length > 0) {
834
- const assembled = (0, _animus_ui_extract_pipeline.assembleStylesheet)({
835
- layers: this.options.layers,
836
- variableCss: this.system.variableCss,
837
- globalCss: this.globalCss,
838
- componentCss: this.resolvedComponentCss
839
- });
840
- const baseIdx = assembled.search(/@layer\s+anm-base\s*\{/);
841
- const variantsIdx = assembled.search(/@layer\s+anm-variants\s*\{/);
842
- if (baseIdx !== -1 && variantsIdx !== -1 && baseIdx >= variantsIdx) failures.push(`CSS layer ordering violated — @layer anm-base (offset ${baseIdx}) must precede @layer anm-variants (offset ${variantsIdx})`);
843
- }
727
+ const failures = (0, _animus_ui_extract_pipeline.runStructuralSelfCheck)({
728
+ componentCount: Object.keys(this.storedManifest?.components ?? {}).length,
729
+ variableCss: this.system.variableCss,
730
+ globalCss: this.globalCss,
731
+ componentCss: this.resolvedComponentCss,
732
+ layers: this.options.layers,
733
+ externalOutcomes: this.externalPackageOutcomes
734
+ });
844
735
  for (const message of failures) {
845
736
  const line = `[animus:verify] ${message}`;
846
737
  if (this.options.strict) throw new Error(line);
@@ -851,54 +742,17 @@ var PluginContext = class {
851
742
  }
852
743
  };
853
744
  //#endregion
854
- //#region src/module-invalidation.ts
855
- /**
856
- * Evict every module node the dev server holds for each file — every
857
- * environment graph (client, ssr, and any custom environment), by-file node
858
- * sets (covers query-suffixed variants), plus the absolute-path id lookup.
859
- * Falls back to the mixed compat graph for hosts without environment
860
- * graphs. Returns the number of nodes invalidated.
861
- *
862
- * Completeness here is load-bearing: the file-plan diff is the ONLY
863
- * invalidation candidate source, so a node shape this enumeration misses
864
- * stays stale for the life of the server (openspec: dev-transform-coherence,
865
- * "Client and SSR nodes are both evicted").
866
- */
867
- function invalidateFileModules(ctx, relPaths) {
868
- const server = ctx.devServer;
869
- if (!server || relPaths.length === 0) return 0;
870
- const graphs = server.environments ? Object.values(server.environments).map((env) => env?.moduleGraph).filter(Boolean) : [server.moduleGraph].filter(Boolean);
871
- let total = 0;
872
- for (const relPath of relPaths) {
873
- const absPath = (0, path.resolve)(ctx.rootDir, relPath);
874
- let fileCount = 0;
875
- for (const graph of graphs) {
876
- const nodes = /* @__PURE__ */ new Set();
877
- const byFile = graph.getModulesByFile?.(absPath);
878
- if (byFile) for (const mod of byFile) nodes.add(mod);
879
- const byId = graph.getModuleById?.(absPath);
880
- if (byId) nodes.add(byId);
881
- for (const mod of nodes) {
882
- graph.invalidateModule(mod);
883
- fileCount++;
884
- }
885
- }
886
- if (fileCount > 0) ctx.log(`HMR invalidate: ${relPath} (plan changed, ${fileCount} nodes)`);
887
- total += fileCount;
888
- }
889
- return total;
890
- }
891
- //#endregion
892
745
  //#region src/rediscovery.ts
893
- /** The current manifest's unresolved-parent drops, parsed from diagnostics
894
- * (shared Rust-mirror matcher — the regex lives in manifest-diagnostics). */
746
+ /** The current manifest's unresolved-parent drops, parsed from diagnostics. */
895
747
  function unresolvedParentDrops(ctx) {
896
748
  const drops = [];
897
- for (const d of ctx.storedManifest?.diagnostics ?? []) {
749
+ const manifest = ctx.storedManifest;
750
+ if (!manifest) return drops;
751
+ for (const d of manifest.diagnostics) {
898
752
  const parent = (0, _animus_ui_extract_pipeline.unresolvedParentName)(d);
899
753
  if (parent !== null) drops.push({
900
- file: String(d.file ?? ""),
901
- component: String(d.component ?? ""),
754
+ file: d.file,
755
+ component: d.component,
902
756
  parent
903
757
  });
904
758
  }
@@ -906,43 +760,24 @@ function unresolvedParentDrops(ctx) {
906
760
  }
907
761
  const EMPTY_DROP_FILES = /* @__PURE__ */ new Set();
908
762
  const dropFilesByManifest = /* @__PURE__ */ new WeakMap();
909
- /** Files carrying an unresolved-parent drop in the CURRENT manifest —
910
- * derived once per manifest publication, then a set lookup. */
763
+ /** Files carrying an unresolved-parent drop in the current manifest. */
911
764
  function unresolvedDropFiles(ctx) {
912
765
  const manifest = ctx.storedManifest;
913
766
  if (!manifest) return EMPTY_DROP_FILES;
914
767
  let files = dropFilesByManifest.get(manifest);
915
768
  if (!files) {
916
769
  const derived = /* @__PURE__ */ new Set();
917
- for (const d of manifest.diagnostics ?? []) if ((0, _animus_ui_extract_pipeline.isUnresolvedParentDrop)(d)) derived.add(String(d.file ?? ""));
770
+ for (const d of manifest.diagnostics) if ((0, _animus_ui_extract_pipeline.isUnresolvedParentDrop)(d)) derived.add(d.file);
918
771
  files = derived;
919
772
  dropFilesByManifest.set(manifest, files);
920
773
  }
921
774
  return files;
922
775
  }
923
776
  /**
924
- * Reconcile the discoverable on-disk source universe before an
925
- * unresolved-parent result is acted on (openspec: dev-transform-coherence,
926
- * "Source-universe reconciliation precedes unresolved-parent fallbacks").
927
- *
928
- * Drop-triggered: runs only when the current manifest reports `chain
929
- * dropped: could not resolve parent component`. One discovery walk (the
930
- * same walk and policy the server start uses) folds every eligible on-disk
931
- * file the cache does not know, then one re-analysis resolves the whole
932
- * extension graph — dependency depth never requires iteration depth, so the
933
- * loop re-enters only when a re-analysis leaves NEW drops and the previous
934
- * walk actually folded something (a changed discovery domain). The
935
- * iteration cap is containment, not semantics.
936
- *
937
- * Synchronous by design — it runs inside the sync `transform` hook. `.mdx`
938
- * sources need async preprocessing and are not folded here (they ingest on
939
- * their first watcher edit).
940
- *
941
- * Returns whether any re-analysis ran (callers diff plans across the WHOLE
942
- * transaction, so a fold-and-reanalyze is invisible to them beyond the
943
- * final manifest).
777
+ * Folds undiscovered on-disk files when the manifest reports an unresolved
778
+ * parent, then re-analyzes; the iteration cap is containment, not semantics.
944
779
  */
945
- function stabilizeSourceUniverse(ctx) {
780
+ async function reconcileSourceCorpus(ctx) {
946
781
  if (ctx.isProd) return false;
947
782
  let reanalyzed = false;
948
783
  for (let iteration = 0; iteration < 3; iteration++) {
@@ -953,12 +788,12 @@ function stabilizeSourceUniverse(ctx) {
953
788
  const drops = unresolvedParentDrops(ctx);
954
789
  const dropKey = drops.map((d) => `${d.file}\0${d.component}\0${d.parent}`).sort().join("\n");
955
790
  const memo = barrenWalkMemos.get(ctx);
956
- if (memo && memo.dropKey === dropKey && memo.cacheSize === ctx.fileCache.size) return reanalyzed;
791
+ if (memo && memo.dropKey === dropKey && memo.cacheGeneration === ctx.fileCacheGeneration) return reanalyzed;
957
792
  const folded = foldUndiscoveredFiles(ctx);
958
793
  if (folded.length === 0) {
959
794
  barrenWalkMemos.set(ctx, {
960
795
  dropKey,
961
- cacheSize: ctx.fileCache.size
796
+ cacheGeneration: ctx.fileCacheGeneration
962
797
  });
963
798
  warnInadmissibleParents(ctx, drops);
964
799
  return reanalyzed;
@@ -968,28 +803,24 @@ function stabilizeSourceUniverse(ctx) {
968
803
  reanalyzed = true;
969
804
  let published = false;
970
805
  try {
971
- published = ctx.runAnalysis(buildFileEntriesFromCache(ctx.fileCache)) !== false;
806
+ published = (await ctx.analyzeIngested()).ok;
972
807
  } finally {
973
- if (!published) for (const key of folded) ctx.fileCache.delete(key);
808
+ if (!published) ctx.mutateFileCache((cache) => {
809
+ for (const key of folded) cache.delete(key);
810
+ });
974
811
  }
975
812
  if (!published) return reanalyzed;
976
813
  }
977
- if (unresolvedDropFiles(ctx).size > 0) ctx.warn("source-universe rediscovery did not stabilize after bounded retries");
814
+ if (unresolvedDropFiles(ctx).size > 0) ctx.warn("source-corpus rediscovery did not stabilize after bounded retries");
978
815
  return reanalyzed;
979
816
  }
980
- /**
981
- * One discovery walk over the project root with the server-start policy;
982
- * folds every eligible file `fileCache` does not hold. Returns the fold
983
- * count.
984
- */
985
- /** Returns the cache keys this fold ADDED, so a failed analysis can roll
986
- * them back — `runAnalysis` requires callers that advanced the file cache to
987
- * restore it, or the content-hash gate suppresses the equal-content retry
988
- * forever. */
817
+ /** Returns the cache keys this fold added: a failed analysis must restore
818
+ * the cache, or the content-hash gate suppresses the retry forever. */
989
819
  function foldUndiscoveredFiles(ctx) {
990
- const excludePatterns = ctx.options.exclude ?? DEFAULT_EXCLUDE;
991
- const filePaths = (0, _animus_ui_extract_pipeline.discoverFiles)(ctx.rootDir, ctx.rootDir, excludePatterns, ctx.extensionsSet);
820
+ const excludeMatcher = ctx.excludeMatcher;
821
+ const filePaths = (0, _animus_ui_extract_pipeline.discoverFiles)(ctx.rootDir, ctx.rootDir, excludeMatcher, ctx.extensionsSet);
992
822
  const folded = [];
823
+ const pending = [];
993
824
  for (const filePath of filePaths) {
994
825
  if ((0, path.extname)(filePath) === ".mdx") continue;
995
826
  const relPath = (0, path.relative)(ctx.rootDir, filePath);
@@ -1000,38 +831,35 @@ function foldUndiscoveredFiles(ctx) {
1000
831
  } catch {
1001
832
  continue;
1002
833
  }
1003
- ctx.fileCache.set(relPath, {
834
+ pending.push([relPath, {
1004
835
  hash: (0, _animus_ui_extract_pipeline.contentHash)(source),
1005
836
  source
1006
- });
837
+ }]);
1007
838
  folded.push(relPath);
1008
839
  }
840
+ if (pending.length > 0) ctx.mutateFileCache((cache) => {
841
+ for (const [relPath, entry] of pending) cache.set(relPath, entry);
842
+ });
1009
843
  return folded;
1010
844
  }
1011
- /** Barren-walk memo per context: the drop tuple-set and cache size at the
1012
- * last walk that folded nothing (see stabilizeSourceUniverse). */
845
+ /** Per context: the drop tuple-set and the cache mutation generation at the
846
+ * last walk that folded nothing. */
1013
847
  const barrenWalkMemos = /* @__PURE__ */ new WeakMap();
1014
848
  /** Per-context (file, parent, condition) verdicts already warned — each
1015
849
  * condition warns once; cleared when the drops disappear. */
1016
850
  const warnedVerdicts = /* @__PURE__ */ new WeakMap();
1017
- /** The narrow probe set is deliberate — NOT ctx.extensionsSet (conscious
1018
- * no-behavior-change choice: the teaching probe resolves what it always did). */
1019
- const PARENT_PROBE_EXTENSIONS = new Set([
851
+ const PARENT_PROBE_EXTENSIONS = /* @__PURE__ */ new Set([
1020
852
  ".tsx",
1021
853
  ".ts",
1022
854
  ".jsx",
1023
855
  ".js"
1024
856
  ]);
1025
857
  /**
1026
- * The teaching half for parents that RESOLVE on disk but cannot be admitted
1027
- * (excluded by configuration, unsupported source type, outside the root).
1028
- * Resolution is a best-effort scan of the consumer's own import statements
1029
- * for the parent binding — enough for the relative-specifier case a
1030
- * developer actually hits; a parent this scan cannot resolve keeps the
1031
- * engine's own diagnostic.
858
+ * Warns for parents that resolve on disk but cannot be admitted. Resolution
859
+ * is a best-effort scan of the consumer's own relative import specifiers.
1032
860
  */
1033
861
  function warnInadmissibleParents(ctx, drops) {
1034
- const excludePatterns = ctx.options.exclude ?? DEFAULT_EXCLUDE;
862
+ const excludeMatcher = ctx.excludeMatcher;
1035
863
  let warned = warnedVerdicts.get(ctx);
1036
864
  if (!warned) {
1037
865
  warned = /* @__PURE__ */ new Set();
@@ -1047,8 +875,8 @@ function warnInadmissibleParents(ctx, drops) {
1047
875
  const resolved = (0, _animus_ui_extract_pipeline.resolveAbsolutePathSpecifier)((0, path.resolve)(ctx.rootDir, (0, path.dirname)(drop.file), specifier), PARENT_PROBE_EXTENSIONS);
1048
876
  if (!resolved) continue;
1049
877
  const relPath = (0, path.relative)(ctx.rootDir, resolved);
1050
- const excludedBy = excludePatterns.find((pattern) => resolved.includes(pattern) || relPath.includes(pattern));
1051
- const reason = excludedBy ? `which is excluded by pattern '${excludedBy}'. Include that file in the extraction universe or remove this extension.` : !(0, _animus_ui_extract_pipeline.isPathWithinRoot)(ctx.rootDir, resolved) ? "outside the extraction root. Declare its package in the system includes or move it under the project root." : !ctx.extensionsSet.has((0, path.extname)(resolved)) ? `whose extension is not in the configured source extensions.` : null;
878
+ const excludedBy = excludeMatcher.explain(resolved, relPath);
879
+ const reason = excludedBy ? `which is excluded by pattern '${excludedBy}'. Include that file in the extraction sources or remove this extension.` : !(0, _animus_ui_extract_pipeline.isPathWithinRoot)(ctx.rootDir, resolved) ? "outside the extraction root. Declare its package in the system includes or move it under the project root." : !ctx.extensionsSet.has((0, path.extname)(resolved)) ? `whose extension is not in the configured source extensions.` : null;
1052
880
  if (reason === null) continue;
1053
881
  const verdictKey = `${drop.file}\0${drop.parent}\0${reason}`;
1054
882
  if (warned.has(verdictKey)) continue;
@@ -1056,7 +884,6 @@ function warnInadmissibleParents(ctx, drops) {
1056
884
  ctx.warn(`cannot extract '${drop.component}': parent '${drop.parent}' resolves to ${relPath}, ${reason}`);
1057
885
  }
1058
886
  }
1059
- /** The module specifier a named import binds `binding` from, if scannable. */
1060
887
  function importSpecifierFor(source, binding) {
1061
888
  const importRe = /import\s+([^;]+?)\s+from\s+['"]([^'"]+)['"]/g;
1062
889
  let match;
@@ -1069,25 +896,17 @@ function importSpecifierFor(source, binding) {
1069
896
  //#endregion
1070
897
  //#region src/transform.ts
1071
898
  /**
1072
- * The dev-mode bridge prepend, shared with the hot-update gate so both
1073
- * compute byte-identical served output. The import goes AFTER the directive
1074
- * prologue the engine hoists to byte 0 — prepending above it would demote
1075
- * 'use client'/'use strict' to an ordinary expression statement, silently
1076
- * un-marking client modules on exactly the RSC-capable hosts this delivery
1077
- * path serves.
899
+ * The import goes after the directive prologue: above it, 'use client'
900
+ * becomes an ordinary expression and the module loses its client marking.
1078
901
  */
1079
902
  function applyDevBridgeImport(code) {
1080
903
  const prologue = /^(?:(['"])use [a-z -]+\1;?\r?\n)*/.exec(code)?.[0] ?? "";
1081
904
  return prologue + `import '${VIRTUAL_BRIDGE_ID}';\n` + code.slice(prologue.length);
1082
905
  }
1083
- /**
1084
- * Live raw-fallback files reachable from `relPath`'s components through
1085
- * extension provenance — the transitive closure, so a grandparent's serve is
1086
- * withheld exactly like a direct parent's.
1087
- */
1088
906
  function rawFallbackDescendants(ctx, relPath) {
907
+ const manifest = ctx.storedManifest;
1089
908
  const conflicted = /* @__PURE__ */ new Set();
1090
- const queue = [...ctx.storedManifest?.files?.[relPath] ?? []];
909
+ const queue = [...manifest?.files[relPath] ?? []];
1091
910
  const seen = new Set(queue);
1092
911
  while (queue.length > 0) {
1093
912
  const id = queue.shift();
@@ -1095,62 +914,51 @@ function rawFallbackDescendants(ctx, relPath) {
1095
914
  if (seen.has(childId)) continue;
1096
915
  seen.add(childId);
1097
916
  queue.push(childId);
1098
- const childFile = ctx.storedManifest?.components?.[childId]?.file;
917
+ const childFile = manifest?.components[childId]?.file;
1099
918
  if (childFile && childFile !== relPath && ctx.rawExtensionFallbacks.has(childFile)) conflicted.add(childFile);
1100
919
  }
1101
920
  }
1102
921
  return [...conflicted].sort();
1103
922
  }
1104
- /**
1105
- * transform: replace builder chains with `createComponent()` calls using
1106
- * the pre-built manifest; detect files created after buildStart and fold
1107
- * them into the analysis.
1108
- *
1109
- * The HMR bridge is NOT injected here — `transformIndexHtml` delivers it as a
1110
- * `<script type="module">` per served document (openspec:
1111
- * dev-stylesheet-management, "HMR bridge auto-injected in dev mode"; "Transform
1112
- * emitter unchanged" forbids the emitter importing it).
1113
- */
1114
- function transformSource(ctx, code, id) {
923
+ async function transformSource(ctx, code, id) {
1115
924
  if (!ctx.storedManifest) return null;
1116
925
  if (id.startsWith("\0") || id.startsWith("virtual:animus/")) return null;
1117
926
  const isExternalPkg = ctx.externalPackageDirs.some((dir) => (0, _animus_ui_extract_pipeline.isPathWithinRoot)(dir, id));
1118
927
  const relativePath = (0, path.relative)(ctx.rootDir, id);
1119
928
  if (!isExternalPkg) {
1120
- if (!/\.[jt]sx?$/.test(id)) return null;
929
+ if (!(0, _animus_ui_extract_pipeline.isEngineTransformExtension)(id)) return null;
1121
930
  if (id.includes("node_modules")) return null;
1122
931
  if (!(0, _animus_ui_extract_pipeline.isPathWithinRoot)(ctx.rootDir, id)) return null;
1123
932
  }
1124
- if (!ctx.storedManifest.files?.[relativePath]?.length) {
1125
- if (!ctx.isProd && !ctx.fileCache.has(relativePath)) {
933
+ if (!ctx.storedManifest.files[relativePath]?.length) {
934
+ if (!ctx.isProd && !ctx.fileCache.has(relativePath)) await runExclusiveAnalysis(ctx, async () => {
935
+ if (ctx.fileCache.has(relativePath)) return;
1126
936
  if (isExternalPkg) {
1127
937
  const owner = Object.entries(ctx.externalDirOwners).find(([dir]) => (0, _animus_ui_extract_pipeline.isPathWithinRoot)(dir, id));
1128
938
  if (owner) ctx.externalFileOwners[relativePath] = owner[1];
1129
939
  }
1130
940
  const hash = (0, _animus_ui_extract_pipeline.contentHash)(code);
1131
- ctx.fileCache.set(relativePath, {
941
+ ctx.mutateFileCache((cache) => cache.set(relativePath, {
1132
942
  hash,
1133
943
  source: code
1134
- });
944
+ }));
1135
945
  const prevPlans = (0, _animus_ui_extract_pipeline.snapshotFilePlans)(ctx.storedManifest);
1136
- const fileEntries = buildFileEntriesFromCache(ctx.fileCache, relativePath);
1137
946
  let analysisOk = false;
1138
947
  try {
1139
- analysisOk = ctx.runAnalysis(fileEntries) !== false;
948
+ analysisOk = (await ctx.analyzeIngested()).ok;
1140
949
  } finally {
1141
- if (!analysisOk) ctx.fileCache.delete(relativePath);
950
+ if (!analysisOk) ctx.mutateFileCache((cache) => cache.delete(relativePath));
1142
951
  }
1143
952
  if (analysisOk) {
1144
- stabilizeSourceUniverse(ctx);
953
+ await reconcileSourceCorpus(ctx);
1145
954
  invalidateFileModules(ctx, (0, _animus_ui_extract_pipeline.diffFilePlans)(prevPlans, (0, _animus_ui_extract_pipeline.snapshotFilePlans)(ctx.storedManifest), { exclude: relativePath }));
1146
- const compCount = ctx.storedManifest.files?.[relativePath]?.length ?? 0;
955
+ const compCount = ctx.storedManifest?.files[relativePath]?.length ?? 0;
1147
956
  ctx.info(`New file detected: ${relativePath} — ${compCount ? `${compCount} components extracted` : "no components"}`);
1148
957
  ctx.invalidateExtractedModules();
1149
958
  }
1150
- }
1151
- if (!ctx.storedManifest.files?.[relativePath]?.length) {
1152
- if (!ctx.isProd) if (unresolvedDropFiles(ctx).has(relativePath)) ctx.rawExtensionFallbacks.add(relativePath);
1153
- else ctx.rawExtensionFallbacks.delete(relativePath);
959
+ });
960
+ if (!ctx.storedManifest.files[relativePath]?.length) {
961
+ ctx.recordFallbackState(relativePath, unresolvedDropFiles(ctx).has(relativePath));
1154
962
  return null;
1155
963
  }
1156
964
  }
@@ -1166,17 +974,20 @@ function transformSource(ctx, code, id) {
1166
974
  try {
1167
975
  const { transformFile } = ctx.engineApi();
1168
976
  const result = transformFile(code, relativePath, ctx.storedManifestJson);
1169
- if (!result.hasComponents) return null;
977
+ if (!result.hasComponents) {
978
+ ctx.recordFallbackState(relativePath, true);
979
+ return null;
980
+ }
1170
981
  if (ctx.verbose) {
1171
- const compCount = ctx.storedManifest.files?.[relativePath]?.length ?? 0;
982
+ const compCount = ctx.storedManifest.files[relativePath]?.length ?? 0;
1172
983
  ctx.log(`transform ${relativePath}: ${compCount} components`);
1173
984
  }
1174
985
  let outputCode = result.code;
1175
986
  if (!ctx.isProd) {
1176
987
  outputCode = applyDevBridgeImport(result.code);
1177
988
  ctx.recordTransformOutput(relativePath, outputCode);
1178
- ctx.rawExtensionFallbacks.delete(relativePath);
1179
989
  }
990
+ ctx.recordFallbackState(relativePath, false);
1180
991
  return {
1181
992
  code: outputCode,
1182
993
  map: null
@@ -1184,90 +995,60 @@ function transformSource(ctx, code, id) {
1184
995
  } catch (e) {
1185
996
  if (ctx.options.strict) throw new Error(`[animus-extract] Failed to transform ${id}: ${e}`, { cause: e });
1186
997
  console.warn(`[animus-extract] Failed to transform ${id}:`, e);
998
+ ctx.recordFallbackState(relativePath, true);
1187
999
  return null;
1188
1000
  }
1189
1001
  }
1190
1002
  //#endregion
1191
1003
  //#region src/hmr.ts
1192
- /**
1193
- * hotUpdate: the single dev file-event hook. Handles system-dependency
1194
- * membership (geological reset), content-hash diffing with incremental
1195
- * re-analysis, deleted-file cache pruning, and targeted module invalidation
1196
- * (component CSS, system props, and definition files whose replacement
1197
- * changed).
1198
- *
1199
- * Vite 8 dispatches this hook once per environment for ONE file event — the
1200
- * client environment first, then every non-client environment (see
1201
- * `handleHMRUpdate` in vite/dist/node/chunks/node.js). The analysis half is
1202
- * therefore claimed by exactly one dispatch (`ctx.hotUpdateEvents`), while the
1203
- * invalidation half runs in every environment against its own module graph —
1204
- * what the mixed-graph `handleHotUpdate` used to achieve implicitly by
1205
- * invalidating the client and SSR instance behind one module node.
1206
- *
1207
- * The hook fires for every watched file whether or not it has modules in any
1208
- * graph, so system dependencies registered through `watcher.add` outside the
1209
- * root still reach the reset branch, with an empty `modules` list.
1210
- */
1211
- async function handleHotUpdate(ctx, environment, { type, file, timestamp, modules, read }) {
1004
+ /** Vite dispatches this hook once per environment for one file event: the
1005
+ * analysis half is claimed by one dispatch, invalidation runs in each. */
1006
+ async function handleHotUpdate(ctx, environment, options) {
1212
1007
  if (ctx.isProd) return;
1008
+ return runExclusiveAnalysis(ctx, () => handleHotUpdateExclusive(ctx, environment, options));
1009
+ }
1010
+ async function handleHotUpdateExclusive(ctx, environment, { type, file, timestamp, modules, read }) {
1213
1011
  const ownsEvent = ctx.hotUpdateEvents.claim(environment.name, file, timestamp);
1214
1012
  const absFile = (0, path.resolve)(file);
1215
1013
  ctx.log(`hotUpdate ${type} ${(0, path.relative)(ctx.rootDir, absFile)} env=${environment.name} owns=${ownsEvent}`);
1216
1014
  if (ctx.isSystemDependency(absFile)) {
1217
1015
  if (ownsEvent) {
1218
1016
  await reconcileSourceEntry(ctx, absFile, type, read);
1219
- ctx.requestGeologicalReset((0, path.relative)(ctx.rootDir, absFile));
1017
+ ctx.requestSystemReload((0, path.relative)(ctx.rootDir, absFile));
1220
1018
  }
1221
1019
  return [];
1222
1020
  }
1223
1021
  if (type === "delete") {
1224
- if (ownsEvent) pruneDeletedFile(ctx, absFile);
1022
+ if (ownsEvent) await pruneDeletedFile(ctx, absFile);
1225
1023
  return;
1226
1024
  }
1227
1025
  if (ownsEvent) ctx.hotUpdateEvents.record(file, timestamp, await analyzeChangedFile(ctx, file, absFile, read));
1228
1026
  const result = ctx.hotUpdateEvents.resultOf(file, timestamp);
1229
1027
  if (result.kind === "ignored") return;
1028
+ if (result.kind === "evicted") {
1029
+ ctx.log(`HMR (${environment.name}): decision for ${(0, path.relative)(ctx.rootDir, absFile)} evicted — invalidating conservatively`);
1030
+ return invalidateStaleModules(ctx, environment, modules, {
1031
+ staleDefinitionFiles: [],
1032
+ systemPropsChanged: true,
1033
+ presentationOnly: false
1034
+ });
1035
+ }
1230
1036
  if (result.kind === "unchanged") {
1231
1037
  if (type === "create") return;
1232
1038
  return [];
1233
1039
  }
1234
1040
  return invalidateStaleModules(ctx, environment, modules, result);
1235
1041
  }
1236
- /**
1237
- * The changed file's text.
1238
- *
1239
- * Editors save atomically — truncate, then rewrite — so a watcher event can
1240
- * arrive while the path is momentarily EMPTY. Vite's `read()` helper retries on
1241
- * empty content for exactly that reason; reading the path directly at the same
1242
- * moment yields `''`, and since the corrective content produces no second
1243
- * event, that empty source would be cached permanently.
1244
- *
1245
- * Vite always supplies `read`, so the direct read is NOT a fallback for Vite:
1246
- * it exists for hosts that drive this hook without one. The dev-lane's adapter
1247
- * contract is deliberately bundler-neutral, and a second runtime satisfying it
1248
- * must not be forced to fabricate a helper to get correct behavior.
1249
- */
1042
+ /** Prefers Vite's `read`, which retries on the empty content an atomic save
1043
+ * exposes; the direct read serves hosts that supply no `read`. */
1250
1044
  async function readChangedSource(absFile, read) {
1251
1045
  return read ? await read() : (0, fs.readFileSync)(absFile, "utf-8");
1252
1046
  }
1253
- /**
1254
- * Reconcile the `fileCache` entry of a file that is BOTH a system dependency
1255
- * and a discovered component source, since the dependency branch is terminal —
1256
- * neither the edit path's cache write nor `pruneDeletedFile` runs for it.
1257
- *
1258
- * An edit refreshes the entry: `performGeologicalReset` rebuilds its
1259
- * full-source analysis from this cache, so a stale entry would be re-analyzed
1260
- * on every later reset. A delete prunes it, exactly as the ordinary delete path
1261
- * would (openspec: hmr-new-file-detection, "Watcher deletion pruning") — a
1262
- * surviving entry is a ghost source no watcher event can ever name again.
1263
- *
1264
- * Dependency-only files have no entry: `pruneFileCache` no-ops for them, and
1265
- * the edit path must not create one — the cache is the component-source set,
1266
- * and a phantom entry would feed the engine a file it never discovered.
1267
- */
1047
+ /** Keeps the cache entry of a file that is both a system dependency and a
1048
+ * discovered source; a file with no entry must never gain one here. */
1268
1049
  async function reconcileSourceEntry(ctx, absFile, type, read) {
1269
1050
  if (type === "delete") {
1270
- pruneFileCache(ctx.fileCache, ctx.rootDir, absFile);
1051
+ ctx.mutateFileCache((cache) => pruneFileCache(cache, ctx.rootDir, absFile));
1271
1052
  return;
1272
1053
  }
1273
1054
  const relPath = (0, path.relative)(ctx.rootDir, absFile);
@@ -1276,25 +1057,22 @@ async function reconcileSourceEntry(ctx, absFile, type, read) {
1276
1057
  try {
1277
1058
  source = await readChangedSource(absFile, read);
1278
1059
  } catch {
1279
- ctx.warn(`could not re-read ${relPath} after a system-dependency edit — geological resets will analyze its pre-edit text until a later edit is read successfully`);
1060
+ ctx.warn(`could not re-read ${relPath} after a system-dependency edit — system reloads will analyze its pre-edit text until a later edit is read successfully`);
1280
1061
  return;
1281
1062
  }
1282
- ctx.fileCache.set(relPath, {
1063
+ ctx.mutateFileCache((cache) => cache.set(relPath, {
1283
1064
  hash: (0, _animus_ui_extract_pipeline.contentHash)(source),
1284
1065
  source
1285
- });
1066
+ }));
1286
1067
  }
1287
- /**
1288
- * The once-per-event analysis half: gate the file, diff its content hash,
1289
- * refresh the cache, and re-run project analysis. The returned result is what
1290
- * the remaining environments act on.
1291
- */
1068
+ /** The once-per-event analysis half; its result is what the remaining
1069
+ * environments act on. */
1292
1070
  async function analyzeChangedFile(ctx, file, absFile, read) {
1293
1071
  const ext = (0, path.extname)(file);
1294
1072
  if (!ctx.extensionsSet.has(ext)) return { kind: "ignored" };
1295
- const excludePatterns = ctx.options.exclude ?? DEFAULT_EXCLUDE;
1073
+ const excludeMatcher = ctx.excludeMatcher;
1296
1074
  const isExternalPkg = ctx.externalPackageDirs.some((dir) => (0, _animus_ui_extract_pipeline.isPathWithinRoot)(dir, file));
1297
- if (!isExternalPkg && excludePatterns.some((pattern) => file.includes(pattern) || (0, path.relative)(ctx.rootDir, file).includes(pattern))) return { kind: "ignored" };
1075
+ if (!isExternalPkg && excludeMatcher.matches(file, (0, path.relative)(ctx.rootDir, file))) return { kind: "ignored" };
1298
1076
  const relPath = (0, path.relative)(ctx.rootDir, absFile);
1299
1077
  let source;
1300
1078
  try {
@@ -1302,41 +1080,32 @@ async function analyzeChangedFile(ctx, file, absFile, read) {
1302
1080
  } catch {
1303
1081
  return { kind: "ignored" };
1304
1082
  }
1305
- let scannerRelPath = relPath;
1306
- if (ext === ".mdx") {
1307
- const result = await (0, _animus_ui_extract_pipeline.preprocessMdx)(source, relPath);
1308
- if (result.kind === "missing-dep") {
1309
- ctx.warn("⚠ .mdx HMR skipped: @mdx-js/mdx not installed; restart dev server after installing");
1310
- return { kind: "ignored" };
1311
- }
1312
- if (result.kind === "error") {
1313
- ctx.warn(`⚠ MDX preprocessing failed for ${relPath}: ${result.error}`);
1314
- return { kind: "ignored" };
1315
- }
1316
- source = result.source;
1317
- scannerRelPath = relPath + ".tsx";
1318
- }
1319
1083
  const hash = (0, _animus_ui_extract_pipeline.contentHash)(source);
1320
- const cached = ctx.fileCache.get(scannerRelPath);
1084
+ const cached = ctx.fileCache.get(relPath);
1321
1085
  if (cached && cached.hash === hash) {
1322
- ctx.log(`HMR skip: ${scannerRelPath} (unchanged)`);
1086
+ ctx.log(`HMR skip: ${relPath} (unchanged)`);
1323
1087
  return { kind: "unchanged" };
1324
1088
  }
1325
- if (isExternalPkg && !ctx.externalFileOwners[scannerRelPath]) {
1089
+ const priorExternalOwner = ctx.externalFileOwners[relPath];
1090
+ if (isExternalPkg && !priorExternalOwner) {
1326
1091
  const owner = Object.entries(ctx.externalDirOwners).find(([dir]) => (0, _animus_ui_extract_pipeline.isPathWithinRoot)(dir, absFile));
1327
- if (owner) ctx.externalFileOwners[scannerRelPath] = owner[1];
1092
+ if (owner) ctx.externalFileOwners[relPath] = owner[1];
1328
1093
  }
1329
- ctx.fileCache.set(scannerRelPath, {
1094
+ ctx.mutateFileCache((cache) => cache.set(relPath, {
1330
1095
  hash,
1331
1096
  source
1332
- });
1097
+ }));
1333
1098
  const restoreEntry = () => {
1334
- if (cached) ctx.fileCache.set(scannerRelPath, cached);
1335
- else ctx.fileCache.delete(scannerRelPath);
1099
+ ctx.mutateFileCache((cache) => {
1100
+ if (cached) cache.set(relPath, cached);
1101
+ else cache.delete(relPath);
1102
+ });
1103
+ if (priorExternalOwner) ctx.externalFileOwners[relPath] = priorExternalOwner;
1104
+ else delete ctx.externalFileOwners[relPath];
1336
1105
  };
1337
1106
  const hmrStart = performance.now();
1338
1107
  const prevPlans = (0, _animus_ui_extract_pipeline.snapshotFilePlans)(ctx.storedManifest);
1339
- const directComponentIds = ctx.storedManifest?.files?.[scannerRelPath] ?? [];
1108
+ const directComponentIds = (ctx.corpus.published.ownership[relPath]?.analysisPaths ?? [relPath]).flatMap((analysisPath) => ctx.storedManifest?.files[analysisPath] ?? []);
1340
1109
  const invalidatedIds = new Set(directComponentIds);
1341
1110
  const queue = [...directComponentIds];
1342
1111
  while (queue.length > 0) {
@@ -1351,11 +1120,10 @@ async function analyzeChangedFile(ctx, file, absFile, read) {
1351
1120
  }
1352
1121
  if (ctx.verbose && invalidatedIds.size > directComponentIds.length) ctx.log(`HMR: ${directComponentIds.length} direct + ${invalidatedIds.size - directComponentIds.length} transitive components invalidated`);
1353
1122
  const analysisStart = performance.now();
1354
- const fileEntries = buildFileEntriesFromCache(ctx.fileCache, relPath);
1355
1123
  const systemPropsBefore = systemPropsModuleSource(ctx);
1356
1124
  let analysisOk;
1357
1125
  try {
1358
- analysisOk = ctx.runAnalysis(fileEntries) !== false;
1126
+ analysisOk = (await ctx.analyzeIngested()).ok;
1359
1127
  } catch (e) {
1360
1128
  restoreEntry();
1361
1129
  throw e;
@@ -1365,7 +1133,7 @@ async function analyzeChangedFile(ctx, file, absFile, read) {
1365
1133
  return { kind: "ignored" };
1366
1134
  }
1367
1135
  try {
1368
- stabilizeSourceUniverse(ctx);
1136
+ await reconcileSourceCorpus(ctx);
1369
1137
  } catch (e) {
1370
1138
  restoreEntry();
1371
1139
  throw e;
@@ -1373,7 +1141,8 @@ async function analyzeChangedFile(ctx, file, absFile, read) {
1373
1141
  const systemPropsChanged = systemPropsModuleSource(ctx) !== systemPropsBefore;
1374
1142
  const analysisMs = Math.round(performance.now() - analysisStart);
1375
1143
  const staleDefinitionFiles = (0, _animus_ui_extract_pipeline.diffFilePlans)(prevPlans, (0, _animus_ui_extract_pipeline.snapshotFilePlans)(ctx.storedManifest)).filter((defFile) => (0, path.resolve)(ctx.rootDir, defFile) !== absFile);
1376
- const presentationOnly = isPresentationOnlyEdit(ctx, scannerRelPath, source);
1144
+ const nativeEntry = ctx.corpus.published.analysisEntries.get(relPath);
1145
+ const presentationOnly = nativeEntry ? isPresentationOnlyEdit(ctx, relPath, nativeEntry.source) : false;
1377
1146
  const hmrMs = Math.round(performance.now() - hmrStart);
1378
1147
  ctx.log(`HMR update: ${relPath} — analysis ${analysisMs}ms, total ${hmrMs}ms${presentationOnly ? " (presentation-only)" : ""}`);
1379
1148
  ctx.logTimingWaterfall(ctx.storedManifest?.timing ?? {});
@@ -1384,23 +1153,12 @@ async function analyzeChangedFile(ctx, file, absFile, read) {
1384
1153
  presentationOnly
1385
1154
  };
1386
1155
  }
1387
- /**
1388
- * True when the changed file's transform output is byte-identical across the
1389
- * edit — a presentation-only change (style values are not part of the emitted
1390
- * replacement, whose class names hash `filename::binding`).
1391
- *
1392
- * Compares a POST-analysis re-transform of the file (the engine transforms
1393
- * from its retained facts, so this runs against the just-updated analysis)
1394
- * with the hash of what the module LAST SERVED (`recordTransformOutput` in
1395
- * the transform hook, bridge import included). Comparing the full served
1396
- * output — never replacement strings — is what keeps a mixed style+code edit
1397
- * deliverable. Fails open: a transform error, a file the manifest does not
1398
- * list, or a module that never served (no recorded hash) delivers normally.
1399
- */
1156
+ /** True when the file's transform output is byte-identical across the edit.
1157
+ * Compares the full served bytes and fails open, so mixed edits deliver. */
1400
1158
  function isPresentationOnlyEdit(ctx, scannerRelPath, source) {
1401
1159
  const servedHash = ctx.transformOutputHashes.get(scannerRelPath);
1402
1160
  if (!servedHash || !ctx.storedManifestJson) return false;
1403
- if (!ctx.storedManifest?.files?.[scannerRelPath]?.length) return false;
1161
+ if (!ctx.storedManifest?.files[scannerRelPath]?.length) return false;
1404
1162
  try {
1405
1163
  const { transformFile } = ctx.engineApi();
1406
1164
  const fresh = transformFile(source, scannerRelPath, ctx.storedManifestJson);
@@ -1410,28 +1168,21 @@ function isPresentationOnlyEdit(ctx, scannerRelPath, source) {
1410
1168
  return false;
1411
1169
  }
1412
1170
  }
1413
- /**
1414
- * Reconcile a deleted file in dev.
1415
- *
1416
- * Without this the removed file's last-known source stays in `ctx.fileCache`,
1417
- * and `buildFileEntriesFromCache` re-feeds that ghost entry to the engine on
1418
- * every later re-analysis — the deleted component's CSS survives for the life
1419
- * of the process.
1420
- */
1421
- function pruneDeletedFile(ctx, absFile) {
1422
- if (!pruneFileCache(ctx.fileCache, ctx.rootDir, absFile)) return;
1171
+ /** Prune a deleted file from the cache: a surviving entry is re-fed to the
1172
+ * engine forever, so the deleted component's CSS never disappears. */
1173
+ async function pruneDeletedFile(ctx, absFile) {
1174
+ const relPath = (0, path.relative)(ctx.rootDir, absFile);
1175
+ if (!ctx.fileCache.get(relPath)) return;
1176
+ if (!ctx.mutateFileCache((cache) => pruneFileCache(cache, ctx.rootDir, absFile))) return;
1423
1177
  const prevPlans = (0, _animus_ui_extract_pipeline.snapshotFilePlans)(ctx.storedManifest);
1424
- const ok = ctx.runAnalysis(buildFileEntriesFromCache(ctx.fileCache)) !== false;
1178
+ const { ok } = await ctx.analyzeIngested();
1179
+ if (!ok) return;
1425
1180
  ctx.log(`Deleted file pruned: ${(0, path.relative)(ctx.rootDir, absFile)}`);
1426
- if (ok) invalidateFileModules(ctx, (0, _animus_ui_extract_pipeline.diffFilePlans)(prevPlans, (0, _animus_ui_extract_pipeline.snapshotFilePlans)(ctx.storedManifest)));
1181
+ invalidateFileModules(ctx, (0, _animus_ui_extract_pipeline.diffFilePlans)(prevPlans, (0, _animus_ui_extract_pipeline.snapshotFilePlans)(ctx.storedManifest)));
1427
1182
  ctx.invalidateExtractedModules();
1428
1183
  }
1429
- /**
1430
- * The per-environment invalidation half: invalidate the modules this
1431
- * environment serves and widen its update set with them. Static CSS
1432
- * (virtual:animus/styles.css) is NOT invalidated here — it only changes on a
1433
- * geological reset (vars/globals are stable during dev).
1434
- */
1184
+ /** The per-environment invalidation half. Static CSS is left alone: it only
1185
+ * changes on a system reload, since vars and globals are stable in dev. */
1435
1186
  function invalidateStaleModules(ctx, environment, modules, analyzed) {
1436
1187
  const graph = environment.moduleGraph;
1437
1188
  const modulesToUpdate = analyzed.presentationOnly ? [] : [...modules];
@@ -1467,29 +1218,8 @@ function invalidateStaleModules(ctx, environment, modules, analyzed) {
1467
1218
  //#endregion
1468
1219
  //#region src/index-html.ts
1469
1220
  /**
1470
- * transformIndexHtml: build the head-prepend tag set.
1471
- *
1472
- * Three independent tags, all delivery-only:
1473
- * - the appearance bootstrap script, when the host build passed a
1474
- * pre-generated artifact via `appearanceBootstrap` (absent otherwise);
1475
- * - the `@layer` declaration style, when `buildStart` computed one;
1476
- * - the HMR bridge module script, in dev only.
1477
- *
1478
- * The two build-time branches are guarded on non-empty CONTENT, not on mere
1479
- * presence: an artifact whose `code` is empty is a caller defect, and emitting
1480
- * `<script data-animus-bootstrap><\/script>` for it would break the
1481
- * unconfigured-parity contract in spirit (a tag that does nothing) while
1482
- * hiding the defect. Symmetric with `layerDeclaration`. The bridge has no
1483
- * content of its own — it is a `src` reference — so its only guard is the mode.
1484
- *
1485
- * Ordering: every descriptor rides the same `head-prepend` bucket, which Vite
1486
- * serializes in array order (`serializeTags`) and splices in immediately after
1487
- * `<head>`. The bootstrap script is therefore FIRST in the array so it runs
1488
- * before the layer declaration and — since built stylesheet links are appended
1489
- * at `</head>` — before any stylesheet reference.
1490
- *
1491
- * With neither build-time input set, a production build emits no tag,
1492
- * attribute, or whitespace of its own.
1221
+ * Vite serializes one `head-prepend` bucket in array order, so the bootstrap
1222
+ * script precedes the layer declaration and every stylesheet reference.
1493
1223
  */
1494
1224
  function buildIndexHtmlTags(ctx) {
1495
1225
  const tags = [];
@@ -1519,7 +1249,13 @@ function buildIndexHtmlTags(ctx) {
1519
1249
  }
1520
1250
  //#endregion
1521
1251
  //#region src/virtual-modules.ts
1522
- /** resolveId: map virtual ids and redirect external DS package imports. */
1252
+ /**
1253
+ * Keyed on the whole option record: two instances sharing a key means the
1254
+ * later `replaceSync` erases the earlier one's component CSS.
1255
+ */
1256
+ function sheetRegistryHash(ctx) {
1257
+ return (0, crypto.createHash)("md5").update((0, _animus_ui_extract_pipeline.stableStringify)(ctx.options)).digest("hex").slice(0, 8);
1258
+ }
1523
1259
  function resolveVirtualId(ctx, id) {
1524
1260
  if (id === VIRTUAL_CSS_ID) return RESOLVED_CSS_ID;
1525
1261
  if (id === VIRTUAL_COMPONENTS_ID) return RESOLVED_COMPONENTS_ID;
@@ -1529,11 +1265,9 @@ function resolveVirtualId(ctx, id) {
1529
1265
  if (srcEntry) return srcEntry;
1530
1266
  return null;
1531
1267
  }
1532
- /** load: serve the virtual stylesheet, components CSS, HMR bridge, and
1533
- * system-props modules from manifest-derived state. */
1534
1268
  function loadVirtualModule(ctx, id) {
1535
1269
  const lcssOpts = {
1536
- minify: ctx.options.minify ?? ctx.isProd,
1270
+ minify: ctx.options.minify ?? ctx.emissionProd,
1537
1271
  targets: ctx.lcssTargets,
1538
1272
  warnFn: (msg) => ctx.warn(msg)
1539
1273
  };
@@ -1559,19 +1293,22 @@ function loadVirtualModule(ctx, id) {
1559
1293
  });
1560
1294
  return [variables, (0, _animus_ui_extract_pipeline.postProcessCss)(body, lcssOpts)].filter(Boolean).join("\n");
1561
1295
  }
1562
- if (id === RESOLVED_COMPONENTS_ID) return `export default \`${(0, _animus_ui_extract_pipeline.postProcessCss)((0, _animus_ui_extract_pipeline.stripLeadingLayerDeclaration)(ctx.resolvedComponentCss || ""), {
1563
- ...lcssOpts,
1564
- minify: false
1565
- }).replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$/g, "\\$")}\`;`;
1566
- if (id === RESOLVED_BRIDGE_ID) return `
1296
+ if (id === RESOLVED_COMPONENTS_ID) {
1297
+ const strippedCss = (0, _animus_ui_extract_pipeline.stripLeadingLayerDeclaration)(ctx.resolvedComponentCss || "");
1298
+ return `export default \`${(0, _animus_ui_extract_pipeline.postProcessCss)(strippedCss, {
1299
+ ...lcssOpts,
1300
+ minify: false
1301
+ }).replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$/g, "\\$")}\`;`;
1302
+ }
1303
+ if (id === RESOLVED_BRIDGE_ID) {
1304
+ const sheetHash = sheetRegistryHash(ctx);
1305
+ return `
1567
1306
  import css from '${VIRTUAL_COMPONENTS_ID}';
1568
1307
 
1569
- const GLOBAL_KEY = '__animus_sheet_${(0, crypto.createHash)("md5").update(ctx.options.system).digest("hex").slice(0, 8)}__';
1308
+ const GLOBAL_KEY = '__animus_sheet_${sheetHash}__';
1570
1309
  let sheet = globalThis[GLOBAL_KEY] || null;
1571
1310
 
1572
- // Server evaluation is a no-op: SSR hosts reach this module through the
1573
- // bridge import prepended to transformed component modules, and only the
1574
- // browser pass owns a document.
1311
+ // Server evaluation is a no-op: only the browser pass owns a document.
1575
1312
  if (typeof document !== 'undefined') {
1576
1313
  if (typeof CSSStyleSheet !== 'undefined' && 'adoptedStyleSheets' in document) {
1577
1314
  if (!sheet) {
@@ -1581,11 +1318,10 @@ if (typeof document !== 'undefined') {
1581
1318
  }
1582
1319
  sheet.replaceSync(css);
1583
1320
  } else {
1584
- // Fallback: inject or update <style> tag
1585
- let el = document.querySelector('style[data-animus-components]');
1321
+ let el = document.querySelector('style[data-animus-components="${sheetHash}"]');
1586
1322
  if (!el) {
1587
1323
  el = document.createElement('style');
1588
- el.setAttribute('data-animus-components', '');
1324
+ el.setAttribute('data-animus-components', '${sheetHash}');
1589
1325
  document.head.appendChild(el);
1590
1326
  }
1591
1327
  el.textContent = css;
@@ -1594,36 +1330,39 @@ if (typeof document !== 'undefined') {
1594
1330
 
1595
1331
  if (import.meta.hot) {
1596
1332
  import.meta.hot.accept('${VIRTUAL_COMPONENTS_ID}', (newModule) => {
1597
- // The server module runner has a hot channel too; only the browser
1598
- // pass owns a document to update.
1333
+ // The server module runner has a hot channel too, but owns no document.
1599
1334
  if (typeof document === 'undefined') return;
1600
1335
  if (sheet) {
1601
1336
  sheet.replaceSync(newModule.default);
1602
1337
  } else {
1603
- const el = document.querySelector('style[data-animus-components]');
1338
+ const el = document.querySelector('style[data-animus-components="${sheetHash}"]');
1604
1339
  if (el) el.textContent = newModule.default;
1605
1340
  }
1606
1341
  });
1607
1342
  }
1608
1343
  `;
1344
+ }
1609
1345
  if (id === RESOLVED_SYSTEM_PROPS_ID) return systemPropsModuleSource(ctx);
1610
1346
  return null;
1611
1347
  }
1612
1348
  //#endregion
1613
1349
  //#region src/index.ts
1614
- /**
1615
- * Vite adapter for the extraction pipeline. State and pipeline operations
1616
- * live in PluginContext; hook bodies live in their own modules — this
1617
- * factory only validates options and wires Vite hooks to those functions.
1618
- */
1619
1350
  function animusExtract(options) {
1620
1351
  (0, _animus_ui_extract_pipeline.assertNoRetiredEngineSelection)(options.engine);
1352
+ (0, _animus_ui_extract_pipeline.assertKnownOptionKeys)(options, ["verify", "appearanceBootstrap"], [{
1353
+ key: "root",
1354
+ reason: "the Vite driver derives its root from the resolved Vite config"
1355
+ }], {
1356
+ onUnknownKey: "warn",
1357
+ warn: (message) => console.warn(`[animus-extract] ${message}`)
1358
+ });
1621
1359
  const ctx = new PluginContext(options);
1622
1360
  return {
1623
1361
  name: "animus-extract",
1624
1362
  enforce: "pre",
1625
1363
  config(_config, env) {
1626
- return { define: { __ANIMUS_DEV__: env.command !== "build" } };
1364
+ const { mode } = (0, _animus_ui_extract_pipeline.resolveMode)(options.mode, () => env.command === "build" ? "production" : "development");
1365
+ return { define: { __ANIMUS_DEV__: mode === "development" } };
1627
1366
  },
1628
1367
  configureServer(server) {
1629
1368
  ctx.devServer = server;