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