@animus-ui/next-plugin 0.1.6 → 0.1.8

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-D4vhawsW.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,42 @@ 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 admitted external watch roots — the engine-side watch
178
+ * surface (webpack contextDependencies / Turbopack watchers). */
179
+ externalWatchRoots = [];
180
+ /** Canonical external root → (raw sourceKey → {raw content hash, recorded
181
+ * abs spelling}): the previous-generation inventory a dirty-root rewalk
182
+ * diffs against (design D3). Raw hashes — MDX preprocessing never skews
183
+ * the diff; the recorded abs path is a registered deletion alias, so a
184
+ * reconstructed deletion always resolves through cached identity. */
185
+ externalInventory = /* @__PURE__ */ new Map();
186
+ /** Sticky diagnostics (design D5/D7): stable key → message, re-surfaced
187
+ * on every full pipeline until the underlying condition clears. */
188
+ stickyDiagnostics = /* @__PURE__ */ new Map();
189
+ /** Volume-membership predicate for the cross-volume gate (design D5).
190
+ * Injected seam: the shared predicate only discriminates on Windows
191
+ * (win32 semantics are unit-tested via path.win32), so runtime tests
192
+ * drive the gate through this. */
193
+ sharesProjectVolume = _animus_ui_extract_pipeline.sharesVolumeRoot;
194
+ /** Orchestrator seams (openspec: external-source-watch-ingestion, design
195
+ * D4). `onExternalRootResolved` fires per volume-admitted external root
196
+ * DURING collection — after resolution, BEFORE that root's sources are
197
+ * walked — so a watching host can open its handle with no blind gap
198
+ * between scan and watch. `onExternalRootsCommitted` fires once after a
199
+ * successful full-pipeline publication with the complete admitted
200
+ * canonical root set (the host's promote/replay/close-old phase; a
201
+ * failed pipeline never fires it, so the host rolls back). */
202
+ onExternalRootResolved = null;
203
+ onExternalRootsCommitted = null;
148
204
  options;
149
205
  staticCssJson;
150
206
  system = null;
@@ -158,15 +214,49 @@ var ExtractionSession = class {
158
214
  /** Loader-reported dependency paths for bundler watch registration. */
159
215
  systemDependencyPaths = [];
160
216
  systemDependencyHashes = /* @__PURE__ */ new Map();
161
- lastCssHash = null;
162
217
  lastSystemPropsHash = null;
163
- lastManifestHash = null;
164
- lastAnalysisInputsHash = null;
218
+ /** Per-payload write guards: payloadHash gates rewrites (byte-identical
219
+ * payloads leave disk untouched); diskHash is the hash of the CURRENT
220
+ * disk bytes (envelope included) recorded in the analysis-commit. null =
221
+ * not yet seeded (first publication reads the artifact's envelope so a
222
+ * same-session restart never rewrites byte-identical artifacts). */
223
+ artifactRecords = {
224
+ manifest: null,
225
+ inputs: null,
226
+ styles: null
227
+ };
228
+ /** Last written analysis-commit; null = seeded from disk on first use. */
229
+ lastCommit = null;
230
+ /** Session identity — claimed once per PROCESS (one Next invocation),
231
+ * adopted by every subsequent session instance so all compilers share
232
+ * one artifact tree (design D2: `next dev`/`next build` co-writing are
233
+ * separate processes and therefore separate sessions). */
234
+ sessionId = require_singleton.claimProcessSessionId();
235
+ /** Epoch value of the last published analysis; null = not yet known
236
+ * (seeded from the session dir's artifact on first publication so a
237
+ * same-session restart with unchanged plans never rewrites bytes). */
238
+ lastEpochValue = null;
239
+ /** Watcher debounce ceiling feeding status deadlines (design D3). */
240
+ debounceCeilingMs = 75;
241
+ /** Test seam: observes every session-artifact write (name, content)
242
+ * post-rename — write ORDER is part of the transaction contract. */
243
+ onArtifactWrite = null;
244
+ /** Status attempt bookkeeping (design D3). */
245
+ statusAttemptId = 0;
246
+ statusAttemptOpen = false;
247
+ /** Debounce-window observations pending analysis (sourceKey → hash). */
248
+ debouncePending = /* @__PURE__ */ new Map();
249
+ /** Session-start hygiene (pruning + legacy cleanup) runs once. */
250
+ sessionStartHygieneDone = false;
165
251
  lcssTargets = null;
166
252
  constructor(options) {
167
253
  this.options = options;
168
254
  this.staticCssJson = (0, _animus_ui_extract_pipeline.serializeStaticCss)(options.staticCss);
169
255
  }
256
+ /** This session's artifact directory. Requires `rootDir` to be set. */
257
+ get sessionDir() {
258
+ return require_session_paths.sessionArtifactDir(this.rootDir, this.sessionId);
259
+ }
170
260
  get verbose() {
171
261
  return this.options.verbose === true || process.env.ANIMUS_DEBUG === "1" || process.env.ANIMUS_DEBUG === "true";
172
262
  }
@@ -182,24 +272,60 @@ var ExtractionSession = class {
182
272
  elapsed(t) {
183
273
  return this.verbose ? Math.round(performance.now() - t) : 0;
184
274
  }
275
+ /** Lazily-computed scan config (options are constructor-fixed, so the
276
+ * derivation is stable for the session's lifetime). */
277
+ scanConfigMemo = null;
185
278
  /** Resolve the scan configuration from options — the single source of the
186
279
  * exclude/extension policy shared by the full and incremental pipelines. */
187
280
  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
- };
281
+ if (this.scanConfigMemo === null) {
282
+ const extensionsSet = new Set(this.options.extensions ?? _animus_ui_extract_pipeline.DEFAULT_EXTENSIONS);
283
+ this.scanConfigMemo = {
284
+ excludePatterns: this.options.exclude ?? DEFAULT_EXCLUDE,
285
+ extensionsSet,
286
+ shouldHandleMdx: extensionsSet.has(".mdx")
287
+ };
288
+ }
289
+ return this.scanConfigMemo;
290
+ }
291
+ /**
292
+ * Watch entry point: one single-flight analysis transaction per event
293
+ * batch (openspec: next-webpack-served-transform-coherence, design D3).
294
+ * The first entering session runs the transaction; every concurrent
295
+ * entry — same session re-entered, or another compiler's session
296
+ * (client/server/RSC each hold their own instance) — joins the in-flight
297
+ * promise, so no compiler proceeds against a generation older than the
298
+ * one the transaction publishes. A rejected transaction rejects every
299
+ * joiner; the gate always clears.
300
+ */
301
+ async handleWatchUpdate(changes) {
302
+ const inflight = require_singleton.getWatchTransaction();
303
+ if (inflight) {
304
+ await inflight;
305
+ return;
306
+ }
307
+ if (!this.system) return;
308
+ const transaction = this.processWatchUpdate(changes);
309
+ require_singleton.setWatchTransaction(transaction);
310
+ try {
311
+ await transaction;
312
+ } finally {
313
+ require_singleton.setWatchTransaction(null);
314
+ }
194
315
  }
195
316
  /**
196
317
  * Incremental watch pass: geological reset when the system file changed,
197
318
  * otherwise content-hash diffing restricted to the watcher's change sets
198
319
  * (falling back to a full discovery walk when no sets are provided).
199
320
  */
200
- async handleWatchUpdate(changes) {
201
- if (!this.system) return;
321
+ async processWatchUpdate(changes) {
202
322
  const rootDir = this.rootDir;
323
+ try {
324
+ changes = this.reconcileExternalRoots(changes);
325
+ } catch (err) {
326
+ this.warn(`external root reconciliation failed: ${String(err)}`);
327
+ }
328
+ const changed = [...changes.modifiedFiles ?? [], ...changes.removedFiles ?? []];
203
329
  let assetChanged = false;
204
330
  try {
205
331
  let systemHit;
@@ -213,16 +339,16 @@ var ExtractionSession = class {
213
339
  break;
214
340
  }
215
341
  }
216
- else systemHit = [...changes.modifiedFiles ?? [], ...changes.removedFiles ?? []].find((path$2) => (0, _animus_ui_extract_pipeline.toWatchKeys)(path$2).some((key) => this.systemDependencyKeys.has(key)));
342
+ else systemHit = changed.find((path$2) => (0, _animus_ui_extract_pipeline.toWatchKeys)(path$2).some((key) => this.systemDependencyKeys.has(key)));
217
343
  if (systemHit) {
218
344
  this.log(`geological reset: system dependency changed (${(0, path.relative)(rootDir, systemHit)})`);
219
345
  this.resetForHmr();
220
- const promise = this.runFullPipeline();
346
+ const promise = this.runFullPipeline(this.pendingFromBatch(changes));
221
347
  require_singleton.setAnalysisPromise(promise);
222
348
  await promise;
223
349
  return;
224
350
  }
225
- if ([...changes.modifiedFiles ?? [], ...changes.removedFiles ?? []].some((path$3) => (0, _animus_ui_extract_pipeline.toWatchKeys)(path$3).some((key) => this.assetDependencyKeys.has(key)))) {
351
+ if (changed.some((path$3) => (0, _animus_ui_extract_pipeline.toWatchKeys)(path$3).some((key) => this.assetDependencyKeys.has(key)))) {
226
352
  this.assetCopyCache.clear();
227
353
  assetChanged = true;
228
354
  }
@@ -230,53 +356,121 @@ var ExtractionSession = class {
230
356
  this.warn(`HMR geological-reset check failed: ${String(err)}`);
231
357
  }
232
358
  const { excludePatterns, extensionsSet, shouldHandleMdx } = this.resolveScanConfig();
359
+ const priorCacheEntries = /* @__PURE__ */ new Map();
360
+ const recordPrior = (key) => {
361
+ if (!priorCacheEntries.has(key)) priorCacheEntries.set(key, this.fileCache.get(key) ?? null);
362
+ };
363
+ const inventoryUpdates = [];
233
364
  let removedAny = false;
234
365
  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;
366
+ let key;
367
+ let owningRoot = null;
368
+ if ((0, _animus_ui_extract_pipeline.isPathWithinRoot)(rootDir, removedPath)) key = (0, path.relative)(rootDir, removedPath);
369
+ else {
370
+ const resolved = this.sourceIdentity?.resolveDeletedSourceId(removedPath);
371
+ if (!resolved) continue;
372
+ key = resolved.sourceKey;
373
+ owningRoot = resolved.owningRoot;
374
+ }
375
+ recordPrior(key);
376
+ recordPrior(key + ".tsx");
377
+ if (this.fileCache.delete(key) || this.fileCache.delete(key + ".tsx")) {
378
+ removedAny = true;
379
+ if (owningRoot) {
380
+ inventoryUpdates.push({
381
+ root: owningRoot,
382
+ key,
383
+ hash: null,
384
+ abs: removedPath
385
+ });
386
+ delete this.externalFileOwners[key];
387
+ delete this.externalFileOwners[key + ".tsx"];
388
+ }
389
+ }
237
390
  }
238
- let files;
391
+ let targets;
239
392
  if (changes.modifiedFiles) {
240
- files = [];
393
+ targets = [];
241
394
  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);
395
+ const classified = this.classifyWatchPath(modifiedPath, {
396
+ excludePatterns,
397
+ extensionsSet
398
+ });
399
+ if (classified) targets.push({
400
+ abs: modifiedPath,
401
+ ...classified
402
+ });
247
403
  }
248
- } else files = (0, _animus_ui_extract_pipeline.discoverFiles)(rootDir, rootDir, excludePatterns, extensionsSet);
404
+ } else targets = (0, _animus_ui_extract_pipeline.discoverFiles)(rootDir, rootDir, excludePatterns, extensionsSet).map((abs) => ({
405
+ abs,
406
+ key: (0, path.relative)(rootDir, abs),
407
+ owningRoot: null
408
+ }));
249
409
  const changedPaths = [];
250
- for (const filePath of files) {
251
- let relPath = (0, path.relative)(rootDir, filePath);
410
+ for (const target of targets) {
411
+ let relPath = target.key;
252
412
  let source;
253
413
  try {
254
- source = (0, fs.readFileSync)(filePath, "utf-8");
414
+ source = (0, fs.readFileSync)(target.abs, "utf-8");
255
415
  } catch {
256
416
  continue;
257
417
  }
258
- if (shouldHandleMdx && (0, path.extname)(filePath) === ".mdx") {
418
+ const rawHash = (0, _animus_ui_extract_pipeline.contentHash)(source);
419
+ if (shouldHandleMdx && (0, path.extname)(target.abs) === ".mdx") {
259
420
  const processed = await this.preprocessMdxEntry(source, relPath, { warn: false });
260
421
  if (!processed) continue;
261
422
  source = processed.source;
262
423
  relPath = processed.relPath;
263
424
  }
264
425
  const cached = this.fileCache.get(relPath);
265
- const hash = (0, _animus_ui_extract_pipeline.contentHash)(source);
426
+ const hash = relPath === target.key ? rawHash : (0, _animus_ui_extract_pipeline.contentHash)(source);
266
427
  if (!cached || cached.hash !== hash) {
267
428
  changedPaths.push(relPath);
429
+ recordPrior(relPath);
268
430
  this.fileCache.set(relPath, {
269
431
  hash,
270
432
  source
271
433
  });
434
+ if (target.owningRoot) {
435
+ const owner = this.externalRootOwners.get(target.owningRoot)?.values().next().value;
436
+ if (owner !== void 0) this.externalFileOwners[relPath] ??= owner;
437
+ inventoryUpdates.push({
438
+ root: target.owningRoot,
439
+ key: target.key,
440
+ hash: rawHash,
441
+ abs: target.abs
442
+ });
443
+ }
272
444
  }
273
445
  }
274
446
  if (changedPaths.length > 0 || removedAny || assetChanged) {
275
447
  const fileEntries = this.buildFileEntriesFromCache();
448
+ const pending = changedPaths.map((rel) => [rel, this.fileCache.get(rel).hash]);
276
449
  require_singleton.resetAnalysisPromise();
277
- const promise = this.runIncrementalPipeline(fileEntries);
450
+ const promise = this.runIncrementalPipeline(fileEntries, pending);
278
451
  require_singleton.setAnalysisPromise(promise);
279
- await promise;
452
+ try {
453
+ await promise;
454
+ } catch (err) {
455
+ for (const [key, prior] of priorCacheEntries) if (prior === null) this.fileCache.delete(key);
456
+ else this.fileCache.set(key, prior);
457
+ throw err;
458
+ }
459
+ for (const update of inventoryUpdates) {
460
+ let inventory = this.externalInventory.get(update.root);
461
+ if (!inventory) {
462
+ inventory = /* @__PURE__ */ new Map();
463
+ this.externalInventory.set(update.root, inventory);
464
+ }
465
+ if (update.hash === null) inventory.delete(update.key);
466
+ else inventory.set(update.key, {
467
+ hash: update.hash,
468
+ abs: update.abs
469
+ });
470
+ }
471
+ } else if (this.statusAttemptOpen) {
472
+ this.debouncePending.clear();
473
+ this.writeAnalysisStatus("idle", []);
280
474
  }
281
475
  }
282
476
  /**
@@ -307,9 +501,11 @@ var ExtractionSession = class {
307
501
  relPath: relPath + ".tsx"
308
502
  };
309
503
  }
310
- async runFullPipeline() {
504
+ async runFullPipeline(pending = []) {
311
505
  const pipelineStart = this.now();
312
506
  const bt = {};
507
+ this.runSessionStartHygiene();
508
+ require_singleton.setSessionArtifactDir(this.sessionDir);
313
509
  (0, _animus_ui_extract_pipeline.clearEngineCache)(require_singleton.engineApi);
314
510
  const rootDir = this.rootDir;
315
511
  const resolvedSystemPath = (0, path.resolve)(rootDir, this.options.system);
@@ -373,6 +569,7 @@ var ExtractionSession = class {
373
569
  t = this.now();
374
570
  const packageNames = (0, _animus_ui_extract_pipeline.extractSystemFilePackages)(resolvedSystemPath);
375
571
  const preResolved = resolvePackagesByName(rootDir, packageNames);
572
+ const rawExternalFiles = /* @__PURE__ */ new Map();
376
573
  const collected = await (0, _animus_ui_extract_pipeline.collectExternalPackageSources)({
377
574
  specifiers: packageNames,
378
575
  resolveSpecifier: (name) => preResolved[name] ? (0, path.resolve)(rootDir, preResolved[name]) : null,
@@ -380,6 +577,7 @@ var ExtractionSession = class {
380
577
  extensionsSet,
381
578
  hasEntry: (relPath) => fileEntries.some((e) => e.path === relPath),
382
579
  preprocessFile: async (source, relPath, absPath) => {
580
+ rawExternalFiles.set(absPath, (0, _animus_ui_extract_pipeline.contentHash)(source));
383
581
  if (shouldHandleMdx && (0, path.extname)(absPath) === ".mdx") return this.preprocessMdxEntry(source, relPath, {
384
582
  warn: true,
385
583
  missingDepFlag
@@ -389,7 +587,16 @@ var ExtractionSession = class {
389
587
  relPath
390
588
  };
391
589
  },
392
- onUnreadable: (relPath, err) => this.warn(`skipped unreadable package file ${relPath}: ${String(err)}`)
590
+ onUnreadable: (relPath, err) => this.warn(`skipped unreadable package file ${relPath}: ${String(err)}`),
591
+ onPackageResolved: (_specifier, packageDir) => {
592
+ if (!this.onExternalRootResolved) return;
593
+ if (!this.sharesProjectVolume(rootDir, packageDir)) return;
594
+ let canonical = packageDir;
595
+ try {
596
+ canonical = (0, fs.realpathSync)(packageDir);
597
+ } catch {}
598
+ this.onExternalRootResolved(canonical);
599
+ }
393
600
  });
394
601
  for (const record of collected.outcomes) if (record.outcome === "empty") this.warn(`include '${record.specifier}' resolved but discovered no component sources`);
395
602
  const unresolvableMessage = (0, _animus_ui_extract_pipeline.unresolvableIncludesMessage)(collected.outcomes);
@@ -402,12 +609,51 @@ var ExtractionSession = class {
402
609
  if (this.options.strict) throw new Error(staleDistMessage);
403
610
  this.warn(staleDistMessage);
404
611
  }
405
- const packageMap = collected.packageMap;
612
+ const rejectedSpecifiers = /* @__PURE__ */ new Set();
613
+ const crossVolumeDetails = [];
614
+ for (const [dir, specifiers] of Object.entries(collected.dirOwnerSets)) {
615
+ if (this.sharesProjectVolume(rootDir, dir)) continue;
616
+ for (const specifier of specifiers) rejectedSpecifiers.add(specifier);
617
+ crossVolumeDetails.push(`${specifiers.join(", ")} → ${dir}`);
618
+ }
619
+ this.stickyDiagnostics.delete("cross-volume");
620
+ if (rejectedSpecifiers.size > 0) {
621
+ 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`;
622
+ if (this.options.strict) throw new Error(`[animus-next] ${message}`);
623
+ this.stickyDiagnostics.set("cross-volume", message);
624
+ }
625
+ for (const message of this.stickyDiagnostics.values()) this.warn(message);
626
+ const admitted = (0, _animus_ui_extract_pipeline.excludeCollectedPackages)(collected, rejectedSpecifiers, rootDir);
627
+ const identity = (0, _animus_ui_extract_pipeline.createSourceIdentity)(rootDir);
628
+ this.externalRootOwners = /* @__PURE__ */ new Map();
629
+ for (const [dir, specifiers] of Object.entries(admitted.dirOwnerSets)) {
630
+ const canonical = identity.registerExternalRoot(dir);
631
+ const owners = this.externalRootOwners.get(canonical) ?? /* @__PURE__ */ new Set();
632
+ for (const specifier of specifiers) owners.add(specifier);
633
+ this.externalRootOwners.set(canonical, owners);
634
+ }
635
+ this.sourceIdentity = identity;
636
+ this.externalWatchRoots = identity.externalRoots();
637
+ this.externalInventory = /* @__PURE__ */ new Map();
638
+ for (const [absPath, rawHash] of rawExternalFiles) {
639
+ const resolved = identity.resolveSourceId(absPath);
640
+ if (!resolved?.owningRoot) continue;
641
+ let inventory = this.externalInventory.get(resolved.owningRoot);
642
+ if (!inventory) {
643
+ inventory = /* @__PURE__ */ new Map();
644
+ this.externalInventory.set(resolved.owningRoot, inventory);
645
+ }
646
+ inventory.set(resolved.sourceKey, {
647
+ hash: rawHash,
648
+ abs: absPath
649
+ });
650
+ }
651
+ const packageMap = admitted.packageMap;
406
652
  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) {
653
+ this.externalDirOwners = (0, _animus_ui_extract_pipeline.firstOwners)(admitted.dirOwnerSets);
654
+ this.externalFileOwners = admitted.fileOwners;
655
+ this.externalSourceEntries = admitted.sourceEntries;
656
+ for (const entry of admitted.entries) {
411
657
  const hash = (0, _animus_ui_extract_pipeline.contentHash)(entry.source);
412
658
  this.fileCache.set(entry.path, {
413
659
  hash,
@@ -419,30 +665,155 @@ var ExtractionSession = class {
419
665
  hash
420
666
  });
421
667
  }
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) {
668
+ this.externalPackageDirs = admitted.packageDirs;
669
+ require_singleton.setSharedExternalDirs(admitted.packageDirs);
670
+ require_singleton.setSharedExternalEntries(admitted.sourceEntries);
671
+ if (this.system && admitted.keyframesScanEntries.size > 0) {
426
672
  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);
673
+ const merge = (0, _animus_ui_extract_pipeline.mergeExternalKeyframes)((entry, root) => api.scanKeyframesExports(entry, root), this.system.keyframesJson, admitted.keyframesScanEntries.values(), this.rootDir);
428
674
  this.system.keyframesJson = merge.keyframesJson;
429
675
  this.externalKeyframesDiagnostics = merge.diagnostics;
430
676
  }
431
677
  bt.packageResolve = this.elapsed(t);
432
- await this.analyzeAndEmit(fileEntries, packageMap, false, bt, pipelineStart);
678
+ const universe = new Set(fileEntries.map((entry) => entry.path));
679
+ for (const key of [...this.fileCache.keys()]) if (!universe.has(key)) this.fileCache.delete(key);
680
+ await this.analyzeAndEmit(fileEntries, packageMap, false, bt, pipelineStart, pending);
681
+ this.onExternalRootsCommitted?.(this.externalWatchRoots);
433
682
  }
434
683
  /**
435
- * Reset analysis state for HMR geological reset.
684
+ * Reset analysis state for HMR geological reset. Payload write guards go
685
+ * back to null so the next publication reseeds them from the disk
686
+ * envelopes — a byte-identical post-reset artifact is still not
687
+ * rewritten.
436
688
  */
437
689
  resetForHmr() {
438
690
  require_singleton.resetAnalysisPromise();
439
- this.lastCssHash = null;
691
+ this.artifactRecords = {
692
+ manifest: null,
693
+ inputs: null,
694
+ styles: null
695
+ };
440
696
  this.lastSystemPropsHash = null;
441
- this.lastManifestHash = null;
442
- this.lastAnalysisInputsHash = null;
443
697
  (0, _animus_ui_extract_pipeline.clearEngineCache)(require_singleton.engineApi);
444
698
  }
445
699
  /**
700
+ * The (sourceKey, observedSourceHash) pairs of a watch batch — cheap
701
+ * evidence for the status file's pending set on the geological-reset
702
+ * path, where the batch's component edits ride along with the system
703
+ * edit (design D3: loaders wait only on observed inputs).
704
+ */
705
+ pendingFromBatch(changes) {
706
+ const { excludePatterns, extensionsSet } = this.resolveScanConfig();
707
+ const pending = [];
708
+ for (const path$4 of changes.modifiedFiles ?? []) {
709
+ const classified = this.classifyWatchPath(path$4, {
710
+ excludePatterns,
711
+ extensionsSet
712
+ });
713
+ if (!classified) continue;
714
+ try {
715
+ pending.push([classified.key, (0, _animus_ui_extract_pipeline.contentHash)((0, fs.readFileSync)(path$4, "utf-8"))]);
716
+ } catch {}
717
+ }
718
+ return pending;
719
+ }
720
+ /**
721
+ * Root-dirty inventory reconciliation (design D3): partition the
722
+ * watcher's change sets into explicit FILE events and external ROOT hits
723
+ * (a reported path that IS an admitted root, or a directory inside one),
724
+ * rewalk each dirty root through the shared discovery policy, diff the
725
+ * SourceId+raw-hash inventory against the previous generation, and merge
726
+ * the reconstructed created/edited/deleted deltas with the explicit
727
+ * events (identity-level dedup happens downstream at the cache gate).
728
+ * Deletion falls out of the inventory diff — webpack may report only the
729
+ * directory (probe-proven).
730
+ */
731
+ reconcileExternalRoots(changes) {
732
+ const identity = this.sourceIdentity;
733
+ if (!identity) return changes;
734
+ if (!changes.modifiedFiles && !changes.removedFiles) return changes;
735
+ if (this.externalWatchRoots.length === 0) return changes;
736
+ const { extensionsSet } = this.resolveScanConfig();
737
+ const dirtyRoots = /* @__PURE__ */ new Set();
738
+ const modified = /* @__PURE__ */ new Set();
739
+ const removed = /* @__PURE__ */ new Set();
740
+ const rootHitFor = (path$5) => {
741
+ const containing = identity.containingExternalRoot(path$5);
742
+ if (!containing) return null;
743
+ try {
744
+ return (0, fs.statSync)(path$5).isDirectory() ? containing : null;
745
+ } catch {
746
+ return identity.resolveDeletedSourceId(path$5) ? null : containing;
747
+ }
748
+ };
749
+ for (const path$6 of changes.modifiedFiles ?? []) {
750
+ const root = rootHitFor(path$6);
751
+ if (root) dirtyRoots.add(root);
752
+ else modified.add(path$6);
753
+ }
754
+ for (const path$7 of changes.removedFiles ?? []) {
755
+ const root = rootHitFor(path$7);
756
+ if (root) dirtyRoots.add(root);
757
+ else removed.add(path$7);
758
+ }
759
+ for (const root of dirtyRoots) {
760
+ const previous = this.externalInventory.get(root) ?? /* @__PURE__ */ new Map();
761
+ const seen = /* @__PURE__ */ new Set();
762
+ const walked = (0, _animus_ui_extract_pipeline.walkPackageSources)(root, extensionsSet);
763
+ for (const abs of walked) {
764
+ const resolved = identity.resolveSourceId(abs);
765
+ if (!resolved || resolved.owningRoot !== root) continue;
766
+ seen.add(resolved.sourceKey);
767
+ let rawHash;
768
+ try {
769
+ rawHash = (0, _animus_ui_extract_pipeline.contentHash)((0, fs.readFileSync)(abs, "utf-8"));
770
+ } catch {
771
+ continue;
772
+ }
773
+ if (previous.get(resolved.sourceKey)?.hash !== rawHash) modified.add(abs);
774
+ }
775
+ for (const [key, entry] of previous) if (!seen.has(key)) removed.add(entry.abs);
776
+ }
777
+ return {
778
+ modifiedFiles: modified,
779
+ removedFiles: removed
780
+ };
781
+ }
782
+ /**
783
+ * Route one watcher-reported path (design D1/D2): a project-root member
784
+ * (lexical containment — existing local semantics preserved; consumer
785
+ * exclude patterns apply) or an admitted external member (identity
786
+ * resolution with symlink-escape rejection; package-relative excludes
787
+ * mirror the collection walk's filters — guardrail G1, one policy).
788
+ * Returns null for dropped paths.
789
+ */
790
+ classifyWatchPath(absPath, scan) {
791
+ if (!scan.extensionsSet.has((0, path.extname)(absPath))) return null;
792
+ const rootDir = this.rootDir;
793
+ if ((0, _animus_ui_extract_pipeline.isPathWithinRoot)(rootDir, absPath)) {
794
+ const rel = (0, path.relative)(rootDir, absPath);
795
+ if (scan.excludePatterns.some((pattern) => absPath.includes(pattern) || rel.includes(pattern))) return null;
796
+ return {
797
+ key: rel,
798
+ owningRoot: null
799
+ };
800
+ }
801
+ const resolved = this.sourceIdentity?.resolveSourceId(absPath);
802
+ if (!resolved) return null;
803
+ if (resolved.owningRoot === null) {
804
+ if (scan.excludePatterns.some((pattern) => resolved.sourceKey.includes(pattern))) return null;
805
+ return {
806
+ key: resolved.sourceKey,
807
+ owningRoot: null
808
+ };
809
+ }
810
+ if ((0, _animus_ui_extract_pipeline.isExcludedPackageRelativePath)(resolved.pathInRoot)) return null;
811
+ return {
812
+ key: resolved.sourceKey,
813
+ owningRoot: resolved.owningRoot
814
+ };
815
+ }
816
+ /**
446
817
  * Build file entries from cache: every cached file rides with full source.
447
818
  * The v2 engine has NO Rust-side cache (arch-extract-v2-spine: uncached
448
819
  * re-analysis beats a cache-hit path), so it must always receive full sources
@@ -450,8 +821,8 @@ var ExtractionSession = class {
450
821
  */
451
822
  buildFileEntriesFromCache() {
452
823
  const entries = [];
453
- for (const [path$4, { hash, source }] of this.fileCache) entries.push({
454
- path: path$4,
824
+ for (const [path$8, { hash, source }] of this.fileCache) entries.push({
825
+ path: path$8,
455
826
  source,
456
827
  hash
457
828
  });
@@ -461,10 +832,10 @@ var ExtractionSession = class {
461
832
  * Run incremental pipeline with cache-aware file entries.
462
833
  * Reuses system config from the last full pipeline run.
463
834
  */
464
- async runIncrementalPipeline(fileEntries) {
835
+ async runIncrementalPipeline(fileEntries, pending = []) {
465
836
  const bt = {};
466
837
  const pipelineStart = this.now();
467
- await this.analyzeAndEmit(fileEntries, this.lastPackageMap, true, bt, pipelineStart);
838
+ await this.analyzeAndEmit(fileEntries, this.lastPackageMap, true, bt, pipelineStart, pending);
468
839
  }
469
840
  /**
470
841
  * Shared analysis + emit core for both pipelines — the single call site
@@ -482,23 +853,44 @@ var ExtractionSession = class {
482
853
  * - `true` (HMR): skips the report log, and guards the system-props.js
483
854
  * write by lastSystemPropsHash.
484
855
  *
485
- * The styles.css write guard (lastCssHash) is identical on both paths.
856
+ * The disk artifacts land under the session directory in transaction
857
+ * order (design D1): manifest → analysis-inputs (Turbopack orchestration
858
+ * only) → styles.css → system-props → analysis-commit →
859
+ * replacements-epoch (last, only when moved). The analysis-status file walks starting → analyzing →
860
+ * committing → idle around the attempt, landing in `failed` (with the
861
+ * diagnostic) on any throw (design D3).
486
862
  */
487
- async analyzeAndEmit(fileEntries, packageMap, devMode, bt, pipelineStart) {
863
+ async analyzeAndEmit(fileEntries, packageMap, devMode, bt, pipelineStart, pending = []) {
864
+ this.beginStatusAttempt();
865
+ this.debouncePending.clear();
866
+ this.writeAnalysisStatus("starting", pending);
867
+ try {
868
+ await this.analyzeAndEmitAttempt(fileEntries, packageMap, devMode, bt, pipelineStart, pending);
869
+ this.writeAnalysisStatus("idle", []);
870
+ } catch (err) {
871
+ this.writeAnalysisStatus("failed", pending, String(err));
872
+ throw err;
873
+ }
874
+ }
875
+ async analyzeAndEmitAttempt(fileEntries, packageMap, devMode, bt, pipelineStart, pending) {
488
876
  const system = this.system;
489
- const result = (0, _animus_ui_extract_pipeline.runProjectAnalysis)(require_singleton.engineApi, {
877
+ const analysisOptions = {
490
878
  fileEntries,
491
879
  packageMap,
492
880
  system,
493
881
  emitter: {
494
882
  runtimeImport: "@animus-ui/system/runtime",
495
883
  cssModuleId: ANIMUS_CSS_MODULE_ID,
496
- systemPropsModuleId: this.systemPropsModuleId ?? (0, path.join)(this.rootDir, ".animus", "system-props.js")
884
+ systemPropsModuleId: this.systemPropsModuleId ?? require_session_paths.systemPropsPath(this.sessionDir)
497
885
  },
498
886
  pathAliasesJson: this.pathAliasesJson,
499
887
  staticCssJson: this.staticCssJson,
500
888
  externalDirs: this.externalPackageDirs.map((dir) => (0, path.relative)(this.rootDir, dir)),
501
- devMode,
889
+ devMode
890
+ };
891
+ this.writeAnalysisStatus("analyzing", pending);
892
+ const result = (0, _animus_ui_extract_pipeline.runProjectAnalysis)(require_singleton.engineApi, {
893
+ ...analysisOptions,
502
894
  warn: (message) => this.warn(message),
503
895
  strict: this.options.strict,
504
896
  extraDiagnostics: this.externalKeyframesDiagnostics
@@ -539,80 +931,305 @@ var ExtractionSession = class {
539
931
  })
540
932
  ].filter(Boolean).join("\n");
541
933
  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
934
  const systemPropsContent = (0, _animus_ui_extract_pipeline.buildSystemPropsModule)({
548
935
  systemPropMapJson: JSON.stringify(manifest?.system_prop_map ?? {}),
549
936
  groupRegistryJson: system.groupRegistryJson,
550
937
  dynamicProps: manifest?.dynamic_props ?? {}
551
938
  });
552
939
  require_singleton.setSharedSystemProps(systemPropsContent);
940
+ require_singleton.setManifestJson(result.manifestJson);
941
+ require_singleton.setAnalyzedHashes(new Map(fileEntries.map((entry) => [entry.path, entry.hash])));
942
+ this.writeAnalysisStatus("committing", pending);
943
+ const epoch = (0, _animus_ui_extract_pipeline.hashReplacementPlans)((0, _animus_ui_extract_pipeline.snapshotFilePlans)(manifest), systemPropsContent);
944
+ if (this.lastCommit === null) this.lastCommit = this.seedCommitFromDisk();
945
+ const generation = (this.lastCommit?.generation ?? 0) + 1;
946
+ this.publishPayloadArtifact("manifest", require_session_paths.MANIFEST_ARTIFACT, result.manifestJson, epoch, generation, require_session_paths.envelopeJsonArtifact);
947
+ if (this.persistAnalysisInputs) this.publishPayloadArtifact("inputs", require_session_paths.ANALYSIS_INPUTS_ARTIFACT, JSON.stringify({
948
+ analyzedHashes: Object.fromEntries(fileEntries.map((entry) => [entry.path, entry.hash])),
949
+ ...result.inputs
950
+ }), epoch, generation, require_session_paths.envelopeJsonArtifact);
951
+ this.publishPayloadArtifact("styles", require_session_paths.STYLES_ARTIFACT, fullCss, epoch, generation, require_session_paths.envelopeCssArtifact);
553
952
  if (devMode) {
554
953
  const systemPropsHash = (0, _animus_ui_extract_pipeline.contentHash)(systemPropsContent);
555
954
  if (systemPropsHash !== this.lastSystemPropsHash) {
556
- this.writeAnimusFile("system-props.js", systemPropsContent);
955
+ this.writeSessionArtifact(require_session_paths.SYSTEM_PROPS_ARTIFACT, systemPropsContent);
557
956
  this.lastSystemPropsHash = systemPropsHash;
558
957
  }
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;
958
+ } else this.writeSessionArtifact(require_session_paths.SYSTEM_PROPS_ARTIFACT, systemPropsContent);
959
+ this.publishAnalysisCommit(epoch, generation);
960
+ this.publishReplacementEpoch(epoch);
961
+ bt.total = this.elapsed(pipelineStart);
962
+ logBuildTimings(bt, manifest?.timing, (msg) => this.log(msg), this.verbose);
963
+ }
964
+ /** Open a status attempt (attemptId increments once per burst — a
965
+ * debouncing pre-write and the analysis that follows share one id). */
966
+ beginStatusAttempt() {
967
+ if (!this.statusAttemptOpen) {
968
+ this.statusAttemptId += 1;
969
+ this.statusAttemptOpen = true;
566
970
  }
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;
971
+ }
972
+ /** Write the session's analysis-status artifact (design D3). Terminal
973
+ * states (idle/failed) close the attempt. */
974
+ writeAnalysisStatus(state, pending, diagnostic) {
975
+ const status = {
976
+ schema: 1,
977
+ sessionId: this.sessionId,
978
+ attemptId: this.statusAttemptId,
979
+ state,
980
+ pending,
981
+ deadlineAt: Date.now() + this.debounceCeilingMs + STATUS_WATCHDOG_MS,
982
+ ...diagnostic !== void 0 ? { diagnostic } : {}
983
+ };
984
+ this.writeSessionArtifact(require_session_paths.ANALYSIS_STATUS_ARTIFACT, JSON.stringify(status));
985
+ if (state === "idle" || state === "failed") this.statusAttemptOpen = false;
986
+ }
987
+ /**
988
+ * Orchestrator seam (Turbopack watcher): record watch events observed
989
+ * during the debounce window so a loader running ahead of the analysis
990
+ * has positive evidence to wait on (design D3 'debouncing'). Paths are
991
+ * filtered by the scan config and hashed at observation time.
992
+ */
993
+ noteDebouncedWatchEvents(absPaths) {
994
+ if (!this.rootDir) return;
995
+ const additions = this.pendingFromBatch({ modifiedFiles: new Set(absPaths) });
996
+ if (additions.length === 0) return;
997
+ this.beginStatusAttempt();
998
+ for (const [key, hash] of additions) this.debouncePending.set(key, hash);
999
+ if (this.debounceStatusWriteScheduled) return;
1000
+ this.debounceStatusWriteScheduled = true;
1001
+ queueMicrotask(() => {
1002
+ this.debounceStatusWriteScheduled = false;
1003
+ if (this.debouncePending.size === 0) return;
1004
+ this.writeAnalysisStatus("debouncing", [...this.debouncePending.entries()]);
1005
+ });
1006
+ }
1007
+ /** One pending microtask flushes a burst of debounce observations. */
1008
+ debounceStatusWriteScheduled = false;
1009
+ /**
1010
+ * Write one payload artifact into the session directory, enveloped with
1011
+ * `{sessionId, generation, replacementEpoch, payloadHash}` and rewritten
1012
+ * only when the PAYLOAD bytes changed (byte-identical re-analyses leave
1013
+ * disk untouched — spec: "Unchanged manifest is not rewritten"). The
1014
+ * recorded diskHash (hash of the enveloped bytes) feeds the
1015
+ * analysis-commit.
1016
+ */
1017
+ publishPayloadArtifact(key, name, payload, epoch, generation, wrap) {
1018
+ const payloadHash = (0, _animus_ui_extract_pipeline.contentHash)(payload);
1019
+ if (this.artifactRecords[key] === null) this.artifactRecords[key] = this.seedPayloadRecord(key, name);
1020
+ const record = this.artifactRecords[key];
1021
+ if (record !== null && record.payloadHash === payloadHash) return;
1022
+ const envelope = {
1023
+ sessionId: this.sessionId,
1024
+ generation,
1025
+ replacementEpoch: epoch,
1026
+ payloadHash
1027
+ };
1028
+ const bytes = wrap(payload, JSON.stringify(envelope));
1029
+ this.writeSessionArtifact(name, bytes);
1030
+ this.artifactRecords[key] = {
1031
+ payloadHash,
1032
+ diskHash: (0, _animus_ui_extract_pipeline.contentHash)(bytes)
1033
+ };
1034
+ }
1035
+ /** Reconstruct a payload write guard from the on-disk artifact's envelope
1036
+ * (same-session restart: a byte-identical payload must not rewrite). */
1037
+ seedPayloadRecord(key, name) {
1038
+ let bytes;
1039
+ try {
1040
+ bytes = (0, fs.readFileSync)((0, path.join)(this.sessionDir, name), "utf-8");
1041
+ } catch {
1042
+ return null;
1043
+ }
1044
+ let envelope;
1045
+ try {
1046
+ envelope = key === "styles" ? require_session_paths.readCssEnvelope(bytes) : require_session_paths.readJsonEnvelope(bytes);
1047
+ } catch {
1048
+ return null;
1049
+ }
1050
+ if (!envelope || typeof envelope.payloadHash !== "string") return null;
1051
+ return {
1052
+ payloadHash: envelope.payloadHash,
1053
+ diskHash: (0, _animus_ui_extract_pipeline.contentHash)(bytes)
1054
+ };
1055
+ }
1056
+ /** Last analysis-commit persisted in this session's directory, or null. */
1057
+ seedCommitFromDisk() {
1058
+ try {
1059
+ const parsed = JSON.parse((0, fs.readFileSync)(require_session_paths.analysisCommitPath(this.sessionDir), "utf-8"));
1060
+ return parsed.schema === 1 && parsed.sessionId === this.sessionId && typeof parsed.generation === "number" ? parsed : null;
1061
+ } catch {
1062
+ return null;
1063
+ }
1064
+ }
1065
+ /**
1066
+ * Publish the analysis-commit — the transaction identity (design D1).
1067
+ * Written AFTER every payload and BEFORE the epoch; skipped entirely when
1068
+ * the payload set (disk hashes) and epoch are unchanged, so a no-op
1069
+ * re-analysis neither re-keys loader-worker hydration nor burns a
1070
+ * generation.
1071
+ */
1072
+ publishAnalysisCommit(epoch, generation) {
1073
+ const manifestHash = this.artifactRecords.manifest?.diskHash ?? "";
1074
+ const inputsHash = this.persistAnalysisInputs ? this.artifactRecords.inputs?.diskHash ?? "" : void 0;
1075
+ const stylesHash = this.artifactRecords.styles?.diskHash ?? "";
1076
+ const prev = this.lastCommit;
1077
+ if (prev !== null && prev.manifestHash === manifestHash && prev.inputsHash === inputsHash && prev.stylesHash === stylesHash && prev.replacementEpoch === epoch) return;
1078
+ const commit = {
1079
+ schema: 1,
1080
+ sessionId: this.sessionId,
1081
+ generation,
1082
+ replacementEpoch: epoch,
1083
+ manifestHash,
1084
+ ...inputsHash !== void 0 ? { inputsHash } : {},
1085
+ stylesHash
1086
+ };
1087
+ this.writeSessionArtifact(require_session_paths.ANALYSIS_COMMIT_ARTIFACT, JSON.stringify(commit));
1088
+ this.lastCommit = commit;
1089
+ }
1090
+ /**
1091
+ * Publish the canonical replacement epoch: maintain the session-scoped
1092
+ * disk witness `{schema, sessionId, epoch}`, rewritten ONLY when the
1093
+ * epoch VALUE changes so style-only analyses and same-session restarts
1094
+ * leave bytes and mtime untouched, then expose the value through the
1095
+ * singleton for the plugin's needBuild fan-out and the loader's catch-up
1096
+ * re-check. Every VALUE move also reconciles sibling sessions' epoch
1097
+ * artifacts (below) — the webpack cold-cache validity witness.
1098
+ */
1099
+ publishReplacementEpoch(epoch) {
1100
+ if (this.lastEpochValue === null) this.lastEpochValue = this.diskEpochValue();
1101
+ if (epoch !== this.lastEpochValue) {
1102
+ this.writeSessionArtifact(require_session_paths.REPLACEMENT_EPOCH_ARTIFACT, JSON.stringify({
1103
+ schema: 1,
1104
+ sessionId: this.sessionId,
1105
+ epoch
1106
+ }));
1107
+ this.lastEpochValue = epoch;
1108
+ this.reconcileSiblingEpochs(epoch);
1109
+ }
1110
+ require_singleton.setReplacementEpoch(epoch);
1111
+ }
1112
+ /**
1113
+ * Sibling epoch reconciliation — the cross-session half of the webpack
1114
+ * persistent-cache witness (spec: dev-served-transform-coherence,
1115
+ * "Offline change invalidates restored modules"). Restored-module
1116
+ * snapshots reference the epoch artifact of the session that BUILT them;
1117
+ * with session-scoped trees that file would otherwise sit untouched
1118
+ * forever, keeping stale snapshots valid. Whenever this session's epoch
1119
+ * value moves, delete every sibling epoch artifact that disagrees (their
1120
+ * snapshots must invalidate — restore-on-demand then rebuilds from the
1121
+ * current generation) and leave agreeing siblings byte-untouched (warm
1122
+ * restores stay valid). Deleting a stale artifact is pruning, not a
1123
+ * foreign-session write; races with concurrent sessions are tolerated
1124
+ * (S14).
1125
+ */
1126
+ /** Sibling ids already reconciled away (artifact deleted or absent) —
1127
+ * nothing left to invalidate for them on later moves. */
1128
+ reconciledSiblingIds = /* @__PURE__ */ new Set();
1129
+ /** sessions-root listing memo, keyed by the root dir's mtime — a new or
1130
+ * pruned sibling DIRECTORY moves it; agreeing siblings stay listed. */
1131
+ siblingListing = null;
1132
+ reconcileSiblingEpochs(epoch) {
1133
+ const rootPath = require_session_paths.sessionsRootDir(this.rootDir);
1134
+ let entries;
1135
+ try {
1136
+ const mtimeMs = (0, fs.statSync)(rootPath).mtimeMs;
1137
+ if (this.siblingListing?.mtimeMs === mtimeMs) entries = this.siblingListing.entries;
1138
+ else {
1139
+ entries = (0, fs.readdirSync)(rootPath);
1140
+ this.siblingListing = {
1141
+ mtimeMs,
1142
+ entries
1143
+ };
574
1144
  }
1145
+ } catch {
1146
+ return;
1147
+ }
1148
+ for (const entry of entries) {
1149
+ if (entry === this.sessionId) continue;
1150
+ if (this.reconciledSiblingIds.has(entry)) continue;
1151
+ const siblingEpochPath = (0, path.join)(rootPath, entry, require_session_paths.REPLACEMENT_EPOCH_ARTIFACT);
1152
+ try {
1153
+ if (JSON.parse((0, fs.readFileSync)(siblingEpochPath, "utf-8")).epoch === epoch) continue;
1154
+ } catch (err) {
1155
+ if (err?.code === "ENOENT") {
1156
+ this.reconciledSiblingIds.add(entry);
1157
+ continue;
1158
+ }
1159
+ }
1160
+ try {
1161
+ (0, fs.unlinkSync)(siblingEpochPath);
1162
+ } catch {}
1163
+ this.reconciledSiblingIds.add(entry);
575
1164
  }
576
- bt.total = this.elapsed(pipelineStart);
577
- logBuildTimings(bt, manifest?.timing, (msg) => this.log(msg), this.verbose);
578
1165
  }
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) {
1166
+ /** Epoch value held by this session's on-disk artifact, or null when
1167
+ * absent, unreadable, or not the expected schema. */
1168
+ diskEpochValue() {
585
1169
  try {
586
- return (0, _animus_ui_extract_pipeline.contentHash)((0, fs.readFileSync)((0, path.join)(this.rootDir, ".animus", name), "utf-8"));
1170
+ const parsed = JSON.parse((0, fs.readFileSync)(require_session_paths.replacementEpochPath(this.sessionDir), "utf-8"));
1171
+ return parsed.schema === 1 && typeof parsed.epoch === "string" ? parsed.epoch : null;
587
1172
  } catch {
588
1173
  return null;
589
1174
  }
590
1175
  }
591
- /** Ensure `.animus/` exists and write one generated artifact into it.
1176
+ /** Session-start hygiene (design D2): delete legacy flat artifacts
1177
+ * (unreachable by session-scoped loaders) and prune sibling session
1178
+ * directories older than the retention window — never the own dir,
1179
+ * tolerating races with concurrently-pruning sessions (S14). */
1180
+ runSessionStartHygiene() {
1181
+ if (this.sessionStartHygieneDone) return;
1182
+ this.sessionStartHygieneDone = true;
1183
+ const animusDir = (0, path.join)(this.rootDir, ".animus");
1184
+ for (const name of LEGACY_FLAT_ARTIFACTS) try {
1185
+ (0, fs.unlinkSync)((0, path.join)(animusDir, name));
1186
+ } catch {}
1187
+ let entries;
1188
+ try {
1189
+ entries = (0, fs.readdirSync)(require_session_paths.sessionsRootDir(this.rootDir));
1190
+ } catch {
1191
+ return;
1192
+ }
1193
+ const cutoff = Date.now() - SESSION_DIR_MAX_AGE_MS;
1194
+ for (const entry of entries) {
1195
+ if (entry === this.sessionId) continue;
1196
+ const dir = (0, path.join)(require_session_paths.sessionsRootDir(this.rootDir), entry);
1197
+ try {
1198
+ if ((0, fs.statSync)(dir).mtimeMs < cutoff) (0, fs.rmSync)(dir, {
1199
+ recursive: true,
1200
+ force: true
1201
+ });
1202
+ } catch {}
1203
+ }
1204
+ }
1205
+ /** Ensure the session directory exists and write one artifact into it.
592
1206
  * Write-then-rename so cross-process readers (Turbopack loader workers)
593
1207
  * can never observe a torn half-written file. The tmp name carries the
594
1208
  * pid — Next dev evaluates the config in more than one process, and two
595
1209
  * 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 });
1210
+ writeSessionArtifact(name, content) {
1211
+ const dir = this.sessionDir;
1212
+ (0, fs.mkdirSync)(dir, { recursive: true });
599
1213
  const tmpPath = (0, path.join)(dir, `.${name}.${process.pid}.tmp`);
600
1214
  (0, fs.writeFileSync)(tmpPath, content);
601
1215
  (0, fs.renameSync)(tmpPath, (0, path.join)(dir, name));
1216
+ this.onArtifactWrite?.(name, content);
602
1217
  }
603
1218
  /**
604
1219
  * 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`.
1220
+ * referenced specifier through Node resolution, copy the bytes into the
1221
+ * session directory's `assets/` under a content-hashed name, and
1222
+ * substitute a RELATIVE url — relative to the session-scoped styles.css,
1223
+ * which sits beside `assets/`, so the emitted `./assets/<file>` form is
1224
+ * unchanged. The stylesheet is processed by Next's own CSS pipeline
1225
+ * (webpack and Turbopack alike), which applies its native asset handling
1226
+ * — publicPath and output hashing — to relative url() references.
1227
+ * Unsubstitutable specifiers warn and emit literally in non-strict mode,
1228
+ * fail the build under `strict: true`.
612
1229
  */
613
1230
  substituteAssetReferences(globalCss, devMode) {
614
1231
  const specifiers = (0, _animus_ui_extract_pipeline.findAssetSpecifiers)(globalCss);
615
- const assetsDir = (0, path.join)(this.rootDir, ".animus", "assets");
1232
+ const assetsDir = (0, path.join)(this.sessionDir, require_session_paths.SESSION_ASSETS_DIR);
616
1233
  const expected = /* @__PURE__ */ new Set();
617
1234
  this.assetDependencyPaths.clear();
618
1235
  this.assetDependencyKeys.clear();
@@ -658,9 +1275,9 @@ var ExtractionSession = class {
658
1275
  if (!devMode) pruneStaleAssets(assetsDir, expected);
659
1276
  return (0, _animus_ui_extract_pipeline.substituteAssetPlaceholders)(globalCss, urlBySpecifier);
660
1277
  }
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);
1278
+ trackAssetDependency(path$9) {
1279
+ this.assetDependencyPaths.add(path$9);
1280
+ for (const key of (0, _animus_ui_extract_pipeline.toWatchKeys)(path$9)) this.assetDependencyKeys.add(key);
664
1281
  }
665
1282
  /**
666
1283
  * Resolve an asset specifier to an absolute file via the shared pipeline
@@ -708,7 +1325,37 @@ function pruneStaleAssets(assetsDir, expected) {
708
1325
  }
709
1326
  }
710
1327
  //#endregion
1328
+ //#region src/loader-path.ts
1329
+ const __dirname$2 = (0, path.dirname)((0, url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
1330
+ /**
1331
+ * The one loader-path derivation: first existing dist candidate under the
1332
+ * plugin dir, else the in-repo source fallback. Both loader resolvers below
1333
+ * are thin wrappers, so rule registration and any loader-chain predicate
1334
+ * can never drift (openspec: next-webpack-served-transform-coherence,
1335
+ * design D1).
1336
+ */
1337
+ function resolveLoaderPath(pluginDir, distCandidates, sourceFallback) {
1338
+ for (const candidate of distCandidates) {
1339
+ const distPath = (0, path.resolve)(pluginDir, candidate);
1340
+ if ((0, fs.existsSync)(distPath)) return distPath;
1341
+ }
1342
+ return (0, path.resolve)(pluginDir, sourceFallback);
1343
+ }
1344
+ /**
1345
+ * This package's webpack loader path, shared by the rule registration
1346
+ * (with-animus) and the needBuild loader-chain predicate (plugin). Compiled
1347
+ * dist ships `loader.mjs` next to this module; running from source falls
1348
+ * back to the TypeScript loader.
1349
+ */
1350
+ function resolveAnimusLoaderPath() {
1351
+ return resolveLoaderPath(__dirname$2, ["loader.mjs"], "loader.ts");
1352
+ }
1353
+ //#endregion
711
1354
  //#region src/plugin.ts
1355
+ /** Loud unsupported-version failure (design D7): the coherence mechanism
1356
+ * cannot exist without the per-compilation needBuild hook, so a webpack
1357
+ * that lacks it fails immediately instead of serving stale transforms. */
1358
+ 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
1359
  const PLUGIN_NAME = "AnimusWebpackPlugin";
713
1360
  /**
714
1361
  * Webpack adapter for the extraction pipeline. All pipeline logic lives in
@@ -722,11 +1369,57 @@ var AnimusWebpackPlugin = class {
722
1369
  session;
723
1370
  initialized = false;
724
1371
  aliasesExtracted = false;
1372
+ /** Loader paths counting as "the animus loader" in a module's chain —
1373
+ * this package's own loader plus an optional harness override. */
1374
+ animusLoaderPaths;
1375
+ /** Epoch THIS compiler last proceeded with (set after every awaited
1376
+ * watchRun transaction — including joined ones). Null until the
1377
+ * initialization pipeline publishes. */
1378
+ lastBuiltEpoch = null;
1379
+ /** Armed by watchRun when the transaction moved the epoch; captured and
1380
+ * disarmed by the next compilation's needBuild wiring (design D1). */
1381
+ epochMovedForNextCompilation = false;
725
1382
  constructor(options) {
726
1383
  this.options = options;
727
1384
  (0, _animus_ui_extract_pipeline.assertNoRetiredEngineSelection)(options.engine);
728
1385
  require_singleton.setSharedEngine(options.engine ?? "v2");
729
1386
  this.session = new ExtractionSession(options);
1387
+ this.animusLoaderPaths = new Set([resolveAnimusLoaderPath()]);
1388
+ if (options.loaderPath) this.animusLoaderPaths.add(options.loaderPath);
1389
+ }
1390
+ /** True when the module's loader chain contains the animus loader — the
1391
+ * needBuild fan-out predicate (design D1: no historical records; covers
1392
+ * restored persistent-cache modules and raw passthroughs alike). */
1393
+ moduleUsesAnimusLoader(module) {
1394
+ const loaders = module?.loaders;
1395
+ if (!Array.isArray(loaders)) return false;
1396
+ return loaders.some((entry) => typeof entry?.loader === "string" && this.animusLoaderPaths.has(entry.loader));
1397
+ }
1398
+ /** Append the exact epoch artifact path to `watchOptions.ignored`,
1399
+ * preserving the user's shape (design D2: the artifact participates in
1400
+ * module snapshots via addDependency but never triggers live watch
1401
+ * turns — no echo compilations). Session identity is process-claimed,
1402
+ * so every compiler's plugin instance derives the SAME session-scoped
1403
+ * path here regardless of which instance ends up owning the pipeline.
1404
+ * Derived from the ONE root (session.rootDir, set at config time by
1405
+ * with-animus); compiler.context is the fallback for bare-apply
1406
+ * harnesses — identical when compiler.context === cwd. */
1407
+ appendEpochWatchIgnore(compiler) {
1408
+ const options = compiler.options;
1409
+ if (!options) return;
1410
+ const epochPath = require_session_paths.replacementEpochPath(require_session_paths.sessionArtifactDir(this.session.rootDir ?? compiler.context, this.session.sessionId));
1411
+ options.watchOptions ??= {};
1412
+ const watchOptions = options.watchOptions;
1413
+ const ignored = watchOptions.ignored;
1414
+ if (ignored === void 0 || ignored === null) watchOptions.ignored = [epochPath];
1415
+ else if (Array.isArray(ignored)) {
1416
+ if (!ignored.includes(epochPath)) ignored.push(epochPath);
1417
+ } else if (typeof ignored === "string") watchOptions.ignored = [ignored, epochPath];
1418
+ else if (ignored instanceof RegExp) watchOptions.ignored = (path) => path === epochPath || ignored.test(path);
1419
+ else if (typeof ignored === "function") {
1420
+ const original = ignored;
1421
+ watchOptions.ignored = (path) => path === epochPath || Boolean(original(path));
1422
+ }
730
1423
  }
731
1424
  /** Extract path aliases from webpack's resolve.alias config. Runs once per
732
1425
  * plugin instance — the resolve config is immutable after apply. */
@@ -751,12 +1444,29 @@ var AnimusWebpackPlugin = class {
751
1444
  }
752
1445
  apply(compiler) {
753
1446
  if (compiler.options?.name === "edge-server") return;
1447
+ const NormalModule = compiler.webpack?.NormalModule;
1448
+ if (typeof NormalModule?.getCompilationHooks !== "function") throw new Error(UNSUPPORTED_WEBPACK_MESSAGE);
1449
+ this.appendEpochWatchIgnore(compiler);
1450
+ compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
1451
+ const epochMoved = this.epochMovedForNextCompilation;
1452
+ this.epochMovedForNextCompilation = false;
1453
+ const needBuild = NormalModule.getCompilationHooks(compilation).needBuild;
1454
+ if (typeof needBuild?.tapAsync !== "function") throw new Error(UNSUPPORTED_WEBPACK_MESSAGE);
1455
+ needBuild.tapAsync(PLUGIN_NAME, (module, _context, callback) => {
1456
+ if (epochMoved && this.moduleUsesAnimusLoader(module)) {
1457
+ callback(null, true);
1458
+ return;
1459
+ }
1460
+ callback();
1461
+ });
1462
+ });
754
1463
  compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
755
1464
  const registerSystemDependencies = () => {
756
1465
  for (const dep of this.session.systemDependencyPaths) if ((0, fs.existsSync)(dep)) compilation.fileDependencies.add(dep);
757
1466
  else compilation.missingDependencies.add(dep);
758
1467
  for (const dep of this.session.assetDependencyPaths) if ((0, fs.existsSync)(dep)) compilation.fileDependencies.add(dep);
759
1468
  else compilation.missingDependencies.add(dep);
1469
+ for (const root of this.session.externalWatchRoots) compilation.contextDependencies.add(root);
760
1470
  };
761
1471
  registerSystemDependencies();
762
1472
  const stage = compiler.webpack?.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL ?? -2e3;
@@ -768,7 +1478,7 @@ var AnimusWebpackPlugin = class {
768
1478
  registerSystemDependencies();
769
1479
  const css = require_singleton.getSharedCss();
770
1480
  if (!css || !RawSource) return;
771
- const cssPath = (0, path.join)(this.session.rootDir || compiler.context, ".animus", "styles.css");
1481
+ const cssPath = require_session_paths.stylesPath(require_session_paths.sessionArtifactDir(this.session.rootDir || compiler.context, this.session.sessionId));
772
1482
  if (compilation.getAsset(cssPath)) {
773
1483
  compilation.updateAsset(cssPath, new RawSource(css));
774
1484
  return;
@@ -793,21 +1503,23 @@ var AnimusWebpackPlugin = class {
793
1503
  this.extractAliases(_compiler);
794
1504
  if (!this.initialized) {
795
1505
  const existing = require_singleton.getAnalysisPromise();
796
- if (existing) {
797
- await existing;
798
- this.initialized = true;
799
- return;
1506
+ if (existing) await existing;
1507
+ else {
1508
+ const promise = this.session.runFullPipeline();
1509
+ require_singleton.setAnalysisPromise(promise);
1510
+ await promise;
800
1511
  }
801
- const promise = this.session.runFullPipeline();
802
- require_singleton.setAnalysisPromise(promise);
803
- await promise;
804
1512
  this.initialized = true;
1513
+ this.lastBuiltEpoch = require_singleton.getReplacementEpoch();
805
1514
  return;
806
1515
  }
807
1516
  await this.session.handleWatchUpdate({
808
1517
  modifiedFiles: _compiler.modifiedFiles,
809
1518
  removedFiles: _compiler.removedFiles
810
1519
  });
1520
+ const epoch = require_singleton.getReplacementEpoch();
1521
+ this.epochMovedForNextCompilation = this.lastBuiltEpoch !== null && epoch !== null && epoch !== this.lastBuiltEpoch;
1522
+ if (epoch !== null) this.lastBuiltEpoch = epoch;
811
1523
  });
812
1524
  }
813
1525
  /**
@@ -816,6 +1528,21 @@ var AnimusWebpackPlugin = class {
816
1528
  resetForHmr() {
817
1529
  this.session.resetForHmr();
818
1530
  }
1531
+ /** Session identity (process-claimed) — with-animus derives the
1532
+ * session-scoped alias/stub paths from it (design D2). */
1533
+ get sessionId() {
1534
+ return this.session.sessionId;
1535
+ }
1536
+ /** Set the project root at CONFIG time (with-animus) — the ONE root all
1537
+ * session-path derivations use. The compiler hooks re-assert it from
1538
+ * compiler.context (identical when compiler.context === cwd). */
1539
+ setRootDir(rootDir) {
1540
+ this.session.rootDir = rootDir;
1541
+ }
1542
+ /** This session's artifact directory, derived from the one root. */
1543
+ get sessionDir() {
1544
+ return this.session.sessionDir;
1545
+ }
819
1546
  /** Expose options for the loader */
820
1547
  getOptions() {
821
1548
  return this.options;
@@ -854,25 +1581,35 @@ function resolveTurbopackMode(options, env = process.env) {
854
1581
  if (mode === "auto") return env.TURBOPACK !== void 0;
855
1582
  return false;
856
1583
  }
1584
+ /** rootDir-relative module request for an absolute path — always forward
1585
+ * slashes, even when path.relative produced Windows separators. */
1586
+ function rootRelativeRequest(rootDir, absPath) {
1587
+ return `./${(0, path.relative)(rootDir, absPath).replace(/\\/g, "/")}`;
1588
+ }
857
1589
  /**
858
1590
  * Build the `turbopack` config fragment: one glob-keyed loader rule
859
1591
  * (file-level allowlisting lives in the loader via manifest lookup — Next 15
860
1592
  * rules have no condition algebra) plus resolve aliases for the virtual
861
1593
  * system-props id, the emitter's stylesheet id, and each collected external
862
- * package specifier redirected to its source entry.
1594
+ * package specifier redirected to its source entry. Loader options carry
1595
+ * the session identity (design D2: an options-borne, JSON-serializable
1596
+ * Turbopack task input — restart-cold by construction), and the artifact
1597
+ * aliases point into the session-scoped tree.
863
1598
  */
864
1599
  function buildTurbopackConfig(args) {
865
- const { rootDir, loaderPath, options, externalSourceEntries } = args;
1600
+ const { rootDir, loaderPath, options, externalSourceEntries, sessionId, sessionDir } = args;
866
1601
  const loaderOptions = {
867
1602
  rootDir,
1603
+ sessionId,
1604
+ sessionDir,
868
1605
  ...options.strict !== void 0 ? { strict: options.strict } : {},
869
1606
  ...options.cssImportTarget !== void 0 ? { cssImportTarget: options.cssImportTarget } : {}
870
1607
  };
871
1608
  const resolveAlias = {
872
- [TURBOPACK_SYSTEM_PROPS_ID]: "./.animus/system-props.js",
873
- ".animus/styles.css": "./.animus/styles.css"
1609
+ [TURBOPACK_SYSTEM_PROPS_ID]: rootRelativeRequest(rootDir, (0, path.join)(sessionDir, require_session_paths.SYSTEM_PROPS_ARTIFACT)),
1610
+ ".animus/styles.css": rootRelativeRequest(rootDir, (0, path.join)(sessionDir, require_session_paths.STYLES_ARTIFACT))
874
1611
  };
875
- for (const [specifier, srcEntry] of externalSourceEntries) resolveAlias[specifier] = `./${(0, path.relative)(rootDir, srcEntry).replace(/\\/g, "/")}`;
1612
+ for (const [specifier, srcEntry] of externalSourceEntries) resolveAlias[specifier] = rootRelativeRequest(rootDir, srcEntry);
876
1613
  return {
877
1614
  rules: { [ANIMUS_TURBOPACK_RULE_GLOB]: { loaders: [{
878
1615
  loader: loaderPath,
@@ -889,11 +1626,7 @@ function buildTurbopackConfig(args) {
889
1626
  * not guaranteed.
890
1627
  */
891
1628
  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");
1629
+ return resolveLoaderPath(pluginDir, ["turbopack-loader.cjs", "turbopack-loader.mjs"], "turbopack-loader.ts");
897
1630
  }
898
1631
  //#endregion
899
1632
  //#region src/turbopack-orchestrator.ts
@@ -904,12 +1637,15 @@ function resolveTurbopackLoaderPath(pluginDir) {
904
1637
  * filesystem watcher — the bundler only ever sees the `.animus/` disk
905
1638
  * artifacts.
906
1639
  */
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). */
1640
+ /** Run the full pipeline with the Turbopack emitter identity: the virtual
1641
+ * system-props id (Turbopack rejects the webpack path's absolute-path
1642
+ * imports). Turbopack orchestration persists the analysis-inputs
1643
+ * hydration corpus — its isolated loader workers replay it (spec:
1644
+ * next-turbopack-integration, "Manifest disk artifact"; webpack mode
1645
+ * skips the corpus). */
910
1646
  async function runTurbopackPipeline(session) {
911
- session.persistAnalysisInputs = true;
912
1647
  session.systemPropsModuleId = TURBOPACK_SYSTEM_PROPS_ID;
1648
+ session.persistAnalysisInputs = true;
913
1649
  await session.runFullPipeline();
914
1650
  }
915
1651
  const activeWatcherRoots = /* @__PURE__ */ new Set();
@@ -935,11 +1671,31 @@ const IGNORED_SEGMENTS = new Set([
935
1671
  function startTurbopackWatcher(session, rootDir, debounceMs = 75, watchFn = fs.watch) {
936
1672
  if (activeWatcherRoots.has(rootDir)) return null;
937
1673
  activeWatcherRoots.add(rootDir);
1674
+ if (typeof session.noteDebouncedWatchEvents === "function") session.debounceCeilingMs = debounceMs;
938
1675
  const pendingPaths = /* @__PURE__ */ new Set();
939
1676
  const watchers = /* @__PURE__ */ new Map();
940
1677
  let timer = null;
941
1678
  let updateChain = Promise.resolve();
942
1679
  let closed = false;
1680
+ const externalWatchers = /* @__PURE__ */ new Map();
1681
+ let pendingOpened = /* @__PURE__ */ new Map();
1682
+ let capturedDuringSnapshot = [];
1683
+ const externalDiagnosticKey = (root) => `external-watch:${root}`;
1684
+ const failureReason = (err) => {
1685
+ const code = err?.code;
1686
+ if (code === "EMFILE" || code === "ENFILE" || code === "ENOSPC" || code === "EPERM") return `capacity(${code})`;
1687
+ if (/inotify|too many/i.test(String(err))) return "capacity";
1688
+ return code ?? "error";
1689
+ };
1690
+ const degradeExternalRoot = (root, err) => {
1691
+ externalWatchers.get(root)?.close();
1692
+ externalWatchers.delete(root);
1693
+ pendingOpened.get(root)?.close();
1694
+ pendingOpened.delete(root);
1695
+ const message = `ANIMUS_EXTERNAL_WATCH_UNAVAILABLE root=${(0, path.relative)(rootDir, root)} reason=${failureReason(err)} effect=changes in this workspace source may require restart`;
1696
+ session.stickyDiagnostics?.set(externalDiagnosticKey(root), message);
1697
+ console.warn(`[animus-extract] ${message}`);
1698
+ };
943
1699
  const flush = () => {
944
1700
  timer = null;
945
1701
  const modifiedFiles = /* @__PURE__ */ new Set();
@@ -951,8 +1707,15 @@ function startTurbopackWatcher(session, rootDir, debounceMs = 75, watchFn = fs.w
951
1707
  removedFiles
952
1708
  }).catch((err) => {
953
1709
  console.warn(`[animus-extract] Turbopack watch update failed: ${String(err)}`);
1710
+ }).then(() => {
1711
+ if (pendingOpened.size > 0 || capturedDuringSnapshot.length > 0) rollbackPendingExternal();
954
1712
  }));
955
1713
  };
1714
+ const rollbackPendingExternal = () => {
1715
+ for (const watcher of pendingOpened.values()) watcher.close();
1716
+ pendingOpened = /* @__PURE__ */ new Map();
1717
+ capturedDuringSnapshot = [];
1718
+ };
956
1719
  const closeAll = () => {
957
1720
  if (closed) return;
958
1721
  closed = true;
@@ -960,12 +1723,29 @@ function startTurbopackWatcher(session, rootDir, debounceMs = 75, watchFn = fs.w
960
1723
  timer = null;
961
1724
  for (const watcher of watchers.values()) watcher.close();
962
1725
  watchers.clear();
1726
+ for (const watcher of externalWatchers.values()) watcher.close();
1727
+ externalWatchers.clear();
1728
+ rollbackPendingExternal();
1729
+ session.onExternalRootResolved = null;
1730
+ session.onExternalRootsCommitted = null;
963
1731
  activeWatcherRoots.delete(rootDir);
964
1732
  };
965
1733
  const onWatcherError = (err) => {
966
1734
  closeAll();
967
1735
  console.warn(`[animus-extract] Turbopack dev watcher failed (${String(err)}); source edits require a dev-server restart`);
968
1736
  };
1737
+ const enqueuePath = (abs) => {
1738
+ pendingPaths.add(abs);
1739
+ try {
1740
+ session.noteDebouncedWatchEvents?.([abs]);
1741
+ } catch (err) {
1742
+ console.warn(`[animus-extract] Turbopack debounce status update failed: ${String(err)}`);
1743
+ }
1744
+ if (!timer) {
1745
+ timer = setTimeout(flush, debounceMs);
1746
+ timer.unref?.();
1747
+ }
1748
+ };
969
1749
  const onEvent = (baseDir, filename) => {
970
1750
  if (closed || !filename) return;
971
1751
  const rel = filename.toString();
@@ -974,10 +1754,47 @@ function startTurbopackWatcher(session, rootDir, debounceMs = 75, watchFn = fs.w
974
1754
  if (baseDir === rootDir && !watchers.has(abs)) try {
975
1755
  if ((0, fs.statSync)(abs).isDirectory()) addWatcher(abs, true);
976
1756
  } catch {}
977
- pendingPaths.add(abs);
978
- if (!timer) {
979
- timer = setTimeout(flush, debounceMs);
980
- timer.unref?.();
1757
+ enqueuePath(abs);
1758
+ };
1759
+ /** External-root event path: fenced by watcher identity ({watcherRootId,
1760
+ * generation} — a handle that is neither current nor pending belongs to
1761
+ * a retired generation and its events are rejected), `filename == null`
1762
+ * marks the ROOT dirty for rediscovery, and events observed while the
1763
+ * root's generation is still snapshotting are buffered for replay. */
1764
+ const onExternalEvent = (root, self, filename) => {
1765
+ if (closed) return;
1766
+ const watcher = self();
1767
+ const active = watcher !== void 0 && externalWatchers.get(root) === watcher;
1768
+ const isPending = watcher !== void 0 && pendingOpened.get(root) === watcher;
1769
+ if (!active && !isPending) return;
1770
+ let abs;
1771
+ if (filename == null) abs = root;
1772
+ else {
1773
+ const rel = filename.toString();
1774
+ if (rel.split(/[\\/]/).some((segment) => IGNORED_SEGMENTS.has(segment))) return;
1775
+ abs = rel === "" ? root : (0, path.join)(root, rel);
1776
+ }
1777
+ if (isPending && !active) {
1778
+ capturedDuringSnapshot.push(abs);
1779
+ return;
1780
+ }
1781
+ enqueuePath(abs);
1782
+ };
1783
+ /** Open one external-root watcher into `into` (the committed set at
1784
+ * startup, the pending set during reset reconciliation). Failures
1785
+ * degrade ONLY this root (design D7); node_modules-resident roots are
1786
+ * documented unwatchable and never registered (guardrail G2). */
1787
+ const openExternalWatcher = (root, into) => {
1788
+ if (root.split(/[\\/]/).includes("node_modules")) return;
1789
+ try {
1790
+ let watcher;
1791
+ watcher = watchFn(root, { recursive: true }, (_event, filename) => onExternalEvent(root, () => watcher, filename));
1792
+ watcher.on("error", (err) => degradeExternalRoot(root, err));
1793
+ watcher.unref?.();
1794
+ into.set(root, watcher);
1795
+ session.stickyDiagnostics?.delete(externalDiagnosticKey(root));
1796
+ } catch (err) {
1797
+ degradeExternalRoot(root, err);
981
1798
  }
982
1799
  };
983
1800
  const addWatcher = (dir, recursive) => {
@@ -997,6 +1814,23 @@ function startTurbopackWatcher(session, rootDir, debounceMs = 75, watchFn = fs.w
997
1814
  console.warn(`[animus-extract] Turbopack dev watcher unavailable (${String(err)}); source edits require a dev-server restart`);
998
1815
  return null;
999
1816
  }
1817
+ for (const root of session.externalWatchRoots ?? []) openExternalWatcher(root, externalWatchers);
1818
+ session.onExternalRootResolved = (root) => {
1819
+ if (closed) return;
1820
+ if (externalWatchers.has(root) || pendingOpened.has(root)) return;
1821
+ openExternalWatcher(root, pendingOpened);
1822
+ };
1823
+ session.onExternalRootsCommitted = (roots) => {
1824
+ if (closed) return;
1825
+ const admitted = new Set(roots);
1826
+ for (const [root, watcher] of pendingOpened) externalWatchers.set(root, watcher);
1827
+ pendingOpened = /* @__PURE__ */ new Map();
1828
+ for (const [root, watcher] of [...externalWatchers]) if (!admitted.has(root)) {
1829
+ watcher.close();
1830
+ externalWatchers.delete(root);
1831
+ }
1832
+ for (const abs of capturedDuringSnapshot.splice(0)) enqueuePath(abs);
1833
+ };
1000
1834
  return { close: closeAll };
1001
1835
  }
1002
1836
  //#endregion
@@ -1027,9 +1861,11 @@ function withAnimus(options) {
1027
1861
  webpack(config, context) {
1028
1862
  if (typeof existingWebpack === "function") config = existingWebpack(config, context);
1029
1863
  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");
1864
+ const plugin = new AnimusWebpackPlugin(options);
1865
+ plugin.setRootDir(rootDir);
1866
+ const sessionDir = plugin.sessionDir;
1867
+ if (!(0, fs.existsSync)(sessionDir)) (0, fs.mkdirSync)(sessionDir, { recursive: true });
1868
+ const stubCssPath = require_session_paths.stylesPath(sessionDir);
1033
1869
  if (!(0, fs.existsSync)(stubCssPath)) {
1034
1870
  const { declaration } = (0, _animus_ui_extract_pipeline.assembleStylesheet)({
1035
1871
  layers: options.layers,
@@ -1048,28 +1884,26 @@ function withAnimus(options) {
1048
1884
  }
1049
1885
  } catch {}
1050
1886
  }
1051
- const plugin = new AnimusWebpackPlugin(options);
1052
- const isExternalPackageFile = (filePath) => plugin.getExternalPackageDirs().some((dir) => filePath.startsWith(dir + path.sep) || filePath === dir);
1887
+ const isExternalPackageFile = (filePath) => plugin.getExternalPackageDirs().some((dir) => (0, _animus_ui_extract_pipeline.isPathWithinRoot)(dir, filePath));
1053
1888
  config.plugins = config.plugins || [];
1054
1889
  config.plugins.push(plugin);
1055
1890
  const { DefinePlugin } = context.webpack ?? {};
1056
1891
  if (typeof DefinePlugin === "function") config.plugins.push(new DefinePlugin({ __ANIMUS_DEV__: JSON.stringify(context.dev === true) }));
1057
1892
  config.resolve = config.resolve || {};
1058
1893
  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");
1894
+ config.resolve.alias[ANIMUS_CSS_MODULE_ID] = require_session_paths.stylesPath(sessionDir);
1895
+ const sessionSystemPropsPath = require_session_paths.systemPropsPath(sessionDir);
1061
1896
  config.plugins.push({ apply(compiler) {
1062
1897
  compiler.hooks.normalModuleFactory.tap("AnimusVirtualResolve", (nmf) => {
1063
1898
  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");
1899
+ if (resolveData.request === "virtual:animus/system-props") resolveData.request = sessionSystemPropsPath;
1900
+ if (resolveData.request === "virtual:animus/styles.css") resolveData.request = require_session_paths.stylesPath(sessionDir);
1066
1901
  const srcEntry = plugin.getExternalSourceEntries().get(resolveData.request);
1067
1902
  if (srcEntry) resolveData.request = srcEntry;
1068
1903
  });
1069
1904
  });
1070
1905
  } });
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");
1906
+ const actualLoaderPath = resolveAnimusLoaderPath();
1073
1907
  config.module = config.module || {};
1074
1908
  config.module.rules = config.module.rules || [];
1075
1909
  config.module.rules.push({
@@ -1113,7 +1947,9 @@ async function wireTurbopack(nextConfig, options) {
1113
1947
  rootDir,
1114
1948
  loaderPath: resolveTurbopackLoaderPath(__dirname$1),
1115
1949
  options,
1116
- externalSourceEntries: session.externalSourceEntries
1950
+ externalSourceEntries: session.externalSourceEntries,
1951
+ sessionId: session.sessionId,
1952
+ sessionDir: session.sessionDir
1117
1953
  });
1118
1954
  const existing = nextConfig.turbopack ?? {};
1119
1955
  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`);