@animus-ui/next-plugin 0.1.7 → 0.1.9

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
@@ -1,5 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_singleton = require("./singleton-DH7yeWZa.cjs");
2
+ const require_session_paths = require("./session-paths-CZoi19NW.cjs");
3
+ const require_singleton = require("./singleton-BjZyw_8b.cjs");
4
+ require("./turbopack-loader.cjs");
3
5
  let _animus_ui_extract_pipeline = require("@animus-ui/extract/pipeline");
4
6
  let fs = require("fs");
5
7
  let path = require("path");
@@ -94,6 +96,21 @@ function logBuildTimings(bt, rustTiming, log, verbose) {
94
96
  * alias harvesting must skip.
95
97
  */
96
98
  const ANIMUS_CSS_MODULE_ID = ".animus/styles.css";
99
+ /** Retention window for sibling session directories (design D2). */
100
+ const SESSION_DIR_MAX_AGE_MS = 1440 * 60 * 1e3;
101
+ /** Watchdog added to the debounce ceiling for status deadlines (D3). */
102
+ const STATUS_WATCHDOG_MS = 2e3;
103
+ /** Flat legacy `.animus/` artifacts removed at session start — unreachable
104
+ * by session-scoped loaders and no longer written by anyone. */
105
+ const LEGACY_FLAT_ARTIFACTS = [
106
+ require_session_paths.MANIFEST_ARTIFACT,
107
+ require_session_paths.ANALYSIS_INPUTS_ARTIFACT,
108
+ require_session_paths.STYLES_ARTIFACT,
109
+ require_session_paths.SYSTEM_PROPS_ARTIFACT,
110
+ require_session_paths.REPLACEMENT_EPOCH_ARTIFACT,
111
+ require_session_paths.ANALYSIS_COMMIT_ARTIFACT,
112
+ require_session_paths.ANALYSIS_STATUS_ARTIFACT
113
+ ];
97
114
  /** Default path fragments excluded from source discovery (full + watch). */
98
115
  const DEFAULT_EXCLUDE = [
99
116
  "node_modules",
@@ -128,15 +145,18 @@ var ExtractionSession = class {
128
145
  /** Physical asset files registered with the host watcher. */
129
146
  assetDependencyPaths = /* @__PURE__ */ new Set();
130
147
  assetDependencyKeys = /* @__PURE__ */ new Set();
131
- /** When set (Turbopack orchestration), every analysis also persists
132
- * `.animus/analysis-inputs.json` so isolated loader workers can hydrate.
133
- * Webpack mode leaves this off — its loader shares the process. */
134
- persistAnalysisInputs = false;
135
148
  /** Emitter identity override for the system-props module id. Webpack mode
136
149
  * (null) injects the absolute `.animus/system-props.js` path, resolved by
137
150
  * NormalModuleReplacement; Turbopack rejects absolute-path imports, so
138
151
  * its driver sets the virtual id that `resolveAlias` maps to disk. */
139
152
  systemPropsModuleId = null;
153
+ /** Whether the analysis-inputs hydration corpus is serialized + written.
154
+ * False (webpack mode): the loader shares the pipeline process and reads
155
+ * the manifest from memory — the corpus is never persisted. True is set
156
+ * by the Turbopack orchestration driver, whose isolated loader workers
157
+ * replay it (spec: next-turbopack-integration, "Manifest disk artifact" /
158
+ * "Webpack mode skips the hydration corpus"). */
159
+ persistAnalysisInputs = false;
140
160
  /** Absolute directory prefixes for external DS packages (loader allowlisting). */
141
161
  externalPackageDirs = [];
142
162
  /** Absolute package dir → owning specifier (cross-source correlation). */
@@ -145,6 +165,45 @@ var ExtractionSession = class {
145
165
  externalFileOwners = {};
146
166
  /** External package specifier → absolute source entry path. */
147
167
  externalSourceEntries = /* @__PURE__ */ new Map();
168
+ /** SourceId derivation authority for the current generation (openspec:
169
+ * external-source-watch-ingestion, design D2) — canonical roots
170
+ * realpath'd once per full pipeline; alias→SourceId associations
171
+ * recorded while files exist so deletion resolves through the cache. */
172
+ sourceIdentity = null;
173
+ /** Canonical admitted external root → declared specifiers (set-valued
174
+ * ownership, design D2; duplicate specifiers resolving to one canonical
175
+ * root share it). Engine watch wiring and tests read this. */
176
+ externalRootOwners = /* @__PURE__ */ new Map();
177
+ /** Canonical external root → the extension set its collection walk used
178
+ * (widened for dist-only roots); rewalks and classification share it. */
179
+ externalRootExtensions = /* @__PURE__ */ new Map();
180
+ /** Canonical admitted external watch roots — the engine-side watch
181
+ * surface (webpack contextDependencies / Turbopack watchers). */
182
+ externalWatchRoots = [];
183
+ /** Canonical external root → (raw sourceKey → {raw content hash, recorded
184
+ * abs spelling}): the previous-generation inventory a dirty-root rewalk
185
+ * diffs against (design D3). Raw hashes — MDX preprocessing never skews
186
+ * the diff; the recorded abs path is a registered deletion alias, so a
187
+ * reconstructed deletion always resolves through cached identity. */
188
+ externalInventory = /* @__PURE__ */ new Map();
189
+ /** Sticky diagnostics (design D5/D7): stable key → message, re-surfaced
190
+ * on every full pipeline until the underlying condition clears. */
191
+ stickyDiagnostics = /* @__PURE__ */ new Map();
192
+ /** Volume-membership predicate for the cross-volume gate (design D5).
193
+ * Injected seam: the shared predicate only discriminates on Windows
194
+ * (win32 semantics are unit-tested via path.win32), so runtime tests
195
+ * drive the gate through this. */
196
+ sharesProjectVolume = _animus_ui_extract_pipeline.sharesVolumeRoot;
197
+ /** Orchestrator seams (openspec: external-source-watch-ingestion, design
198
+ * D4). `onExternalRootResolved` fires per volume-admitted external root
199
+ * DURING collection — after resolution, BEFORE that root's sources are
200
+ * walked — so a watching host can open its handle with no blind gap
201
+ * between scan and watch. `onExternalRootsCommitted` fires once after a
202
+ * successful full-pipeline publication with the complete admitted
203
+ * canonical root set (the host's promote/replay/close-old phase; a
204
+ * failed pipeline never fires it, so the host rolls back). */
205
+ onExternalRootResolved = null;
206
+ onExternalRootsCommitted = null;
148
207
  options;
149
208
  staticCssJson;
150
209
  system = null;
@@ -158,15 +217,49 @@ var ExtractionSession = class {
158
217
  /** Loader-reported dependency paths for bundler watch registration. */
159
218
  systemDependencyPaths = [];
160
219
  systemDependencyHashes = /* @__PURE__ */ new Map();
161
- lastCssHash = null;
162
220
  lastSystemPropsHash = null;
163
- lastManifestHash = null;
164
- lastAnalysisInputsHash = null;
221
+ /** Per-payload write guards: payloadHash gates rewrites (byte-identical
222
+ * payloads leave disk untouched); diskHash is the hash of the CURRENT
223
+ * disk bytes (envelope included) recorded in the analysis-commit. null =
224
+ * not yet seeded (first publication reads the artifact's envelope so a
225
+ * same-session restart never rewrites byte-identical artifacts). */
226
+ artifactRecords = {
227
+ manifest: null,
228
+ inputs: null,
229
+ styles: null
230
+ };
231
+ /** Last written analysis-commit; null = seeded from disk on first use. */
232
+ lastCommit = null;
233
+ /** Session identity — claimed once per PROCESS (one Next invocation),
234
+ * adopted by every subsequent session instance so all compilers share
235
+ * one artifact tree (design D2: `next dev`/`next build` co-writing are
236
+ * separate processes and therefore separate sessions). */
237
+ sessionId = require_singleton.claimProcessSessionId();
238
+ /** Epoch value of the last published analysis; null = not yet known
239
+ * (seeded from the session dir's artifact on first publication so a
240
+ * same-session restart with unchanged plans never rewrites bytes). */
241
+ lastEpochValue = null;
242
+ /** Watcher debounce ceiling feeding status deadlines (design D3). */
243
+ debounceCeilingMs = 75;
244
+ /** Test seam: observes every session-artifact write (name, content)
245
+ * post-rename — write ORDER is part of the transaction contract. */
246
+ onArtifactWrite = null;
247
+ /** Status attempt bookkeeping (design D3). */
248
+ statusAttemptId = 0;
249
+ statusAttemptOpen = false;
250
+ /** Debounce-window observations pending analysis (sourceKey → hash). */
251
+ debouncePending = /* @__PURE__ */ new Map();
252
+ /** Session-start hygiene (pruning + legacy cleanup) runs once. */
253
+ sessionStartHygieneDone = false;
165
254
  lcssTargets = null;
166
255
  constructor(options) {
167
256
  this.options = options;
168
257
  this.staticCssJson = (0, _animus_ui_extract_pipeline.serializeStaticCss)(options.staticCss);
169
258
  }
259
+ /** This session's artifact directory. Requires `rootDir` to be set. */
260
+ get sessionDir() {
261
+ return require_session_paths.sessionArtifactDir(this.rootDir, this.sessionId);
262
+ }
170
263
  get verbose() {
171
264
  return this.options.verbose === true || process.env.ANIMUS_DEBUG === "1" || process.env.ANIMUS_DEBUG === "true";
172
265
  }
@@ -182,24 +275,87 @@ var ExtractionSession = class {
182
275
  elapsed(t) {
183
276
  return this.verbose ? Math.round(performance.now() - t) : 0;
184
277
  }
278
+ /** Lazily-computed scan config (options are constructor-fixed, so the
279
+ * derivation is stable for the session's lifetime). */
280
+ scanConfigMemo = null;
185
281
  /** Resolve the scan configuration from options — the single source of the
186
282
  * exclude/extension policy shared by the full and incremental pipelines. */
187
283
  resolveScanConfig() {
188
- const extensionsSet = new Set(this.options.extensions ?? _animus_ui_extract_pipeline.DEFAULT_EXTENSIONS);
189
- return {
190
- excludePatterns: this.options.exclude ?? DEFAULT_EXCLUDE,
191
- extensionsSet,
192
- shouldHandleMdx: extensionsSet.has(".mdx")
193
- };
284
+ if (this.scanConfigMemo === null) {
285
+ const extensionsSet = new Set(this.options.extensions ?? _animus_ui_extract_pipeline.DEFAULT_EXTENSIONS);
286
+ this.scanConfigMemo = {
287
+ excludePatterns: this.options.exclude ?? DEFAULT_EXCLUDE,
288
+ extensionsSet,
289
+ shouldHandleMdx: extensionsSet.has(".mdx")
290
+ };
291
+ }
292
+ return this.scanConfigMemo;
293
+ }
294
+ /**
295
+ * Watch entry point: one single-flight analysis transaction per event
296
+ * batch (openspec: next-webpack-served-transform-coherence, design D3).
297
+ * The first entering session runs the transaction; every concurrent
298
+ * entry — same session re-entered, or another compiler's session
299
+ * (client/server/RSC each hold their own instance) — joins the in-flight
300
+ * promise, so no compiler proceeds against a generation older than the
301
+ * one the transaction publishes. A rejected transaction rejects every
302
+ * joiner; the gate always clears.
303
+ */
304
+ async handleWatchUpdate(changes) {
305
+ const inflight = require_singleton.getWatchTransaction();
306
+ if (inflight) {
307
+ await inflight;
308
+ return;
309
+ }
310
+ if (!this.system) {
311
+ const owner = require_singleton.getOwningWatchSession();
312
+ const hasBatch = (changes.modifiedFiles?.size ?? 0) > 0 || (changes.removedFiles?.size ?? 0) > 0;
313
+ if (owner && owner !== this && hasBatch) await owner.ingestForwardedBatch(changes);
314
+ return;
315
+ }
316
+ const transaction = this.processWatchUpdate(changes);
317
+ require_singleton.setWatchTransaction(transaction);
318
+ try {
319
+ await transaction;
320
+ } finally {
321
+ require_singleton.setWatchTransaction(null);
322
+ }
323
+ }
324
+ /**
325
+ * Owner-side entry for a batch observed by a NON-OWNING compiler's
326
+ * watcher. Serializes behind any in-flight transaction, then runs the
327
+ * forwarded batch as its own transaction — never drops it (a forwarded
328
+ * batch has no other delivery; content-hash diffing makes an
329
+ * already-covered batch a cheap no-op).
330
+ */
331
+ async ingestForwardedBatch(changes) {
332
+ if (!this.system) return;
333
+ for (;;) {
334
+ const inflight = require_singleton.getWatchTransaction();
335
+ if (!inflight) break;
336
+ await inflight;
337
+ }
338
+ const transaction = this.processWatchUpdate(changes);
339
+ require_singleton.setWatchTransaction(transaction);
340
+ try {
341
+ await transaction;
342
+ } finally {
343
+ require_singleton.setWatchTransaction(null);
344
+ }
194
345
  }
195
346
  /**
196
347
  * Incremental watch pass: geological reset when the system file changed,
197
348
  * otherwise content-hash diffing restricted to the watcher's change sets
198
349
  * (falling back to a full discovery walk when no sets are provided).
199
350
  */
200
- async handleWatchUpdate(changes) {
201
- if (!this.system) return;
351
+ async processWatchUpdate(changes) {
202
352
  const rootDir = this.rootDir;
353
+ try {
354
+ changes = this.reconcileExternalRoots(changes);
355
+ } catch (err) {
356
+ this.warn(`external root reconciliation failed: ${String(err)}`);
357
+ }
358
+ const changed = [...changes.modifiedFiles ?? [], ...changes.removedFiles ?? []];
203
359
  let assetChanged = false;
204
360
  try {
205
361
  let systemHit;
@@ -213,16 +369,16 @@ var ExtractionSession = class {
213
369
  break;
214
370
  }
215
371
  }
216
- else systemHit = [...changes.modifiedFiles ?? [], ...changes.removedFiles ?? []].find((path$2) => (0, _animus_ui_extract_pipeline.toWatchKeys)(path$2).some((key) => this.systemDependencyKeys.has(key)));
372
+ else systemHit = changed.find((path$2) => (0, _animus_ui_extract_pipeline.toWatchKeys)(path$2).some((key) => this.systemDependencyKeys.has(key)));
217
373
  if (systemHit) {
218
374
  this.log(`geological reset: system dependency changed (${(0, path.relative)(rootDir, systemHit)})`);
219
375
  this.resetForHmr();
220
- const promise = this.runFullPipeline();
376
+ const promise = this.runFullPipeline(this.pendingFromBatch(changes));
221
377
  require_singleton.setAnalysisPromise(promise);
222
378
  await promise;
223
379
  return;
224
380
  }
225
- if ([...changes.modifiedFiles ?? [], ...changes.removedFiles ?? []].some((path$3) => (0, _animus_ui_extract_pipeline.toWatchKeys)(path$3).some((key) => this.assetDependencyKeys.has(key)))) {
381
+ if (changed.some((path$3) => (0, _animus_ui_extract_pipeline.toWatchKeys)(path$3).some((key) => this.assetDependencyKeys.has(key)))) {
226
382
  this.assetCopyCache.clear();
227
383
  assetChanged = true;
228
384
  }
@@ -230,53 +386,122 @@ var ExtractionSession = class {
230
386
  this.warn(`HMR geological-reset check failed: ${String(err)}`);
231
387
  }
232
388
  const { excludePatterns, extensionsSet, shouldHandleMdx } = this.resolveScanConfig();
389
+ const priorCacheEntries = /* @__PURE__ */ new Map();
390
+ const recordPrior = (key) => {
391
+ if (!priorCacheEntries.has(key)) priorCacheEntries.set(key, this.fileCache.get(key) ?? null);
392
+ };
393
+ const inventoryUpdates = [];
394
+ const ownerRemovals = [];
233
395
  let removedAny = false;
234
396
  if (changes.removedFiles) for (const removedPath of changes.removedFiles) {
235
- const rel = (0, path.relative)(rootDir, removedPath);
236
- if (this.fileCache.delete(rel) || this.fileCache.delete(rel + ".tsx")) removedAny = true;
397
+ let key;
398
+ let owningRoot = null;
399
+ if ((0, _animus_ui_extract_pipeline.isPathWithinRoot)(rootDir, removedPath)) key = (0, path.relative)(rootDir, removedPath);
400
+ else {
401
+ const resolved = this.sourceIdentity?.resolveDeletedSourceId(removedPath);
402
+ if (!resolved) continue;
403
+ key = resolved.sourceKey;
404
+ owningRoot = resolved.owningRoot;
405
+ }
406
+ recordPrior(key);
407
+ recordPrior(key + ".tsx");
408
+ if (this.fileCache.delete(key) || this.fileCache.delete(key + ".tsx")) {
409
+ removedAny = true;
410
+ if (owningRoot) {
411
+ inventoryUpdates.push({
412
+ root: owningRoot,
413
+ key,
414
+ hash: null,
415
+ abs: removedPath
416
+ });
417
+ ownerRemovals.push(key, key + ".tsx");
418
+ }
419
+ }
237
420
  }
238
- let files;
421
+ let targets;
239
422
  if (changes.modifiedFiles) {
240
- files = [];
423
+ targets = [];
241
424
  for (const modifiedPath of changes.modifiedFiles) {
242
- if (!extensionsSet.has((0, path.extname)(modifiedPath))) continue;
243
- const rel = (0, path.relative)(rootDir, modifiedPath);
244
- if (rel.startsWith("..")) continue;
245
- if (excludePatterns.some((pattern) => modifiedPath.includes(pattern) || rel.includes(pattern))) continue;
246
- files.push(modifiedPath);
425
+ const classified = this.classifyWatchPath(modifiedPath, {
426
+ excludePatterns,
427
+ extensionsSet
428
+ });
429
+ if (classified) targets.push({
430
+ abs: modifiedPath,
431
+ ...classified
432
+ });
247
433
  }
248
- } else files = (0, _animus_ui_extract_pipeline.discoverFiles)(rootDir, rootDir, excludePatterns, extensionsSet);
434
+ } else targets = (0, _animus_ui_extract_pipeline.discoverFiles)(rootDir, rootDir, excludePatterns, extensionsSet).map((abs) => ({
435
+ abs,
436
+ key: (0, path.relative)(rootDir, abs),
437
+ owningRoot: null
438
+ }));
249
439
  const changedPaths = [];
250
- for (const filePath of files) {
251
- let relPath = (0, path.relative)(rootDir, filePath);
440
+ for (const target of targets) {
441
+ let relPath = target.key;
252
442
  let source;
253
443
  try {
254
- source = (0, fs.readFileSync)(filePath, "utf-8");
444
+ source = (0, fs.readFileSync)(target.abs, "utf-8");
255
445
  } catch {
256
446
  continue;
257
447
  }
258
- if (shouldHandleMdx && (0, path.extname)(filePath) === ".mdx") {
448
+ const rawHash = (0, _animus_ui_extract_pipeline.contentHash)(source);
449
+ if (shouldHandleMdx && (0, path.extname)(target.abs) === ".mdx") {
259
450
  const processed = await this.preprocessMdxEntry(source, relPath, { warn: false });
260
451
  if (!processed) continue;
261
452
  source = processed.source;
262
453
  relPath = processed.relPath;
263
454
  }
264
455
  const cached = this.fileCache.get(relPath);
265
- const hash = (0, _animus_ui_extract_pipeline.contentHash)(source);
456
+ const hash = relPath === target.key ? rawHash : (0, _animus_ui_extract_pipeline.contentHash)(source);
266
457
  if (!cached || cached.hash !== hash) {
267
458
  changedPaths.push(relPath);
459
+ recordPrior(relPath);
268
460
  this.fileCache.set(relPath, {
269
461
  hash,
270
462
  source
271
463
  });
464
+ if (target.owningRoot) {
465
+ const owner = this.externalRootOwners.get(target.owningRoot)?.values().next().value;
466
+ if (owner !== void 0) this.externalFileOwners[relPath] ??= owner;
467
+ inventoryUpdates.push({
468
+ root: target.owningRoot,
469
+ key: target.key,
470
+ hash: rawHash,
471
+ abs: target.abs
472
+ });
473
+ }
272
474
  }
273
475
  }
274
476
  if (changedPaths.length > 0 || removedAny || assetChanged) {
275
477
  const fileEntries = this.buildFileEntriesFromCache();
478
+ const pending = changedPaths.map((rel) => [rel, this.fileCache.get(rel).hash]);
276
479
  require_singleton.resetAnalysisPromise();
277
- const promise = this.runIncrementalPipeline(fileEntries);
480
+ const promise = this.runIncrementalPipeline(fileEntries, pending);
278
481
  require_singleton.setAnalysisPromise(promise);
279
- await promise;
482
+ try {
483
+ await promise;
484
+ } catch (err) {
485
+ for (const [key, prior] of priorCacheEntries) if (prior === null) this.fileCache.delete(key);
486
+ else this.fileCache.set(key, prior);
487
+ throw err;
488
+ }
489
+ for (const update of inventoryUpdates) {
490
+ let inventory = this.externalInventory.get(update.root);
491
+ if (!inventory) {
492
+ inventory = /* @__PURE__ */ new Map();
493
+ this.externalInventory.set(update.root, inventory);
494
+ }
495
+ if (update.hash === null) inventory.delete(update.key);
496
+ else inventory.set(update.key, {
497
+ hash: update.hash,
498
+ abs: update.abs
499
+ });
500
+ }
501
+ for (const key of ownerRemovals) delete this.externalFileOwners[key];
502
+ } else if (this.statusAttemptOpen) {
503
+ this.debouncePending.clear();
504
+ this.writeAnalysisStatus("idle", []);
280
505
  }
281
506
  }
282
507
  /**
@@ -307,9 +532,11 @@ var ExtractionSession = class {
307
532
  relPath: relPath + ".tsx"
308
533
  };
309
534
  }
310
- async runFullPipeline() {
535
+ async runFullPipeline(pending = []) {
311
536
  const pipelineStart = this.now();
312
537
  const bt = {};
538
+ this.runSessionStartHygiene();
539
+ require_singleton.setSessionArtifactDir(this.sessionDir);
313
540
  (0, _animus_ui_extract_pipeline.clearEngineCache)(require_singleton.engineApi);
314
541
  const rootDir = this.rootDir;
315
542
  const resolvedSystemPath = (0, path.resolve)(rootDir, this.options.system);
@@ -373,6 +600,7 @@ var ExtractionSession = class {
373
600
  t = this.now();
374
601
  const packageNames = (0, _animus_ui_extract_pipeline.extractSystemFilePackages)(resolvedSystemPath);
375
602
  const preResolved = resolvePackagesByName(rootDir, packageNames);
603
+ const rawExternalFiles = /* @__PURE__ */ new Map();
376
604
  const collected = await (0, _animus_ui_extract_pipeline.collectExternalPackageSources)({
377
605
  specifiers: packageNames,
378
606
  resolveSpecifier: (name) => preResolved[name] ? (0, path.resolve)(rootDir, preResolved[name]) : null,
@@ -380,6 +608,7 @@ var ExtractionSession = class {
380
608
  extensionsSet,
381
609
  hasEntry: (relPath) => fileEntries.some((e) => e.path === relPath),
382
610
  preprocessFile: async (source, relPath, absPath) => {
611
+ rawExternalFiles.set(absPath, (0, _animus_ui_extract_pipeline.contentHash)(source));
383
612
  if (shouldHandleMdx && (0, path.extname)(absPath) === ".mdx") return this.preprocessMdxEntry(source, relPath, {
384
613
  warn: true,
385
614
  missingDepFlag
@@ -389,7 +618,16 @@ var ExtractionSession = class {
389
618
  relPath
390
619
  };
391
620
  },
392
- onUnreadable: (relPath, err) => this.warn(`skipped unreadable package file ${relPath}: ${String(err)}`)
621
+ onUnreadable: (relPath, err) => this.warn(`skipped unreadable package file ${relPath}: ${String(err)}`),
622
+ onPackageResolved: (_specifier, packageDir) => {
623
+ if (!this.onExternalRootResolved) return;
624
+ if (!this.sharesProjectVolume(rootDir, packageDir)) return;
625
+ let canonical = packageDir;
626
+ try {
627
+ canonical = (0, fs.realpathSync)(packageDir);
628
+ } catch {}
629
+ this.onExternalRootResolved(canonical);
630
+ }
393
631
  });
394
632
  for (const record of collected.outcomes) if (record.outcome === "empty") this.warn(`include '${record.specifier}' resolved but discovered no component sources`);
395
633
  const unresolvableMessage = (0, _animus_ui_extract_pipeline.unresolvableIncludesMessage)(collected.outcomes);
@@ -402,12 +640,54 @@ var ExtractionSession = class {
402
640
  if (this.options.strict) throw new Error(staleDistMessage);
403
641
  this.warn(staleDistMessage);
404
642
  }
405
- const packageMap = collected.packageMap;
643
+ const rejectedSpecifiers = /* @__PURE__ */ new Set();
644
+ const crossVolumeDetails = [];
645
+ for (const [dir, specifiers] of Object.entries(collected.dirOwnerSets)) {
646
+ if (this.sharesProjectVolume(rootDir, dir)) continue;
647
+ for (const specifier of specifiers) rejectedSpecifiers.add(specifier);
648
+ crossVolumeDetails.push(`${specifiers.join(", ")} → ${dir}`);
649
+ }
650
+ this.stickyDiagnostics.delete("cross-volume");
651
+ if (rejectedSpecifiers.size > 0) {
652
+ const message = `ANIMUS_EXTERNAL_CROSS_VOLUME_UNSUPPORTED: external package source root(s) on a different volume than the project root (${rootDir}): ${crossVolumeDetails.join("; ")} — cross-volume workspace sources are unsupported; the package(s) are excluded from extraction and watching`;
653
+ if (this.options.strict) throw new Error(`[animus-next] ${message}`);
654
+ this.stickyDiagnostics.set("cross-volume", message);
655
+ }
656
+ for (const message of this.stickyDiagnostics.values()) this.warn(message);
657
+ const admitted = (0, _animus_ui_extract_pipeline.excludeCollectedPackages)(collected, rejectedSpecifiers, rootDir);
658
+ const identity = (0, _animus_ui_extract_pipeline.createSourceIdentity)(rootDir);
659
+ this.externalRootOwners = /* @__PURE__ */ new Map();
660
+ this.externalRootExtensions = /* @__PURE__ */ new Map();
661
+ for (const [dir, specifiers] of Object.entries(admitted.dirOwnerSets)) {
662
+ const canonical = identity.registerExternalRoot(dir);
663
+ const owners = this.externalRootOwners.get(canonical) ?? /* @__PURE__ */ new Set();
664
+ for (const specifier of specifiers) owners.add(specifier);
665
+ this.externalRootOwners.set(canonical, owners);
666
+ const exts = admitted.dirExtensions[dir];
667
+ if (exts) this.externalRootExtensions.set(canonical, new Set(exts));
668
+ }
669
+ this.sourceIdentity = identity;
670
+ this.externalWatchRoots = identity.externalRoots();
671
+ this.externalInventory = /* @__PURE__ */ new Map();
672
+ for (const [absPath, rawHash] of rawExternalFiles) {
673
+ const resolved = identity.resolveSourceId(absPath);
674
+ if (!resolved?.owningRoot) continue;
675
+ let inventory = this.externalInventory.get(resolved.owningRoot);
676
+ if (!inventory) {
677
+ inventory = /* @__PURE__ */ new Map();
678
+ this.externalInventory.set(resolved.owningRoot, inventory);
679
+ }
680
+ inventory.set(resolved.sourceKey, {
681
+ hash: rawHash,
682
+ abs: absPath
683
+ });
684
+ }
685
+ const packageMap = admitted.packageMap;
406
686
  this.lastPackageMap = packageMap;
407
- this.externalDirOwners = collected.dirOwners;
408
- this.externalFileOwners = collected.fileOwners;
409
- this.externalSourceEntries = collected.sourceEntries;
410
- for (const entry of collected.entries) {
687
+ this.externalDirOwners = (0, _animus_ui_extract_pipeline.firstOwners)(admitted.dirOwnerSets);
688
+ this.externalFileOwners = admitted.fileOwners;
689
+ this.externalSourceEntries = admitted.sourceEntries;
690
+ for (const entry of admitted.entries) {
411
691
  const hash = (0, _animus_ui_extract_pipeline.contentHash)(entry.source);
412
692
  this.fileCache.set(entry.path, {
413
693
  hash,
@@ -419,30 +699,160 @@ var ExtractionSession = class {
419
699
  hash
420
700
  });
421
701
  }
422
- this.externalPackageDirs = collected.packageDirs;
423
- require_singleton.setSharedExternalDirs(collected.packageDirs);
424
- require_singleton.setSharedExternalEntries(collected.sourceEntries);
425
- if (this.system && collected.sourceEntries.size > 0) {
702
+ this.externalPackageDirs = admitted.packageDirs;
703
+ require_singleton.setSharedExternalDirs(admitted.packageDirs);
704
+ require_singleton.setSharedExternalEntries(admitted.sourceEntries);
705
+ if (this.system && admitted.keyframesScanEntries.size > 0) {
426
706
  const api = require_singleton.engineApi();
427
- const merge = (0, _animus_ui_extract_pipeline.mergeExternalKeyframes)((entry, root) => api.scanKeyframesExports(entry, root), this.system.keyframesJson, collected.sourceEntries.values(), this.rootDir);
707
+ const merge = (0, _animus_ui_extract_pipeline.mergeExternalKeyframes)((entry, root) => api.scanKeyframesExports(entry, root), this.system.keyframesJson, admitted.keyframesScanEntries.values(), this.rootDir);
428
708
  this.system.keyframesJson = merge.keyframesJson;
429
709
  this.externalKeyframesDiagnostics = merge.diagnostics;
430
- }
710
+ } else this.externalKeyframesDiagnostics = [];
431
711
  bt.packageResolve = this.elapsed(t);
432
- await this.analyzeAndEmit(fileEntries, packageMap, false, bt, pipelineStart);
712
+ const universe = new Set(fileEntries.map((entry) => entry.path));
713
+ for (const key of [...this.fileCache.keys()]) if (!universe.has(key)) this.fileCache.delete(key);
714
+ await this.analyzeAndEmit(fileEntries, packageMap, false, bt, pipelineStart, pending);
715
+ this.onExternalRootsCommitted?.(this.externalWatchRoots);
716
+ require_singleton.setOwningWatchSession(this);
433
717
  }
434
718
  /**
435
- * Reset analysis state for HMR geological reset.
719
+ * Reset analysis state for HMR geological reset. Payload write guards go
720
+ * back to null so the next publication reseeds them from the disk
721
+ * envelopes — a byte-identical post-reset artifact is still not
722
+ * rewritten.
436
723
  */
437
724
  resetForHmr() {
438
725
  require_singleton.resetAnalysisPromise();
439
- this.lastCssHash = null;
726
+ this.artifactRecords = {
727
+ manifest: null,
728
+ inputs: null,
729
+ styles: null
730
+ };
440
731
  this.lastSystemPropsHash = null;
441
- this.lastManifestHash = null;
442
- this.lastAnalysisInputsHash = null;
443
732
  (0, _animus_ui_extract_pipeline.clearEngineCache)(require_singleton.engineApi);
444
733
  }
445
734
  /**
735
+ * The (sourceKey, observedSourceHash) pairs of a watch batch — cheap
736
+ * evidence for the status file's pending set on the geological-reset
737
+ * path, where the batch's component edits ride along with the system
738
+ * edit (design D3: loaders wait only on observed inputs).
739
+ */
740
+ pendingFromBatch(changes) {
741
+ const { excludePatterns, extensionsSet } = this.resolveScanConfig();
742
+ const pending = [];
743
+ for (const path$4 of changes.modifiedFiles ?? []) {
744
+ const classified = this.classifyWatchPath(path$4, {
745
+ excludePatterns,
746
+ extensionsSet
747
+ });
748
+ if (!classified) continue;
749
+ try {
750
+ pending.push([classified.key, (0, _animus_ui_extract_pipeline.contentHash)((0, fs.readFileSync)(path$4, "utf-8"))]);
751
+ } catch {}
752
+ }
753
+ return pending;
754
+ }
755
+ /**
756
+ * Root-dirty inventory reconciliation (design D3): partition the
757
+ * watcher's change sets into explicit FILE events and external ROOT hits
758
+ * (a reported path that IS an admitted root, or a directory inside one),
759
+ * rewalk each dirty root through the shared discovery policy, diff the
760
+ * SourceId+raw-hash inventory against the previous generation, and merge
761
+ * the reconstructed created/edited/deleted deltas with the explicit
762
+ * events (identity-level dedup happens downstream at the cache gate).
763
+ * Deletion falls out of the inventory diff — webpack may report only the
764
+ * directory (probe-proven).
765
+ */
766
+ reconcileExternalRoots(changes) {
767
+ const identity = this.sourceIdentity;
768
+ if (!identity) return changes;
769
+ if (!changes.modifiedFiles && !changes.removedFiles) return changes;
770
+ if (this.externalWatchRoots.length === 0) return changes;
771
+ const { extensionsSet } = this.resolveScanConfig();
772
+ const dirtyRoots = /* @__PURE__ */ new Set();
773
+ const modified = /* @__PURE__ */ new Set();
774
+ const removed = /* @__PURE__ */ new Set();
775
+ const rootHitFor = (path$5) => {
776
+ const containing = identity.containingExternalRoot(path$5);
777
+ if (!containing) return null;
778
+ try {
779
+ return (0, fs.statSync)(path$5).isDirectory() ? containing : null;
780
+ } catch {
781
+ return identity.resolveDeletedSourceId(path$5) ? null : containing;
782
+ }
783
+ };
784
+ for (const path$6 of changes.modifiedFiles ?? []) {
785
+ const root = rootHitFor(path$6);
786
+ if (root) dirtyRoots.add(root);
787
+ else modified.add(path$6);
788
+ }
789
+ for (const path$7 of changes.removedFiles ?? []) {
790
+ const root = rootHitFor(path$7);
791
+ if (root) dirtyRoots.add(root);
792
+ else removed.add(path$7);
793
+ }
794
+ for (const root of dirtyRoots) {
795
+ const previous = this.externalInventory.get(root) ?? /* @__PURE__ */ new Map();
796
+ const seen = /* @__PURE__ */ new Set();
797
+ const walked = (0, _animus_ui_extract_pipeline.walkPackageSources)(root, this.externalRootExtensions.get(root) ?? extensionsSet);
798
+ if (walked.length === 0 && previous.size > 0) this.warn(`external root rewalk found no files under ${root} while its inventory holds ${previous.size} — reconciling as full deletion`);
799
+ for (const abs of walked) {
800
+ const resolved = identity.resolveSourceId(abs);
801
+ if (!resolved || resolved.owningRoot !== root) continue;
802
+ seen.add(resolved.sourceKey);
803
+ let rawHash;
804
+ try {
805
+ rawHash = (0, _animus_ui_extract_pipeline.contentHash)((0, fs.readFileSync)(abs, "utf-8"));
806
+ } catch {
807
+ continue;
808
+ }
809
+ if (previous.get(resolved.sourceKey)?.hash !== rawHash) modified.add(abs);
810
+ }
811
+ for (const [key, entry] of previous) if (!seen.has(key)) removed.add(entry.abs);
812
+ }
813
+ return {
814
+ modifiedFiles: modified,
815
+ removedFiles: removed
816
+ };
817
+ }
818
+ /**
819
+ * Route one watcher-reported path (design D1/D2): a project-root member
820
+ * (lexical containment — existing local semantics preserved; consumer
821
+ * exclude patterns apply) or an admitted external member (identity
822
+ * resolution with symlink-escape rejection; package-relative excludes
823
+ * mirror the collection walk's filters — guardrail G1, one policy).
824
+ * Returns null for dropped paths.
825
+ */
826
+ classifyWatchPath(absPath, scan) {
827
+ const ext = (0, path.extname)(absPath);
828
+ const rootDir = this.rootDir;
829
+ if ((0, _animus_ui_extract_pipeline.isPathWithinRoot)(rootDir, absPath)) {
830
+ if (!scan.extensionsSet.has(ext)) return null;
831
+ const rel = (0, path.relative)(rootDir, absPath);
832
+ if (scan.excludePatterns.some((pattern) => absPath.includes(pattern) || rel.includes(pattern))) return null;
833
+ return {
834
+ key: rel,
835
+ owningRoot: null
836
+ };
837
+ }
838
+ const resolved = this.sourceIdentity?.resolveSourceId(absPath);
839
+ if (!resolved) return null;
840
+ if (resolved.owningRoot === null) {
841
+ if (!scan.extensionsSet.has(ext)) return null;
842
+ if (scan.excludePatterns.some((pattern) => resolved.sourceKey.includes(pattern))) return null;
843
+ return {
844
+ key: resolved.sourceKey,
845
+ owningRoot: null
846
+ };
847
+ }
848
+ if (!(this.externalRootExtensions.get(resolved.owningRoot) ?? scan.extensionsSet).has(ext)) return null;
849
+ if ((0, _animus_ui_extract_pipeline.isExcludedPackageRelativePath)(resolved.pathInRoot)) return null;
850
+ return {
851
+ key: resolved.sourceKey,
852
+ owningRoot: resolved.owningRoot
853
+ };
854
+ }
855
+ /**
446
856
  * Build file entries from cache: every cached file rides with full source.
447
857
  * The v2 engine has NO Rust-side cache (arch-extract-v2-spine: uncached
448
858
  * re-analysis beats a cache-hit path), so it must always receive full sources
@@ -450,8 +860,8 @@ var ExtractionSession = class {
450
860
  */
451
861
  buildFileEntriesFromCache() {
452
862
  const entries = [];
453
- for (const [path$4, { hash, source }] of this.fileCache) entries.push({
454
- path: path$4,
863
+ for (const [path$8, { hash, source }] of this.fileCache) entries.push({
864
+ path: path$8,
455
865
  source,
456
866
  hash
457
867
  });
@@ -461,10 +871,10 @@ var ExtractionSession = class {
461
871
  * Run incremental pipeline with cache-aware file entries.
462
872
  * Reuses system config from the last full pipeline run.
463
873
  */
464
- async runIncrementalPipeline(fileEntries) {
874
+ async runIncrementalPipeline(fileEntries, pending = []) {
465
875
  const bt = {};
466
876
  const pipelineStart = this.now();
467
- await this.analyzeAndEmit(fileEntries, this.lastPackageMap, true, bt, pipelineStart);
877
+ await this.analyzeAndEmit(fileEntries, this.lastPackageMap, true, bt, pipelineStart, pending);
468
878
  }
469
879
  /**
470
880
  * Shared analysis + emit core for both pipelines — the single call site
@@ -482,27 +892,49 @@ var ExtractionSession = class {
482
892
  * - `true` (HMR): skips the report log, and guards the system-props.js
483
893
  * write by lastSystemPropsHash.
484
894
  *
485
- * The styles.css write guard (lastCssHash) is identical on both paths.
895
+ * The disk artifacts land under the session directory in transaction
896
+ * order (design D1): manifest → analysis-inputs (Turbopack orchestration
897
+ * only) → styles.css → system-props → analysis-commit →
898
+ * replacements-epoch (last, only when moved). The analysis-status file walks starting → analyzing →
899
+ * committing → idle around the attempt, landing in `failed` (with the
900
+ * diagnostic) on any throw (design D3).
486
901
  */
487
- async analyzeAndEmit(fileEntries, packageMap, devMode, bt, pipelineStart) {
902
+ async analyzeAndEmit(fileEntries, packageMap, devMode, bt, pipelineStart, pending = []) {
903
+ this.beginStatusAttempt();
904
+ this.debouncePending.clear();
905
+ this.writeAnalysisStatus("starting", pending);
906
+ try {
907
+ await this.analyzeAndEmitAttempt(fileEntries, packageMap, devMode, bt, pipelineStart, pending);
908
+ this.writeAnalysisStatus("idle", []);
909
+ } catch (err) {
910
+ this.writeAnalysisStatus("failed", pending, String(err));
911
+ throw err;
912
+ }
913
+ }
914
+ async analyzeAndEmitAttempt(fileEntries, packageMap, devMode, bt, pipelineStart, pending) {
488
915
  const system = this.system;
489
- const result = (0, _animus_ui_extract_pipeline.runProjectAnalysis)(require_singleton.engineApi, {
916
+ const analysisOptions = {
490
917
  fileEntries,
491
918
  packageMap,
492
919
  system,
493
920
  emitter: {
494
921
  runtimeImport: "@animus-ui/system/runtime",
495
922
  cssModuleId: ANIMUS_CSS_MODULE_ID,
496
- systemPropsModuleId: this.systemPropsModuleId ?? (0, path.join)(this.rootDir, ".animus", "system-props.js")
923
+ systemPropsModuleId: this.systemPropsModuleId ?? require_session_paths.systemPropsPath(this.sessionDir)
497
924
  },
498
925
  pathAliasesJson: this.pathAliasesJson,
499
926
  staticCssJson: this.staticCssJson,
500
927
  externalDirs: this.externalPackageDirs.map((dir) => (0, path.relative)(this.rootDir, dir)),
501
- devMode,
928
+ devMode
929
+ };
930
+ this.writeAnalysisStatus("analyzing", pending);
931
+ const result = (0, _animus_ui_extract_pipeline.runProjectAnalysis)(require_singleton.engineApi, {
932
+ ...analysisOptions,
502
933
  warn: (message) => this.warn(message),
503
934
  strict: this.options.strict,
504
935
  extraDiagnostics: this.externalKeyframesDiagnostics
505
936
  });
937
+ (0, _animus_ui_extract_pipeline.assertNoErrorDiagnostics)(result.manifest?.diagnostics);
506
938
  (0, _animus_ui_extract_pipeline.enforceExternalTokenContracts)({
507
939
  diagnostics: result.manifest?.diagnostics,
508
940
  fileOwners: this.externalFileOwners,
@@ -539,80 +971,310 @@ var ExtractionSession = class {
539
971
  })
540
972
  ].filter(Boolean).join("\n");
541
973
  require_singleton.setSharedCss(fullCss);
542
- const cssHash = (0, _animus_ui_extract_pipeline.contentHash)(fullCss);
543
- if (cssHash !== this.lastCssHash) {
544
- this.writeAnimusFile("styles.css", fullCss);
545
- this.lastCssHash = cssHash;
546
- }
547
974
  const systemPropsContent = (0, _animus_ui_extract_pipeline.buildSystemPropsModule)({
548
975
  systemPropMapJson: JSON.stringify(manifest?.system_prop_map ?? {}),
549
976
  groupRegistryJson: system.groupRegistryJson,
550
977
  dynamicProps: manifest?.dynamic_props ?? {}
551
978
  });
552
979
  require_singleton.setSharedSystemProps(systemPropsContent);
980
+ require_singleton.setManifestJson(result.manifestJson);
981
+ require_singleton.setAnalyzedHashes(new Map(fileEntries.map((entry) => [entry.path, entry.hash])));
982
+ this.writeAnalysisStatus("committing", pending);
983
+ const epoch = (0, _animus_ui_extract_pipeline.hashReplacementPlans)((0, _animus_ui_extract_pipeline.snapshotFilePlans)(manifest), systemPropsContent);
984
+ if (this.lastCommit === null) this.lastCommit = this.seedCommitFromDisk();
985
+ const generation = (this.lastCommit?.generation ?? 0) + 1;
986
+ this.publishPayloadArtifact("manifest", require_session_paths.MANIFEST_ARTIFACT, result.manifestJson, epoch, generation, require_session_paths.envelopeJsonArtifact);
987
+ if (this.persistAnalysisInputs) this.publishPayloadArtifact("inputs", require_session_paths.ANALYSIS_INPUTS_ARTIFACT, JSON.stringify({
988
+ analyzedHashes: Object.fromEntries(fileEntries.map((entry) => [entry.path, entry.hash])),
989
+ ...result.inputs
990
+ }), epoch, generation, require_session_paths.envelopeJsonArtifact);
991
+ this.publishPayloadArtifact("styles", require_session_paths.STYLES_ARTIFACT, fullCss, epoch, generation, require_session_paths.envelopeCssArtifact);
553
992
  if (devMode) {
554
993
  const systemPropsHash = (0, _animus_ui_extract_pipeline.contentHash)(systemPropsContent);
555
994
  if (systemPropsHash !== this.lastSystemPropsHash) {
556
- this.writeAnimusFile("system-props.js", systemPropsContent);
995
+ this.writeSessionArtifact(require_session_paths.SYSTEM_PROPS_ARTIFACT, systemPropsContent);
557
996
  this.lastSystemPropsHash = systemPropsHash;
558
997
  }
559
- } else this.writeAnimusFile("system-props.js", systemPropsContent);
560
- require_singleton.setManifestJson(result.manifestJson);
561
- const manifestHash = (0, _animus_ui_extract_pipeline.contentHash)(result.manifestJson);
562
- if (this.lastManifestHash === null) this.lastManifestHash = this.diskArtifactHash("manifest.json");
563
- if (manifestHash !== this.lastManifestHash) {
564
- this.writeAnimusFile("manifest.json", result.manifestJson);
565
- this.lastManifestHash = manifestHash;
998
+ } else this.writeSessionArtifact(require_session_paths.SYSTEM_PROPS_ARTIFACT, systemPropsContent);
999
+ this.publishAnalysisCommit(epoch, generation);
1000
+ this.publishReplacementEpoch(epoch);
1001
+ bt.total = this.elapsed(pipelineStart);
1002
+ logBuildTimings(bt, manifest?.timing, (msg) => this.log(msg), this.verbose);
1003
+ }
1004
+ /** Open a status attempt (attemptId increments once per burst — a
1005
+ * debouncing pre-write and the analysis that follows share one id). */
1006
+ beginStatusAttempt() {
1007
+ if (!this.statusAttemptOpen) {
1008
+ this.statusAttemptId += 1;
1009
+ this.statusAttemptOpen = true;
566
1010
  }
567
- if (this.persistAnalysisInputs) {
568
- const inputsJson = JSON.stringify(result.inputs);
569
- const inputsHash = (0, _animus_ui_extract_pipeline.contentHash)(inputsJson);
570
- if (this.lastAnalysisInputsHash === null) this.lastAnalysisInputsHash = this.diskArtifactHash("analysis-inputs.json");
571
- if (inputsHash !== this.lastAnalysisInputsHash) {
572
- this.writeAnimusFile("analysis-inputs.json", inputsJson);
573
- this.lastAnalysisInputsHash = inputsHash;
1011
+ }
1012
+ /** Write the session's analysis-status artifact (design D3). Terminal
1013
+ * states (idle/failed) close the attempt. */
1014
+ writeAnalysisStatus(state, pending, diagnostic) {
1015
+ const status = {
1016
+ schema: 1,
1017
+ sessionId: this.sessionId,
1018
+ attemptId: this.statusAttemptId,
1019
+ state,
1020
+ pending,
1021
+ deadlineAt: Date.now() + this.debounceCeilingMs + STATUS_WATCHDOG_MS,
1022
+ ...diagnostic !== void 0 ? { diagnostic } : {}
1023
+ };
1024
+ this.writeSessionArtifact(require_session_paths.ANALYSIS_STATUS_ARTIFACT, JSON.stringify(status));
1025
+ if (state === "idle" || state === "failed") this.statusAttemptOpen = false;
1026
+ }
1027
+ /**
1028
+ * Orchestrator seam (Turbopack watcher): record watch events observed
1029
+ * during the debounce window so a loader running ahead of the analysis
1030
+ * has positive evidence to wait on (design D3 'debouncing'). Paths are
1031
+ * filtered by the scan config and hashed at observation time.
1032
+ */
1033
+ noteDebouncedWatchEvents(absPaths) {
1034
+ if (!this.rootDir) return;
1035
+ const additions = this.pendingFromBatch({ modifiedFiles: new Set(absPaths) });
1036
+ if (additions.length === 0) return;
1037
+ this.beginStatusAttempt();
1038
+ for (const [key, hash] of additions) this.debouncePending.set(key, hash);
1039
+ if (this.debounceStatusWriteScheduled) return;
1040
+ this.debounceStatusWriteScheduled = true;
1041
+ queueMicrotask(() => {
1042
+ this.debounceStatusWriteScheduled = false;
1043
+ if (this.debouncePending.size === 0) return;
1044
+ try {
1045
+ this.writeAnalysisStatus("debouncing", [...this.debouncePending.entries()]);
1046
+ } catch (err) {
1047
+ this.warn(`debounce status write failed: ${String(err)}`);
574
1048
  }
1049
+ });
1050
+ }
1051
+ /** One pending microtask flushes a burst of debounce observations. */
1052
+ debounceStatusWriteScheduled = false;
1053
+ /**
1054
+ * Write one payload artifact into the session directory, enveloped with
1055
+ * `{sessionId, generation, replacementEpoch, payloadHash}` and rewritten
1056
+ * only when the PAYLOAD bytes changed (byte-identical re-analyses leave
1057
+ * disk untouched — spec: "Unchanged manifest is not rewritten"). The
1058
+ * recorded diskHash (hash of the enveloped bytes) feeds the
1059
+ * analysis-commit.
1060
+ */
1061
+ publishPayloadArtifact(key, name, payload, epoch, generation, wrap) {
1062
+ const payloadHash = (0, _animus_ui_extract_pipeline.contentHash)(payload);
1063
+ if (this.artifactRecords[key] === null) this.artifactRecords[key] = this.seedPayloadRecord(key, name);
1064
+ const record = this.artifactRecords[key];
1065
+ if (record !== null && record.payloadHash === payloadHash) return;
1066
+ const envelope = {
1067
+ sessionId: this.sessionId,
1068
+ generation,
1069
+ replacementEpoch: epoch,
1070
+ payloadHash
1071
+ };
1072
+ const bytes = wrap(payload, JSON.stringify(envelope));
1073
+ this.writeSessionArtifact(name, bytes);
1074
+ this.artifactRecords[key] = {
1075
+ payloadHash,
1076
+ diskHash: (0, _animus_ui_extract_pipeline.contentHash)(bytes)
1077
+ };
1078
+ }
1079
+ /** Reconstruct a payload write guard from the on-disk artifact's envelope
1080
+ * (same-session restart: a byte-identical payload must not rewrite). */
1081
+ seedPayloadRecord(key, name) {
1082
+ let bytes;
1083
+ try {
1084
+ bytes = (0, fs.readFileSync)((0, path.join)(this.sessionDir, name), "utf-8");
1085
+ } catch {
1086
+ return null;
575
1087
  }
576
- bt.total = this.elapsed(pipelineStart);
577
- logBuildTimings(bt, manifest?.timing, (msg) => this.log(msg), this.verbose);
1088
+ let envelope;
1089
+ try {
1090
+ envelope = key === "styles" ? require_session_paths.readCssEnvelope(bytes) : require_session_paths.readJsonEnvelope(bytes);
1091
+ } catch {
1092
+ return null;
1093
+ }
1094
+ if (!envelope || typeof envelope.payloadHash !== "string") return null;
1095
+ return {
1096
+ payloadHash: envelope.payloadHash,
1097
+ diskHash: (0, _animus_ui_extract_pipeline.contentHash)(bytes)
1098
+ };
1099
+ }
1100
+ /** Last analysis-commit persisted in this session's directory, or null. */
1101
+ seedCommitFromDisk() {
1102
+ try {
1103
+ const parsed = JSON.parse((0, fs.readFileSync)(require_session_paths.analysisCommitPath(this.sessionDir), "utf-8"));
1104
+ return parsed.schema === 1 && parsed.sessionId === this.sessionId && typeof parsed.generation === "number" ? parsed : null;
1105
+ } catch {
1106
+ return null;
1107
+ }
1108
+ }
1109
+ /**
1110
+ * Publish the analysis-commit — the transaction identity (design D1).
1111
+ * Written AFTER every payload and BEFORE the epoch; skipped entirely when
1112
+ * the payload set (disk hashes) and epoch are unchanged, so a no-op
1113
+ * re-analysis neither re-keys loader-worker hydration nor burns a
1114
+ * generation.
1115
+ */
1116
+ publishAnalysisCommit(epoch, generation) {
1117
+ const manifestHash = this.artifactRecords.manifest?.diskHash ?? "";
1118
+ const inputsHash = this.persistAnalysisInputs ? this.artifactRecords.inputs?.diskHash ?? "" : void 0;
1119
+ const stylesHash = this.artifactRecords.styles?.diskHash ?? "";
1120
+ const prev = this.lastCommit;
1121
+ if (prev !== null && prev.manifestHash === manifestHash && prev.inputsHash === inputsHash && prev.stylesHash === stylesHash && prev.replacementEpoch === epoch) return;
1122
+ const commit = {
1123
+ schema: 1,
1124
+ sessionId: this.sessionId,
1125
+ generation,
1126
+ replacementEpoch: epoch,
1127
+ manifestHash,
1128
+ ...inputsHash !== void 0 ? { inputsHash } : {},
1129
+ stylesHash
1130
+ };
1131
+ this.writeSessionArtifact(require_session_paths.ANALYSIS_COMMIT_ARTIFACT, JSON.stringify(commit));
1132
+ this.lastCommit = commit;
1133
+ }
1134
+ /**
1135
+ * Publish the canonical replacement epoch: maintain the session-scoped
1136
+ * disk witness `{schema, sessionId, epoch}`, rewritten ONLY when the
1137
+ * epoch VALUE changes so style-only analyses and same-session restarts
1138
+ * leave bytes and mtime untouched, then expose the value through the
1139
+ * singleton for the plugin's needBuild fan-out and the loader's catch-up
1140
+ * re-check. Every VALUE move also reconciles sibling sessions' epoch
1141
+ * artifacts (below) — the webpack cold-cache validity witness.
1142
+ */
1143
+ publishReplacementEpoch(epoch) {
1144
+ const onDisk = this.diskEpochValue();
1145
+ if (this.lastEpochValue === null) this.lastEpochValue = onDisk;
1146
+ if (epoch !== onDisk) this.writeSessionArtifact(require_session_paths.REPLACEMENT_EPOCH_ARTIFACT, JSON.stringify({
1147
+ schema: 1,
1148
+ sessionId: this.sessionId,
1149
+ epoch
1150
+ }));
1151
+ if (epoch !== this.lastEpochValue) {
1152
+ this.lastEpochValue = epoch;
1153
+ this.reconcileSiblingEpochs(epoch);
1154
+ }
1155
+ require_singleton.setReplacementEpoch(epoch);
578
1156
  }
579
- /** Content hash of an existing `.animus/` artifact, or null when absent or
580
- * unreadable. Seeds a null write guard so a FRESH session never rewrites a
581
- * byte-identical artifact (spec: next-turbopack-integration — a rewrite
582
- * changes mtime/inode and needlessly re-keys Turbopack loader-worker
583
- * hydration). */
584
- diskArtifactHash(name) {
1157
+ /**
1158
+ * Sibling epoch reconciliation — the cross-session half of the webpack
1159
+ * persistent-cache witness (spec: dev-served-transform-coherence,
1160
+ * "Offline change invalidates restored modules"). Restored-module
1161
+ * snapshots reference the epoch artifact of the session that BUILT them;
1162
+ * with session-scoped trees that file would otherwise sit untouched
1163
+ * forever, keeping stale snapshots valid. Whenever this session's epoch
1164
+ * value moves, delete every sibling epoch artifact that disagrees (their
1165
+ * snapshots must invalidate — restore-on-demand then rebuilds from the
1166
+ * current generation) and leave agreeing siblings byte-untouched (warm
1167
+ * restores stay valid). Deleting a stale artifact is pruning, not a
1168
+ * foreign-session write; races with concurrent sessions are tolerated
1169
+ * (S14).
1170
+ */
1171
+ /** Sibling ids already reconciled away (artifact deleted or absent) —
1172
+ * nothing left to invalidate for them on later moves. */
1173
+ reconciledSiblingIds = /* @__PURE__ */ new Set();
1174
+ /** sessions-root listing memo, keyed by the root dir's mtime — a new or
1175
+ * pruned sibling DIRECTORY moves it; agreeing siblings stay listed. */
1176
+ siblingListing = null;
1177
+ reconcileSiblingEpochs(epoch) {
1178
+ const rootPath = require_session_paths.sessionsRootDir(this.rootDir);
1179
+ let entries;
585
1180
  try {
586
- return (0, _animus_ui_extract_pipeline.contentHash)((0, fs.readFileSync)((0, path.join)(this.rootDir, ".animus", name), "utf-8"));
1181
+ const mtimeMs = (0, fs.statSync)(rootPath).mtimeMs;
1182
+ if (this.siblingListing?.mtimeMs === mtimeMs) entries = this.siblingListing.entries;
1183
+ else {
1184
+ entries = (0, fs.readdirSync)(rootPath);
1185
+ this.siblingListing = {
1186
+ mtimeMs,
1187
+ entries
1188
+ };
1189
+ }
1190
+ } catch {
1191
+ return;
1192
+ }
1193
+ for (const entry of entries) {
1194
+ if (entry === this.sessionId) continue;
1195
+ if (this.reconciledSiblingIds.has(entry)) continue;
1196
+ const siblingEpochPath = (0, path.join)(rootPath, entry, require_session_paths.REPLACEMENT_EPOCH_ARTIFACT);
1197
+ try {
1198
+ if (JSON.parse((0, fs.readFileSync)(siblingEpochPath, "utf-8")).epoch === epoch) continue;
1199
+ } catch (err) {
1200
+ if (err?.code === "ENOENT") {
1201
+ this.reconciledSiblingIds.add(entry);
1202
+ continue;
1203
+ }
1204
+ }
1205
+ try {
1206
+ (0, fs.unlinkSync)(siblingEpochPath);
1207
+ } catch {}
1208
+ this.reconciledSiblingIds.add(entry);
1209
+ }
1210
+ }
1211
+ /** Epoch value held by this session's on-disk artifact, or null when
1212
+ * absent, unreadable, or not the expected schema. */
1213
+ diskEpochValue() {
1214
+ try {
1215
+ const parsed = JSON.parse((0, fs.readFileSync)(require_session_paths.replacementEpochPath(this.sessionDir), "utf-8"));
1216
+ return parsed.schema === 1 && typeof parsed.epoch === "string" ? parsed.epoch : null;
587
1217
  } catch {
588
1218
  return null;
589
1219
  }
590
1220
  }
591
- /** Ensure `.animus/` exists and write one generated artifact into it.
1221
+ /** Session-start hygiene (design D2): delete legacy flat artifacts
1222
+ * (unreachable by session-scoped loaders) and prune sibling session
1223
+ * directories older than the retention window — never the own dir,
1224
+ * tolerating races with concurrently-pruning sessions (S14). */
1225
+ runSessionStartHygiene() {
1226
+ if (this.sessionStartHygieneDone) return;
1227
+ this.sessionStartHygieneDone = true;
1228
+ const animusDir = (0, path.join)(this.rootDir, ".animus");
1229
+ for (const name of LEGACY_FLAT_ARTIFACTS) try {
1230
+ (0, fs.unlinkSync)((0, path.join)(animusDir, name));
1231
+ } catch {}
1232
+ let entries;
1233
+ try {
1234
+ entries = (0, fs.readdirSync)(require_session_paths.sessionsRootDir(this.rootDir));
1235
+ } catch {
1236
+ return;
1237
+ }
1238
+ const cutoff = Date.now() - SESSION_DIR_MAX_AGE_MS;
1239
+ for (const entry of entries) {
1240
+ if (entry === this.sessionId) continue;
1241
+ const dir = (0, path.join)(require_session_paths.sessionsRootDir(this.rootDir), entry);
1242
+ try {
1243
+ if ((0, fs.statSync)(dir).mtimeMs < cutoff) (0, fs.rmSync)(dir, {
1244
+ recursive: true,
1245
+ force: true
1246
+ });
1247
+ } catch {}
1248
+ }
1249
+ }
1250
+ /** Ensure the session directory exists and write one artifact into it.
592
1251
  * Write-then-rename so cross-process readers (Turbopack loader workers)
593
1252
  * can never observe a torn half-written file. The tmp name carries the
594
1253
  * pid — Next dev evaluates the config in more than one process, and two
595
1254
  * sessions writing the same artifact must not race on one tmp path. */
596
- writeAnimusFile(name, content) {
597
- const dir = (0, path.join)(this.rootDir, ".animus");
598
- if (!(0, fs.existsSync)(dir)) (0, fs.mkdirSync)(dir, { recursive: true });
1255
+ writeSessionArtifact(name, content) {
1256
+ const dir = this.sessionDir;
1257
+ (0, fs.mkdirSync)(dir, { recursive: true });
599
1258
  const tmpPath = (0, path.join)(dir, `.${name}.${process.pid}.tmp`);
600
1259
  (0, fs.writeFileSync)(tmpPath, content);
601
1260
  (0, fs.renameSync)(tmpPath, (0, path.join)(dir, name));
1261
+ this.onArtifactWrite?.(name, content);
602
1262
  }
603
1263
  /**
604
1264
  * asset() placeholder substitution (global-styles-system): resolve each
605
- * referenced specifier through Node resolution, copy the bytes into
606
- * `.animus/assets/` under a content-hashed name, and substitute a
607
- * RELATIVE url. `.animus/styles.css` is processed by Next's own CSS
608
- * pipeline (webpack and Turbopack alike), which applies its native asset
609
- * handling — publicPath and output hashing — to relative url()
610
- * references. Unsubstitutable specifiers warn and emit literally in
611
- * non-strict mode, fail the build under `strict: true`.
1265
+ * referenced specifier through Node resolution, copy the bytes into the
1266
+ * session directory's `assets/` under a content-hashed name, and
1267
+ * substitute a RELATIVE url — relative to the session-scoped styles.css,
1268
+ * which sits beside `assets/`, so the emitted `./assets/<file>` form is
1269
+ * unchanged. The stylesheet is processed by Next's own CSS pipeline
1270
+ * (webpack and Turbopack alike), which applies its native asset handling
1271
+ * — publicPath and output hashing — to relative url() references.
1272
+ * Unsubstitutable specifiers warn and emit literally in non-strict mode,
1273
+ * fail the build under `strict: true`.
612
1274
  */
613
1275
  substituteAssetReferences(globalCss, devMode) {
614
1276
  const specifiers = (0, _animus_ui_extract_pipeline.findAssetSpecifiers)(globalCss);
615
- const assetsDir = (0, path.join)(this.rootDir, ".animus", "assets");
1277
+ const assetsDir = (0, path.join)(this.sessionDir, require_session_paths.SESSION_ASSETS_DIR);
616
1278
  const expected = /* @__PURE__ */ new Set();
617
1279
  this.assetDependencyPaths.clear();
618
1280
  this.assetDependencyKeys.clear();
@@ -658,9 +1320,9 @@ var ExtractionSession = class {
658
1320
  if (!devMode) pruneStaleAssets(assetsDir, expected);
659
1321
  return (0, _animus_ui_extract_pipeline.substituteAssetPlaceholders)(globalCss, urlBySpecifier);
660
1322
  }
661
- trackAssetDependency(path$5) {
662
- this.assetDependencyPaths.add(path$5);
663
- for (const key of (0, _animus_ui_extract_pipeline.toWatchKeys)(path$5)) this.assetDependencyKeys.add(key);
1323
+ trackAssetDependency(path$9) {
1324
+ this.assetDependencyPaths.add(path$9);
1325
+ for (const key of (0, _animus_ui_extract_pipeline.toWatchKeys)(path$9)) this.assetDependencyKeys.add(key);
664
1326
  }
665
1327
  /**
666
1328
  * Resolve an asset specifier to an absolute file via the shared pipeline
@@ -708,7 +1370,37 @@ function pruneStaleAssets(assetsDir, expected) {
708
1370
  }
709
1371
  }
710
1372
  //#endregion
1373
+ //#region src/loader-path.ts
1374
+ const __dirname$2 = (0, path.dirname)((0, url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
1375
+ /**
1376
+ * The one loader-path derivation: first existing dist candidate under the
1377
+ * plugin dir, else the in-repo source fallback. Both loader resolvers below
1378
+ * are thin wrappers, so rule registration and any loader-chain predicate
1379
+ * can never drift (openspec: next-webpack-served-transform-coherence,
1380
+ * design D1).
1381
+ */
1382
+ function resolveLoaderPath(pluginDir, distCandidates, sourceFallback) {
1383
+ for (const candidate of distCandidates) {
1384
+ const distPath = (0, path.resolve)(pluginDir, candidate);
1385
+ if ((0, fs.existsSync)(distPath)) return distPath;
1386
+ }
1387
+ return (0, path.resolve)(pluginDir, sourceFallback);
1388
+ }
1389
+ /**
1390
+ * This package's webpack loader path, shared by the rule registration
1391
+ * (with-animus) and the needBuild loader-chain predicate (plugin). Compiled
1392
+ * dist ships `loader.mjs` next to this module; running from source falls
1393
+ * back to the TypeScript loader.
1394
+ */
1395
+ function resolveAnimusLoaderPath() {
1396
+ return resolveLoaderPath(__dirname$2, ["loader.mjs"], "loader.ts");
1397
+ }
1398
+ //#endregion
711
1399
  //#region src/plugin.ts
1400
+ /** Loud unsupported-version failure (design D7): the coherence mechanism
1401
+ * cannot exist without the per-compilation needBuild hook, so a webpack
1402
+ * that lacks it fails immediately instead of serving stale transforms. */
1403
+ const UNSUPPORTED_WEBPACK_MESSAGE = "[animus-extract] Unsupported webpack: NormalModule.getCompilationHooks(compilation).needBuild is required for dev transform coherence and this webpack does not expose it. Use a Next.js version covered by the animus webpack gauntlet.";
712
1404
  const PLUGIN_NAME = "AnimusWebpackPlugin";
713
1405
  /**
714
1406
  * Webpack adapter for the extraction pipeline. All pipeline logic lives in
@@ -722,11 +1414,57 @@ var AnimusWebpackPlugin = class {
722
1414
  session;
723
1415
  initialized = false;
724
1416
  aliasesExtracted = false;
1417
+ /** Loader paths counting as "the animus loader" in a module's chain —
1418
+ * this package's own loader plus an optional harness override. */
1419
+ animusLoaderPaths;
1420
+ /** Epoch THIS compiler last proceeded with (set after every awaited
1421
+ * watchRun transaction — including joined ones). Null until the
1422
+ * initialization pipeline publishes. */
1423
+ lastBuiltEpoch = null;
1424
+ /** Armed by watchRun when the transaction moved the epoch; captured and
1425
+ * disarmed by the next compilation's needBuild wiring (design D1). */
1426
+ epochMovedForNextCompilation = false;
725
1427
  constructor(options) {
726
1428
  this.options = options;
727
1429
  (0, _animus_ui_extract_pipeline.assertNoRetiredEngineSelection)(options.engine);
728
1430
  require_singleton.setSharedEngine(options.engine ?? "v2");
729
1431
  this.session = new ExtractionSession(options);
1432
+ this.animusLoaderPaths = new Set([resolveAnimusLoaderPath()]);
1433
+ if (options.loaderPath) this.animusLoaderPaths.add(options.loaderPath);
1434
+ }
1435
+ /** True when the module's loader chain contains the animus loader — the
1436
+ * needBuild fan-out predicate (design D1: no historical records; covers
1437
+ * restored persistent-cache modules and raw passthroughs alike). */
1438
+ moduleUsesAnimusLoader(module) {
1439
+ const loaders = module?.loaders;
1440
+ if (!Array.isArray(loaders)) return false;
1441
+ return loaders.some((entry) => typeof entry?.loader === "string" && this.animusLoaderPaths.has(entry.loader));
1442
+ }
1443
+ /** Append the exact epoch artifact path to `watchOptions.ignored`,
1444
+ * preserving the user's shape (design D2: the artifact participates in
1445
+ * module snapshots via addDependency but never triggers live watch
1446
+ * turns — no echo compilations). Session identity is process-claimed,
1447
+ * so every compiler's plugin instance derives the SAME session-scoped
1448
+ * path here regardless of which instance ends up owning the pipeline.
1449
+ * Derived from the ONE root (session.rootDir, set at config time by
1450
+ * with-animus); compiler.context is the fallback for bare-apply
1451
+ * harnesses — identical when compiler.context === cwd. */
1452
+ appendEpochWatchIgnore(compiler) {
1453
+ const options = compiler.options;
1454
+ if (!options) return;
1455
+ const epochPath = require_session_paths.replacementEpochPath(require_session_paths.sessionArtifactDir(this.session.rootDir ?? compiler.context, this.session.sessionId));
1456
+ options.watchOptions ??= {};
1457
+ const watchOptions = options.watchOptions;
1458
+ const ignored = watchOptions.ignored;
1459
+ if (ignored === void 0 || ignored === null) watchOptions.ignored = [epochPath];
1460
+ else if (Array.isArray(ignored)) {
1461
+ if (!ignored.includes(epochPath)) ignored.push(epochPath);
1462
+ } else if (typeof ignored === "string") watchOptions.ignored = [ignored, epochPath];
1463
+ else if (ignored instanceof RegExp) watchOptions.ignored = (path) => path === epochPath || ignored.test(path);
1464
+ else if (typeof ignored === "function") {
1465
+ const original = ignored;
1466
+ watchOptions.ignored = (path) => path === epochPath || Boolean(original(path));
1467
+ }
730
1468
  }
731
1469
  /** Extract path aliases from webpack's resolve.alias config. Runs once per
732
1470
  * plugin instance — the resolve config is immutable after apply. */
@@ -751,12 +1489,29 @@ var AnimusWebpackPlugin = class {
751
1489
  }
752
1490
  apply(compiler) {
753
1491
  if (compiler.options?.name === "edge-server") return;
1492
+ const NormalModule = compiler.webpack?.NormalModule;
1493
+ if (typeof NormalModule?.getCompilationHooks !== "function") throw new Error(UNSUPPORTED_WEBPACK_MESSAGE);
1494
+ this.appendEpochWatchIgnore(compiler);
1495
+ compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
1496
+ const epochMoved = this.epochMovedForNextCompilation;
1497
+ this.epochMovedForNextCompilation = false;
1498
+ const needBuild = NormalModule.getCompilationHooks(compilation).needBuild;
1499
+ if (typeof needBuild?.tapAsync !== "function") throw new Error(UNSUPPORTED_WEBPACK_MESSAGE);
1500
+ needBuild.tapAsync(PLUGIN_NAME, (module, _context, callback) => {
1501
+ if (epochMoved && this.moduleUsesAnimusLoader(module)) {
1502
+ callback(null, true);
1503
+ return;
1504
+ }
1505
+ callback();
1506
+ });
1507
+ });
754
1508
  compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
755
1509
  const registerSystemDependencies = () => {
756
1510
  for (const dep of this.session.systemDependencyPaths) if ((0, fs.existsSync)(dep)) compilation.fileDependencies.add(dep);
757
1511
  else compilation.missingDependencies.add(dep);
758
1512
  for (const dep of this.session.assetDependencyPaths) if ((0, fs.existsSync)(dep)) compilation.fileDependencies.add(dep);
759
1513
  else compilation.missingDependencies.add(dep);
1514
+ for (const root of this.session.externalWatchRoots) compilation.contextDependencies.add(root);
760
1515
  };
761
1516
  registerSystemDependencies();
762
1517
  const stage = compiler.webpack?.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL ?? -2e3;
@@ -768,7 +1523,7 @@ var AnimusWebpackPlugin = class {
768
1523
  registerSystemDependencies();
769
1524
  const css = require_singleton.getSharedCss();
770
1525
  if (!css || !RawSource) return;
771
- const cssPath = (0, path.join)(this.session.rootDir || compiler.context, ".animus", "styles.css");
1526
+ const cssPath = require_session_paths.stylesPath(require_session_paths.sessionArtifactDir(this.session.rootDir || compiler.context, this.session.sessionId));
772
1527
  if (compilation.getAsset(cssPath)) {
773
1528
  compilation.updateAsset(cssPath, new RawSource(css));
774
1529
  return;
@@ -777,7 +1532,7 @@ var AnimusWebpackPlugin = class {
777
1532
  });
778
1533
  });
779
1534
  compiler.hooks.run.tapPromise(PLUGIN_NAME, async (_compiler) => {
780
- this.session.rootDir = _compiler.context;
1535
+ this.adoptCompilerContext(_compiler);
781
1536
  this.extractAliases(_compiler);
782
1537
  const existing = require_singleton.getAnalysisPromise();
783
1538
  if (existing) {
@@ -789,25 +1544,27 @@ var AnimusWebpackPlugin = class {
789
1544
  await promise;
790
1545
  });
791
1546
  compiler.hooks.watchRun.tapPromise(PLUGIN_NAME, async (_compiler) => {
792
- this.session.rootDir = _compiler.context;
1547
+ this.adoptCompilerContext(_compiler);
793
1548
  this.extractAliases(_compiler);
794
1549
  if (!this.initialized) {
795
1550
  const existing = require_singleton.getAnalysisPromise();
796
- if (existing) {
797
- await existing;
798
- this.initialized = true;
799
- return;
1551
+ if (existing) await existing;
1552
+ else {
1553
+ const promise = this.session.runFullPipeline();
1554
+ require_singleton.setAnalysisPromise(promise);
1555
+ await promise;
800
1556
  }
801
- const promise = this.session.runFullPipeline();
802
- require_singleton.setAnalysisPromise(promise);
803
- await promise;
804
1557
  this.initialized = true;
1558
+ this.lastBuiltEpoch = require_singleton.getReplacementEpoch();
805
1559
  return;
806
1560
  }
807
1561
  await this.session.handleWatchUpdate({
808
1562
  modifiedFiles: _compiler.modifiedFiles,
809
1563
  removedFiles: _compiler.removedFiles
810
1564
  });
1565
+ const epoch = require_singleton.getReplacementEpoch();
1566
+ this.epochMovedForNextCompilation = this.lastBuiltEpoch !== null && epoch !== null && epoch !== this.lastBuiltEpoch;
1567
+ if (epoch !== null) this.lastBuiltEpoch = epoch;
811
1568
  });
812
1569
  }
813
1570
  /**
@@ -816,6 +1573,44 @@ var AnimusWebpackPlugin = class {
816
1573
  resetForHmr() {
817
1574
  this.session.resetForHmr();
818
1575
  }
1576
+ /** Session identity (process-claimed) — with-animus derives the
1577
+ * session-scoped alias/stub paths from it (design D2). */
1578
+ get sessionId() {
1579
+ return this.session.sessionId;
1580
+ }
1581
+ /** Set the project root at CONFIG time (with-animus) — the ONE root all
1582
+ * session-path derivations use. The compiler hooks ADOPT
1583
+ * compiler.context only when no root was published (bare-apply
1584
+ * harnesses); they never overwrite this. */
1585
+ setRootDir(rootDir) {
1586
+ this.session.rootDir = rootDir;
1587
+ }
1588
+ /** One warning per plugin instance for a context/root mismatch. */
1589
+ warnedRootDivergence = false;
1590
+ /** The run/watchRun taps' root handling: adopt compiler.context when no
1591
+ * config-time root exists; otherwise KEEP the configured root — every
1592
+ * frozen config-time derivation (stub path, css alias target,
1593
+ * virtual:animus/system-props target, watch-ignore) came from it, and a
1594
+ * silent overwrite re-keys sessionDir so the pipeline publishes
1595
+ * artifacts where none of those consumers look. A disagreement is
1596
+ * surfaced loudly instead (with-animus derives the root from Next's own
1597
+ * `dir`, which equals compiler.context in real Next runs — a mismatch
1598
+ * means a custom-webpack setup wired the plugin differently). */
1599
+ adoptCompilerContext(compiler) {
1600
+ const context = compiler.context;
1601
+ if (!this.session.rootDir) {
1602
+ this.session.rootDir = context;
1603
+ return;
1604
+ }
1605
+ if (context && this.session.rootDir !== context && !this.warnedRootDivergence) {
1606
+ this.warnedRootDivergence = true;
1607
+ console.warn(`[animus-extract] compiler.context (${context}) differs from the configured project root (${this.session.rootDir}); keeping the configured root — run next against the app directory (or align your custom webpack context) so both agree`);
1608
+ }
1609
+ }
1610
+ /** This session's artifact directory, derived from the one root. */
1611
+ get sessionDir() {
1612
+ return this.session.sessionDir;
1613
+ }
819
1614
  /** Expose options for the loader */
820
1615
  getOptions() {
821
1616
  return this.options;
@@ -854,25 +1649,35 @@ function resolveTurbopackMode(options, env = process.env) {
854
1649
  if (mode === "auto") return env.TURBOPACK !== void 0;
855
1650
  return false;
856
1651
  }
1652
+ /** rootDir-relative module request for an absolute path — always forward
1653
+ * slashes, even when path.relative produced Windows separators. */
1654
+ function rootRelativeRequest(rootDir, absPath) {
1655
+ return `./${(0, path.relative)(rootDir, absPath).replace(/\\/g, "/")}`;
1656
+ }
857
1657
  /**
858
1658
  * Build the `turbopack` config fragment: one glob-keyed loader rule
859
1659
  * (file-level allowlisting lives in the loader via manifest lookup — Next 15
860
1660
  * rules have no condition algebra) plus resolve aliases for the virtual
861
1661
  * system-props id, the emitter's stylesheet id, and each collected external
862
- * package specifier redirected to its source entry.
1662
+ * package specifier redirected to its source entry. Loader options carry
1663
+ * the session identity (design D2: an options-borne, JSON-serializable
1664
+ * Turbopack task input — restart-cold by construction), and the artifact
1665
+ * aliases point into the session-scoped tree.
863
1666
  */
864
1667
  function buildTurbopackConfig(args) {
865
- const { rootDir, loaderPath, options, externalSourceEntries } = args;
1668
+ const { rootDir, loaderPath, options, externalSourceEntries, sessionId, sessionDir } = args;
866
1669
  const loaderOptions = {
867
1670
  rootDir,
1671
+ sessionId,
1672
+ sessionDir,
868
1673
  ...options.strict !== void 0 ? { strict: options.strict } : {},
869
1674
  ...options.cssImportTarget !== void 0 ? { cssImportTarget: options.cssImportTarget } : {}
870
1675
  };
871
1676
  const resolveAlias = {
872
- [TURBOPACK_SYSTEM_PROPS_ID]: "./.animus/system-props.js",
873
- ".animus/styles.css": "./.animus/styles.css"
1677
+ [TURBOPACK_SYSTEM_PROPS_ID]: rootRelativeRequest(rootDir, (0, path.join)(sessionDir, require_session_paths.SYSTEM_PROPS_ARTIFACT)),
1678
+ ".animus/styles.css": rootRelativeRequest(rootDir, (0, path.join)(sessionDir, require_session_paths.STYLES_ARTIFACT))
874
1679
  };
875
- for (const [specifier, srcEntry] of externalSourceEntries) resolveAlias[specifier] = `./${(0, path.relative)(rootDir, srcEntry).replace(/\\/g, "/")}`;
1680
+ for (const [specifier, srcEntry] of externalSourceEntries) resolveAlias[specifier] = rootRelativeRequest(rootDir, srcEntry);
876
1681
  return {
877
1682
  rules: { [ANIMUS_TURBOPACK_RULE_GLOB]: { loaders: [{
878
1683
  loader: loaderPath,
@@ -889,11 +1694,7 @@ function buildTurbopackConfig(args) {
889
1694
  * not guaranteed.
890
1695
  */
891
1696
  function resolveTurbopackLoaderPath(pluginDir) {
892
- for (const candidate of ["turbopack-loader.cjs", "turbopack-loader.mjs"]) {
893
- const distPath = (0, path.resolve)(pluginDir, candidate);
894
- if ((0, fs.existsSync)(distPath)) return distPath;
895
- }
896
- return (0, path.resolve)(pluginDir, "turbopack-loader.ts");
1697
+ return resolveLoaderPath(pluginDir, ["turbopack-loader.cjs", "turbopack-loader.mjs"], "turbopack-loader.ts");
897
1698
  }
898
1699
  //#endregion
899
1700
  //#region src/turbopack-orchestrator.ts
@@ -904,12 +1705,15 @@ function resolveTurbopackLoaderPath(pluginDir) {
904
1705
  * filesystem watcher — the bundler only ever sees the `.animus/` disk
905
1706
  * artifacts.
906
1707
  */
907
- /** Run the full pipeline with the Turbopack emitter identity: hydration
908
- * artifacts persisted, and the virtual system-props id (Turbopack rejects
909
- * the webpack path's absolute-path imports). */
1708
+ /** Run the full pipeline with the Turbopack emitter identity: the virtual
1709
+ * system-props id (Turbopack rejects the webpack path's absolute-path
1710
+ * imports). Turbopack orchestration persists the analysis-inputs
1711
+ * hydration corpus — its isolated loader workers replay it (spec:
1712
+ * next-turbopack-integration, "Manifest disk artifact"; webpack mode
1713
+ * skips the corpus). */
910
1714
  async function runTurbopackPipeline(session) {
911
- session.persistAnalysisInputs = true;
912
1715
  session.systemPropsModuleId = TURBOPACK_SYSTEM_PROPS_ID;
1716
+ session.persistAnalysisInputs = true;
913
1717
  await session.runFullPipeline();
914
1718
  }
915
1719
  const activeWatcherRoots = /* @__PURE__ */ new Set();
@@ -935,11 +1739,31 @@ const IGNORED_SEGMENTS = new Set([
935
1739
  function startTurbopackWatcher(session, rootDir, debounceMs = 75, watchFn = fs.watch) {
936
1740
  if (activeWatcherRoots.has(rootDir)) return null;
937
1741
  activeWatcherRoots.add(rootDir);
1742
+ if (typeof session.noteDebouncedWatchEvents === "function") session.debounceCeilingMs = debounceMs;
938
1743
  const pendingPaths = /* @__PURE__ */ new Set();
939
1744
  const watchers = /* @__PURE__ */ new Map();
940
1745
  let timer = null;
941
1746
  let updateChain = Promise.resolve();
942
1747
  let closed = false;
1748
+ const externalWatchers = /* @__PURE__ */ new Map();
1749
+ let pendingOpened = /* @__PURE__ */ new Map();
1750
+ let capturedDuringSnapshot = [];
1751
+ const externalDiagnosticKey = (root) => `external-watch:${root}`;
1752
+ const failureReason = (err) => {
1753
+ const code = err?.code;
1754
+ if (code === "EMFILE" || code === "ENFILE" || code === "ENOSPC" || code === "EPERM") return `capacity(${code})`;
1755
+ if (/inotify|too many/i.test(String(err))) return "capacity";
1756
+ return code ?? "error";
1757
+ };
1758
+ const degradeExternalRoot = (root, err) => {
1759
+ externalWatchers.get(root)?.close();
1760
+ externalWatchers.delete(root);
1761
+ pendingOpened.get(root)?.close();
1762
+ pendingOpened.delete(root);
1763
+ const message = `ANIMUS_EXTERNAL_WATCH_UNAVAILABLE root=${(0, path.relative)(rootDir, root)} reason=${failureReason(err)} effect=changes in this workspace source may require restart`;
1764
+ session.stickyDiagnostics?.set(externalDiagnosticKey(root), message);
1765
+ console.warn(`[animus-extract] ${message}`);
1766
+ };
943
1767
  const flush = () => {
944
1768
  timer = null;
945
1769
  const modifiedFiles = /* @__PURE__ */ new Set();
@@ -951,8 +1775,15 @@ function startTurbopackWatcher(session, rootDir, debounceMs = 75, watchFn = fs.w
951
1775
  removedFiles
952
1776
  }).catch((err) => {
953
1777
  console.warn(`[animus-extract] Turbopack watch update failed: ${String(err)}`);
1778
+ }).then(() => {
1779
+ if (pendingOpened.size > 0 || capturedDuringSnapshot.length > 0) rollbackPendingExternal();
954
1780
  }));
955
1781
  };
1782
+ const rollbackPendingExternal = () => {
1783
+ for (const watcher of pendingOpened.values()) watcher.close();
1784
+ pendingOpened = /* @__PURE__ */ new Map();
1785
+ capturedDuringSnapshot = [];
1786
+ };
956
1787
  const closeAll = () => {
957
1788
  if (closed) return;
958
1789
  closed = true;
@@ -960,12 +1791,29 @@ function startTurbopackWatcher(session, rootDir, debounceMs = 75, watchFn = fs.w
960
1791
  timer = null;
961
1792
  for (const watcher of watchers.values()) watcher.close();
962
1793
  watchers.clear();
1794
+ for (const watcher of externalWatchers.values()) watcher.close();
1795
+ externalWatchers.clear();
1796
+ rollbackPendingExternal();
1797
+ session.onExternalRootResolved = null;
1798
+ session.onExternalRootsCommitted = null;
963
1799
  activeWatcherRoots.delete(rootDir);
964
1800
  };
965
1801
  const onWatcherError = (err) => {
966
1802
  closeAll();
967
1803
  console.warn(`[animus-extract] Turbopack dev watcher failed (${String(err)}); source edits require a dev-server restart`);
968
1804
  };
1805
+ const enqueuePath = (abs) => {
1806
+ pendingPaths.add(abs);
1807
+ try {
1808
+ session.noteDebouncedWatchEvents?.([abs]);
1809
+ } catch (err) {
1810
+ console.warn(`[animus-extract] Turbopack debounce status update failed: ${String(err)}`);
1811
+ }
1812
+ if (!timer) {
1813
+ timer = setTimeout(flush, debounceMs);
1814
+ timer.unref?.();
1815
+ }
1816
+ };
969
1817
  const onEvent = (baseDir, filename) => {
970
1818
  if (closed || !filename) return;
971
1819
  const rel = filename.toString();
@@ -974,10 +1822,47 @@ function startTurbopackWatcher(session, rootDir, debounceMs = 75, watchFn = fs.w
974
1822
  if (baseDir === rootDir && !watchers.has(abs)) try {
975
1823
  if ((0, fs.statSync)(abs).isDirectory()) addWatcher(abs, true);
976
1824
  } catch {}
977
- pendingPaths.add(abs);
978
- if (!timer) {
979
- timer = setTimeout(flush, debounceMs);
980
- timer.unref?.();
1825
+ enqueuePath(abs);
1826
+ };
1827
+ /** External-root event path: fenced by watcher identity ({watcherRootId,
1828
+ * generation} — a handle that is neither current nor pending belongs to
1829
+ * a retired generation and its events are rejected), `filename == null`
1830
+ * marks the ROOT dirty for rediscovery, and events observed while the
1831
+ * root's generation is still snapshotting are buffered for replay. */
1832
+ const onExternalEvent = (root, self, filename) => {
1833
+ if (closed) return;
1834
+ const watcher = self();
1835
+ const active = watcher !== void 0 && externalWatchers.get(root) === watcher;
1836
+ const isPending = watcher !== void 0 && pendingOpened.get(root) === watcher;
1837
+ if (!active && !isPending) return;
1838
+ let abs;
1839
+ if (filename == null) abs = root;
1840
+ else {
1841
+ const rel = filename.toString();
1842
+ if (rel.split(/[\\/]/).some((segment) => IGNORED_SEGMENTS.has(segment))) return;
1843
+ abs = rel === "" ? root : (0, path.join)(root, rel);
1844
+ }
1845
+ if (isPending && !active) {
1846
+ capturedDuringSnapshot.push(abs);
1847
+ return;
1848
+ }
1849
+ enqueuePath(abs);
1850
+ };
1851
+ /** Open one external-root watcher into `into` (the committed set at
1852
+ * startup, the pending set during reset reconciliation). Failures
1853
+ * degrade ONLY this root (design D7); node_modules-resident roots are
1854
+ * documented unwatchable and never registered (guardrail G2). */
1855
+ const openExternalWatcher = (root, into) => {
1856
+ if (root.split(/[\\/]/).includes("node_modules")) return;
1857
+ try {
1858
+ let watcher;
1859
+ watcher = watchFn(root, { recursive: true }, (_event, filename) => onExternalEvent(root, () => watcher, filename));
1860
+ watcher.on("error", (err) => degradeExternalRoot(root, err));
1861
+ watcher.unref?.();
1862
+ into.set(root, watcher);
1863
+ session.stickyDiagnostics?.delete(externalDiagnosticKey(root));
1864
+ } catch (err) {
1865
+ degradeExternalRoot(root, err);
981
1866
  }
982
1867
  };
983
1868
  const addWatcher = (dir, recursive) => {
@@ -997,6 +1882,23 @@ function startTurbopackWatcher(session, rootDir, debounceMs = 75, watchFn = fs.w
997
1882
  console.warn(`[animus-extract] Turbopack dev watcher unavailable (${String(err)}); source edits require a dev-server restart`);
998
1883
  return null;
999
1884
  }
1885
+ for (const root of session.externalWatchRoots ?? []) openExternalWatcher(root, externalWatchers);
1886
+ session.onExternalRootResolved = (root) => {
1887
+ if (closed) return;
1888
+ if (externalWatchers.has(root) || pendingOpened.has(root)) return;
1889
+ openExternalWatcher(root, pendingOpened);
1890
+ };
1891
+ session.onExternalRootsCommitted = (roots) => {
1892
+ if (closed) return;
1893
+ const admitted = new Set(roots);
1894
+ for (const [root, watcher] of pendingOpened) externalWatchers.set(root, watcher);
1895
+ pendingOpened = /* @__PURE__ */ new Map();
1896
+ for (const [root, watcher] of [...externalWatchers]) if (!admitted.has(root)) {
1897
+ watcher.close();
1898
+ externalWatchers.delete(root);
1899
+ }
1900
+ for (const abs of capturedDuringSnapshot.splice(0)) enqueuePath(abs);
1901
+ };
1000
1902
  return { close: closeAll };
1001
1903
  }
1002
1904
  //#endregion
@@ -1026,10 +1928,12 @@ function withAnimus(options) {
1026
1928
  ...nextConfig,
1027
1929
  webpack(config, context) {
1028
1930
  if (typeof existingWebpack === "function") config = existingWebpack(config, context);
1029
- const rootDir = process.cwd();
1030
- const animusDir = (0, path.join)(rootDir, ".animus");
1031
- if (!(0, fs.existsSync)(animusDir)) (0, fs.mkdirSync)(animusDir, { recursive: true });
1032
- const stubCssPath = (0, path.join)(animusDir, "styles.css");
1931
+ const rootDir = typeof context.dir === "string" && context.dir.length > 0 ? context.dir : process.cwd();
1932
+ const plugin = new AnimusWebpackPlugin(options);
1933
+ plugin.setRootDir(rootDir);
1934
+ const sessionDir = plugin.sessionDir;
1935
+ if (!(0, fs.existsSync)(sessionDir)) (0, fs.mkdirSync)(sessionDir, { recursive: true });
1936
+ const stubCssPath = require_session_paths.stylesPath(sessionDir);
1033
1937
  if (!(0, fs.existsSync)(stubCssPath)) {
1034
1938
  const { declaration } = (0, _animus_ui_extract_pipeline.assembleStylesheet)({
1035
1939
  layers: options.layers,
@@ -1048,28 +1952,26 @@ function withAnimus(options) {
1048
1952
  }
1049
1953
  } catch {}
1050
1954
  }
1051
- const plugin = new AnimusWebpackPlugin(options);
1052
- const isExternalPackageFile = (filePath) => plugin.getExternalPackageDirs().some((dir) => filePath.startsWith(dir + path.sep) || filePath === dir);
1955
+ const isExternalPackageFile = (filePath) => plugin.getExternalPackageDirs().some((dir) => (0, _animus_ui_extract_pipeline.isPathWithinRoot)(dir, filePath));
1053
1956
  config.plugins = config.plugins || [];
1054
1957
  config.plugins.push(plugin);
1055
1958
  const { DefinePlugin } = context.webpack ?? {};
1056
1959
  if (typeof DefinePlugin === "function") config.plugins.push(new DefinePlugin({ __ANIMUS_DEV__: JSON.stringify(context.dev === true) }));
1057
1960
  config.resolve = config.resolve || {};
1058
1961
  config.resolve.alias = config.resolve.alias || {};
1059
- config.resolve.alias[ANIMUS_CSS_MODULE_ID] = (0, path.join)(rootDir, ".animus", "styles.css");
1060
- const systemPropsPath = (0, path.join)(rootDir, ".animus", "system-props.js");
1962
+ config.resolve.alias[ANIMUS_CSS_MODULE_ID] = require_session_paths.stylesPath(sessionDir);
1963
+ const sessionSystemPropsPath = require_session_paths.systemPropsPath(sessionDir);
1061
1964
  config.plugins.push({ apply(compiler) {
1062
1965
  compiler.hooks.normalModuleFactory.tap("AnimusVirtualResolve", (nmf) => {
1063
1966
  nmf.hooks.beforeResolve.tap("AnimusVirtualResolve", (resolveData) => {
1064
- if (resolveData.request === "virtual:animus/system-props") resolveData.request = systemPropsPath;
1065
- if (resolveData.request === "virtual:animus/styles.css") resolveData.request = (0, path.join)(rootDir, ".animus", "styles.css");
1967
+ if (resolveData.request === "virtual:animus/system-props") resolveData.request = sessionSystemPropsPath;
1968
+ if (resolveData.request === "virtual:animus/styles.css") resolveData.request = require_session_paths.stylesPath(sessionDir);
1066
1969
  const srcEntry = plugin.getExternalSourceEntries().get(resolveData.request);
1067
1970
  if (srcEntry) resolveData.request = srcEntry;
1068
1971
  });
1069
1972
  });
1070
1973
  } });
1071
- const loaderPath = (0, path.resolve)(__dirname$1, "loader.mjs");
1072
- const actualLoaderPath = (0, fs.existsSync)(loaderPath) ? loaderPath : (0, path.resolve)(__dirname$1, "loader.ts");
1974
+ const actualLoaderPath = resolveAnimusLoaderPath();
1073
1975
  config.module = config.module || {};
1074
1976
  config.module.rules = config.module.rules || [];
1075
1977
  config.module.rules.push({
@@ -1113,7 +2015,9 @@ async function wireTurbopack(nextConfig, options) {
1113
2015
  rootDir,
1114
2016
  loaderPath: resolveTurbopackLoaderPath(__dirname$1),
1115
2017
  options,
1116
- externalSourceEntries: session.externalSourceEntries
2018
+ externalSourceEntries: session.externalSourceEntries,
2019
+ sessionId: session.sessionId,
2020
+ sessionDir: session.sessionDir
1117
2021
  });
1118
2022
  const existing = nextConfig.turbopack ?? {};
1119
2023
  if (existing.rules && "*.{ts,tsx,js,jsx,mjs}" in existing.rules) throw new Error(`[animus-extract] turbopack.rules['${ANIMUS_TURBOPACK_RULE_GLOB}'] is already configured — remove the consumer rule or disable unstable_turbopack`);