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