@danypops/pi-packed 0.25.1 → 0.26.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-packed",
3
- "version": "0.25.1",
3
+ "version": "0.26.0",
4
4
  "description": "Pi package lifecycle, validation, daemon, tools, profiles, and TUI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  "typecheck": "bunx tsc --noEmit"
28
28
  },
29
29
  "dependencies": {
30
- "@danypops/armada": "^0.4.6",
30
+ "@danypops/armada": "^0.4.8",
31
31
  "@danypops/packed": "^0.7.0",
32
32
  "@danypops/pi-extension-harness": "^0.7.0",
33
33
  "@danypops/vehicle-client": "^0.8.1",
@@ -10,6 +10,7 @@ import { existsSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import { createNodeServiceInstallDeps } from "@danypops/vehicle-server/service";
12
12
  import { listPackageResources, type PackageResources, resolveInstalledDir } from "../packages/resources.ts";
13
+ import { type DuplicateDependencyDiagnostic, findDuplicateDependencyVersions } from "./duplicate-dependency-doctor.ts";
13
14
  import type { ModuleFreshnessDiagnostic } from "./module-freshness.ts";
14
15
  import { checkServiceUnitPaths, type ServiceUnitDiagnostic } from "./service-doctor.ts";
15
16
  import { runExtensionSmoke, type SmokeOptions, type SmokeRegistrations } from "./smoke.ts";
@@ -56,6 +57,8 @@ export interface DoctorReport {
56
57
  truncated: boolean;
57
58
  /** A daemon-backed package's systemd --user unit referencing a path that no longer exists on disk -- see service-doctor.ts. Empty (not omitted) on a platform without systemd --user coverage. */
58
59
  serviceUnits: ServiceUnitDiagnostic[];
60
+ /** Any @danypops/* package resolved to more than one distinct version anywhere in piHome's own npm tree -- see duplicate-dependency-doctor.ts's own doc comment for why this stays out of `ok`. */
61
+ duplicateDependencies: DuplicateDependencyDiagnostic[];
59
62
  /**
60
63
  * Whether THIS running process still holds a stale in-memory copy of one
61
64
  * of its own runtime dependencies, loaded once at startup -- see
@@ -140,6 +143,7 @@ export async function runDoctor(piHome: string, projectRoot?: string, options: S
140
143
  scanned: bounded.length,
141
144
  truncated,
142
145
  serviceUnits: serviceReport.diagnostics,
146
+ duplicateDependencies: findDuplicateDependencyVersions(piHome),
143
147
  };
144
148
  }
145
149
 
@@ -161,6 +165,10 @@ export function formatDoctorReport(report: DoctorReport, json: boolean): string
161
165
  for (const diagnostic of report.serviceUnits) {
162
166
  out += `\n${diagnostic.severity.toUpperCase()} ${diagnostic.code} ${diagnostic.package} (${diagnostic.unitName}.service): ${diagnostic.message}\n`;
163
167
  }
168
+ for (const duplicate of report.duplicateDependencies) {
169
+ const versions = duplicate.locations.map((location) => `${location.version} (${location.path})`).join(", ");
170
+ out += `\nDUPLICATE_DEPENDENCY_VERSION ${duplicate.name}: ${versions}\n`;
171
+ }
164
172
  for (const module of report.moduleFreshness ?? []) {
165
173
  if (!module.stale) continue;
166
174
  out += `\nSTALE_MODULE_CACHE ${module.name}: the daemon loaded ${module.loadedVersion ?? "an unknown version"} at startup, but ${module.currentVersion ?? "a different version"} is now on disk -- restart the daemon (systemctl --user restart pi-packed.service) to pick it up.\n`;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * duplicate-dependency-doctor.ts — walks piHome's npm/node_modules tree
3
+ * (every top-level @danypops/<name>, plus one level of nesting -- the same
4
+ * shadow-copy shape findShadowCopies walks for one named package) and
5
+ * reports any @danypops/<name> resolved to more than one distinct version
6
+ * at once. Generalizes a bug class that recurred four times in one
7
+ * session (pi-web-spider, pi-tickets, pi-papyrus/jittor, pi-packed itself):
8
+ * a stale dependency floor forces a private nested copy instead of
9
+ * deduping to the shared hoisted one.
10
+ *
11
+ * Informational only, not part of DoctorReport's own `ok` -- a duplicate
12
+ * version isn't always a bug (an incompatible major range from a third
13
+ * party can coexist safely), so failing the whole report on every one
14
+ * would make a real, actionable case easy to tune out.
15
+ */
16
+ import { readdirSync, readFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+
19
+ export interface DuplicateDependencyLocation {
20
+ readonly path: string;
21
+ readonly version: string;
22
+ }
23
+
24
+ export interface DuplicateDependencyDiagnostic {
25
+ readonly name: string;
26
+ readonly locations: readonly DuplicateDependencyLocation[];
27
+ }
28
+
29
+ /** Matches this file's own scan bound -- a tree-wide sweep never processes an unbounded package list. */
30
+ const MAX_PACKAGES_SCANNED = 2_000;
31
+
32
+ interface FoundPackage {
33
+ readonly name: string;
34
+ readonly version: string;
35
+ readonly path: string;
36
+ }
37
+
38
+ function readNameAndVersion(packageJsonPath: string): { name?: string; version?: string } {
39
+ try {
40
+ const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown; version?: unknown };
41
+ return {
42
+ name: typeof pkg.name === "string" ? pkg.name : undefined,
43
+ version: typeof pkg.version === "string" ? pkg.version : undefined,
44
+ };
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ /** Every @danypops/<name> directory directly under nodeModulesDir -- never throws; a missing or
51
+ * unreadable directory is just an empty result. */
52
+ function listDanypopsPackageDirs(nodeModulesDir: string): string[] {
53
+ let scoped: string[];
54
+ try {
55
+ scoped = readdirSync(join(nodeModulesDir, "@danypops"));
56
+ } catch {
57
+ return [];
58
+ }
59
+ return scoped.map((name) => join(nodeModulesDir, "@danypops", name));
60
+ }
61
+
62
+ function scanInto(nodeModulesDir: string, budget: { remaining: number }, found: FoundPackage[]): void {
63
+ for (const dir of listDanypopsPackageDirs(nodeModulesDir)) {
64
+ if (budget.remaining <= 0) return;
65
+ budget.remaining--;
66
+ const { name, version } = readNameAndVersion(join(dir, "package.json"));
67
+ if (name && version) found.push({ name, version, path: dir });
68
+ // One level of nesting under THIS package's own node_modules -- the real, confirmed-live
69
+ // shadow-copy shape a stale floor produces; never descends deeper (matches findShadowCopies'
70
+ // own bound -- npm's own dedup is responsible for anything nested further than that).
71
+ for (const nestedDir of listDanypopsPackageDirs(join(dir, "node_modules"))) {
72
+ if (budget.remaining <= 0) return;
73
+ budget.remaining--;
74
+ const nested = readNameAndVersion(join(nestedDir, "package.json"));
75
+ if (nested.name && nested.version) found.push({ name: nested.name, version: nested.version, path: nestedDir });
76
+ }
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Every @danypops/<name> resolved to more than one distinct version
82
+ * anywhere under piHome/npm/node_modules -- sorted by name for stable
83
+ * output. Two locations at the SAME version are never reported (npm simply
84
+ * didn't need to hoist further, not a real split); only an actual version
85
+ * disagreement is.
86
+ */
87
+ export function findDuplicateDependencyVersions(piHome: string): DuplicateDependencyDiagnostic[] {
88
+ const found: FoundPackage[] = [];
89
+ scanInto(join(piHome, "npm", "node_modules"), { remaining: MAX_PACKAGES_SCANNED }, found);
90
+
91
+ const byName = new Map<string, DuplicateDependencyLocation[]>();
92
+ for (const pkg of found) {
93
+ byName.set(pkg.name, [...(byName.get(pkg.name) ?? []), { path: pkg.path, version: pkg.version }]);
94
+ }
95
+
96
+ const diagnostics: DuplicateDependencyDiagnostic[] = [];
97
+ for (const [name, locations] of byName) {
98
+ const distinctVersions = new Set(locations.map((location) => location.version));
99
+ if (distinctVersions.size > 1) diagnostics.push({ name, locations });
100
+ }
101
+ diagnostics.sort((a, b) => a.name.localeCompare(b.name));
102
+ return diagnostics;
103
+ }
@@ -38,6 +38,7 @@ import {
38
38
  } from "@danypops/vehicle-server/service";
39
39
  import { npmPackageName, readInstalledPackagesAcrossScopes, readPackageDeclarations } from "../packages/installed.ts";
40
40
  import type { InstalledPkg } from "../packages/package.ts";
41
+ import { versionAtLeast } from "../publish/publish.ts";
41
42
 
42
43
  export interface DaemonServiceManifest {
43
44
  /** Relative to the installed package's own root directory. */
@@ -125,6 +126,39 @@ function dependsOnVehicle(pkg: InstalledPackageJson): boolean {
125
126
  * web-spider, packed, enigma all expose a `serve` subcommand on the same
126
127
  * bin their `service install` command already points at).
127
128
  */
129
+ /**
130
+ * Resolves ONE candidate directory to a daemon entrypoint -- factored out
131
+ * so the nested and hoisted candidates for the same dependency name can
132
+ * each be resolved independently and then compared (see
133
+ * detectVehicleDaemonService), instead of the first one found
134
+ * unconditionally winning.
135
+ */
136
+ function resolveDependencyCandidate(depDir: string, depName: string): ResolvedDaemonEntrypoint | undefined {
137
+ const dep = readPackageJson(depDir);
138
+ if (!dep) return undefined;
139
+ // An explicit manifest wins over convention detection -- confirmed live: papyrus's real
140
+ // handle file is "vehicle-handle.json", not the "daemon.json" convention guess.
141
+ const depManifest = dep.packed?.daemonService;
142
+ if (depManifest && typeof depManifest.binPath === "string" && depManifest.binPath.length > 0 && dep.version) {
143
+ return {
144
+ binPath: join(depDir, depManifest.binPath),
145
+ args: depManifest.args,
146
+ name: depManifest.name ?? unscopedName(dep.name ?? depName),
147
+ displayName: depManifest.displayName,
148
+ handleFilename: depManifest.handleFilename,
149
+ workingDirectory: depManifest.workingDirectory,
150
+ restartOnFailure: depManifest.restartOnFailure,
151
+ restartSec: depManifest.restartSec,
152
+ version: dep.version,
153
+ };
154
+ }
155
+ const depBin = firstBinPath(dep);
156
+ if (depBin && dependsOnVehicle(dep) && dep.version) {
157
+ return { binPath: join(depDir, depBin), args: ["serve"], name: unscopedName(dep.name ?? depName), version: dep.version };
158
+ }
159
+ return undefined;
160
+ }
161
+
128
162
  export function detectVehicleDaemonService(
129
163
  packageDir: string,
130
164
  fallbackName: string,
@@ -143,36 +177,19 @@ export function detectVehicleDaemonService(
143
177
  join(packageDir, "node_modules", depName),
144
178
  ...(hoistedNodeModulesDir ? [join(hoistedNodeModulesDir, depName)] : []),
145
179
  ];
146
- for (const depDir of candidateDirs) {
147
- const dep = readPackageJson(depDir);
148
- if (!dep) continue;
149
- // An explicit manifest on the dependency itself wins over convention detection here too --
150
- // mirrors resolveDaemonServiceSpec's own explicit-manifest-first behavior for a directly
151
- // installed package. Without this, a dependency's own correct, explicit
152
- // handleFilename/name/etc. was silently discarded in favor of a guess whenever the daemon
153
- // was discovered one level down instead of installed directly by name -- exactly the
154
- // common case (a Pi extension like pi-papyrus has no daemon of its own). Confirmed live:
155
- // papyrus's real handle file is "vehicle-handle.json", not the "daemon.json" convention
156
- // guess, and papyrus is only ever discovered this way, never installed directly by name.
157
- const depManifest = dep.packed?.daemonService;
158
- if (depManifest && typeof depManifest.binPath === "string" && depManifest.binPath.length > 0 && dep.version) {
159
- return {
160
- binPath: join(depDir, depManifest.binPath),
161
- args: depManifest.args,
162
- name: depManifest.name ?? unscopedName(dep.name ?? depName),
163
- displayName: depManifest.displayName,
164
- handleFilename: depManifest.handleFilename,
165
- workingDirectory: depManifest.workingDirectory,
166
- restartOnFailure: depManifest.restartOnFailure,
167
- restartSec: depManifest.restartSec,
168
- version: dep.version,
169
- };
170
- }
171
- const depBin = firstBinPath(dep);
172
- if (depBin && dependsOnVehicle(dep) && dep.version) {
173
- return { binPath: join(depDir, depBin), args: ["serve"], name: unscopedName(dep.name ?? depName), version: dep.version };
174
- }
180
+ const resolved = candidateDirs
181
+ .map((depDir) => resolveDependencyCandidate(depDir, depName))
182
+ .filter((entry): entry is ResolvedDaemonEntrypoint => entry !== undefined);
183
+ if (resolved.length === 0) continue;
184
+ if (resolved.length === 1) return resolved[0];
185
+ // Nested and hoisted both resolved for the same dependency (the real jittor incident:
186
+ // a stale nested copy always won just because it's checked first). Prefer the newer
187
+ // version; a genuine tie keeps the nested result, preserving papyrus's intentional case.
188
+ let best = resolved[0]!;
189
+ for (const candidate of resolved.slice(1)) {
190
+ if (versionAtLeast(candidate.version, best.version) && candidate.version !== best.version) best = candidate;
175
191
  }
192
+ return best;
176
193
  }
177
194
  return undefined;
178
195
  }
@@ -408,13 +425,7 @@ export interface ReconcileAllResult {
408
425
  reconciled: Array<{ packageName: string; vehicleName: string; installed: boolean; reason?: string }>;
409
426
  skipped: number;
410
427
  failed: Array<{ packageName: string; reason: string }>;
411
- /**
412
- * Every Vehicle unregistered because this same sweep could no longer
413
- * discover it by any path (neither a configured extension's own
414
- * dependency walk nor listUnconfiguredDaemonDependencies' root-pinned
415
- * scan) -- see pruneStaleVehicles' own doc comment for why this is safe
416
- * to do unconditionally rather than requiring a separate opt-in call.
417
- */
428
+ /** Every Vehicle unregistered because this sweep no longer discovers it at all -- see pruneStaleVehicles. */
418
429
  pruned: Array<{ vehicleName: string; executable: string }>;
419
430
  pruneFailed: Array<{ vehicleName: string; reason: string }>;
420
431
  }
@@ -479,43 +490,20 @@ export async function reconcileAllDaemonServices(
479
490
  ...(resolved.result.installed ? {} : { reason: resolved.result.reason }),
480
491
  });
481
492
  }
482
- // listUnconfiguredDaemonDependencies deliberately walks the FULL top-level
483
- // node_modules sweep, not just readInstalledPackagesAcrossScopes' own
484
- // pi:-configured list above -- a root-pinned dependency (e.g. lector,
485
- // pinned directly in piHome/npm/package.json ahead of whatever version its
486
- // own pi-lector wrapper's tree would resolve) is a real, currently-running
487
- // Vehicle that the configured-packages loop above never reaches at all.
488
- // Folding it in here is what makes the discovered set below actually
489
- // complete -- pruning against anything narrower would delete a Vehicle
490
- // that's still genuinely there, just reachable by a different path.
493
+ // Also fold in root-pinned dependencies (e.g. lector) the configured-packages loop above
494
+ // never reaches -- pruning against a narrower set would delete a Vehicle still genuinely there.
491
495
  for (const dep of listUnconfiguredDaemonDependencies(piHome)) seen.add(dep.vehicleName);
492
496
  const { pruned, pruneFailed } = await pruneStaleVehicles(piHome, seen, installer);
493
497
  return { reconciled, skipped, failed, pruned, pruneFailed };
494
498
  }
495
499
 
496
500
  /**
497
- * Unregisters every Vehicle Armada currently declares that `discovered`
498
- * (this same sweep's own complete union of configured-extension and
499
- * root-pinned resolution) no longer produces at all -- the other half of
500
- * what makes Armada authoritative for every Vehicle Packed knows about.
501
- * Without this, a Vehicle whose packed.daemonService.name changed (or that
502
- * a stale nested dependency copy once resolved under a different name --
503
- * see this session's own live incident, a duplicate "web-spider-daemon"
504
- * losing every restart's single-instance-lock race against the correctly-
505
- * named "web-spider" Vehicle) just sits in the manifest forever: nothing
506
- * ever re-discovers it to overwrite it, and nothing ever notices it's gone
507
- * stale either.
508
- *
509
- * Scoped to only ever touch a Vehicle whose own `executable` lives under
510
- * piHome's own npm/node_modules -- i.e. one only Packed itself could
511
- * plausibly have registered in the first place. A Vehicle registered by an
512
- * entirely different mechanism (a hand-rolled `armada upsert` pointing
513
- * somewhere else on disk) is never a candidate, no matter how the
514
- * discovered set above turns out -- this function has no way to know that
515
- * caller's own intent, so it stays out of its way entirely. Packed's own
516
- * Vehicle (PACKED_VEHICLE_NAME) is excluded for the same reason
517
- * install()/remove()/restart() already refuse to touch it: reconcile runs
518
- * from inside Packed's own process.
501
+ * Unregisters every Vehicle Armada declares that `discovered` no longer
502
+ * produces at all -- otherwise a renamed/collided Vehicle (see the real
503
+ * web-spider-daemon incident) sits in the manifest forever. Scoped to only
504
+ * ever touch a Vehicle whose `executable` lives under piHome's own
505
+ * npm/node_modules -- one Packed itself could plausibly have registered.
506
+ * Packed's own Vehicle is excluded, matching install/remove/restart.
519
507
  */
520
508
  async function pruneStaleVehicles(
521
509
  piHome: string,
@@ -1059,7 +1059,7 @@ describe("CLI", () => {
1059
1059
  },
1060
1060
  async doctor(projectRoot) {
1061
1061
  calls.push(`doctor:${projectRoot}`);
1062
- return { ok: true, conflicts: [], extensions: [], scanned: 0, truncated: false, serviceUnits: [] };
1062
+ return { ok: true, conflicts: [], extensions: [], scanned: 0, truncated: false, serviceUnits: [], duplicateDependencies: [] };
1063
1063
  },
1064
1064
  async updatesForProject(projectRoot) {
1065
1065
  calls.push(`updatesForProject:${projectRoot}`);
@@ -1101,6 +1101,7 @@ describe("CLI", () => {
1101
1101
  scanned: 0,
1102
1102
  truncated: false,
1103
1103
  serviceUnits: [],
1104
+ duplicateDependencies: [],
1104
1105
  });
1105
1106
  expect(JSON.parse((await cliRun(["updates", "--project", "/tmp/project", "--json"], d)).out).updates).toEqual([]);
1106
1107
  expect(calls).toEqual([
@@ -316,7 +316,7 @@ describe("startPackedDaemon's own maintenance-task wiring (self-heals Vehicle dr
316
316
  });
317
317
  });
318
318
 
319
- describe("reconcileAllDaemonServices pruning -- unregisters a Vehicle no longer discoverable by any path (the-armada-registrar-jittor-web-spider-daemon-collision)", () => {
319
+ describe("reconcileAllDaemonServices pruning (the-armada-registrar-jittor-web-spider-daemon-collision)", () => {
320
320
  function stalePiHomeVehicle(piHome: string, name: string): VehicleSpec {
321
321
  return {
322
322
  name: name as VehicleSpec["name"],
@@ -208,6 +208,60 @@ describe("resolveDaemonServiceSpec", () => {
208
208
  expect(result.spec.restartSec).toBe(2);
209
209
  });
210
210
 
211
+ it("prefers the higher-semver candidate when BOTH a nested and hoisted copy of the same dependency resolve via bin+dependency convention -- the real jittor incident: a stale nested copy must never win over a correctly-versioned hoisted one just because it's checked first", () => {
212
+ const piHome = fakePiHome();
213
+ const extDir = join(piHome, "npm", "node_modules", "@danypops/pi-papyrus");
214
+ writeRawPackage(extDir, { name: "@danypops/pi-papyrus", version: "1.0.0", dependencies: { "@danypops/jittor": "^0.14.0" } });
215
+ // A stale nested copy pi-papyrus's own outdated floor forced npm to keep private, instead of
216
+ // deduping to the shared, correctly-versioned, hoisted copy every other consumer already uses.
217
+ const nestedDir = join(extDir, "node_modules", "@danypops/jittor");
218
+ writeRawPackage(nestedDir, {
219
+ name: "@danypops/jittor",
220
+ version: "0.14.0",
221
+ bin: { jittor: "src/cli.ts" },
222
+ dependencies: { "@danypops/vehicle-server": "^0.3.1" },
223
+ });
224
+ const hoistedDir = join(piHome, "npm", "node_modules", "@danypops/jittor");
225
+ writeRawPackage(hoistedDir, {
226
+ name: "@danypops/jittor",
227
+ version: "0.18.1",
228
+ bin: { jittor: "src/cli.ts" },
229
+ dependencies: { "@danypops/vehicle-server": "^0.17.0" },
230
+ });
231
+
232
+ const result = resolveDaemonServiceSpec(piHome, "npm:@danypops/pi-papyrus");
233
+ expect(result.ok).toBe(true);
234
+ if (!result.ok) throw new Error("expected ok");
235
+ expect(result.spec.binPath).toBe(join(hoistedDir, "src/cli.ts"));
236
+ expect(result.spec.version).toBe("0.18.1");
237
+ });
238
+
239
+ it("keeps the nested copy when it is genuinely the newer (or equal) one -- the tie-break only ever prefers a candidate that's actually newer, never blindly flips to hoisted", () => {
240
+ const piHome = fakePiHome();
241
+ const extDir = join(piHome, "npm", "node_modules", "@danypops/pi-papyrus");
242
+ writeRawPackage(extDir, { name: "@danypops/pi-papyrus", version: "1.0.0", dependencies: { "@danypops/jittor": "^0.18.0" } });
243
+ const nestedDir = join(extDir, "node_modules", "@danypops/jittor");
244
+ writeRawPackage(nestedDir, {
245
+ name: "@danypops/jittor",
246
+ version: "0.18.1",
247
+ bin: { jittor: "src/cli.ts" },
248
+ dependencies: { "@danypops/vehicle-server": "^0.17.0" },
249
+ });
250
+ const hoistedDir = join(piHome, "npm", "node_modules", "@danypops/jittor");
251
+ writeRawPackage(hoistedDir, {
252
+ name: "@danypops/jittor",
253
+ version: "0.14.0",
254
+ bin: { jittor: "src/cli.ts" },
255
+ dependencies: { "@danypops/vehicle-server": "^0.3.1" },
256
+ });
257
+
258
+ const result = resolveDaemonServiceSpec(piHome, "npm:@danypops/pi-papyrus");
259
+ expect(result.ok).toBe(true);
260
+ if (!result.ok) throw new Error("expected ok");
261
+ expect(result.spec.binPath).toBe(join(nestedDir, "src/cli.ts"));
262
+ expect(result.spec.version).toBe("0.18.1");
263
+ });
264
+
211
265
  it("also detects the legacy @danypops/daemon-kit dependency name, not just vehicle-server", () => {
212
266
  const piHome = fakePiHome();
213
267
  const dir = join(piHome, "npm", "node_modules", "@danypops/web-spider-daemon");
@@ -169,6 +169,40 @@ describeIfSandboxed("runDoctor", () => {
169
169
  expect(report.ok).toBe(true);
170
170
  expect(report.scanned).toBe(1);
171
171
  });
172
+
173
+ it("surfaces a real duplicate @danypops/* dependency version without failing the overall report -- the same jittor-incident shape, caught proactively this time", async () => {
174
+ const home = piHome(["npm:pi-a"]);
175
+ installNpmPackage(
176
+ home,
177
+ "pi-a",
178
+ { extensions: ["extension/index.ts"] },
179
+ {
180
+ "extension/index.ts": 'export default function (pi: any) { pi.registerTool({ name: "alpha" }); }',
181
+ },
182
+ );
183
+ const nodeModules = join(home, "npm", "node_modules");
184
+ mkdirSync(join(nodeModules, "@danypops", "jittor"), { recursive: true });
185
+ writeFileSync(
186
+ join(nodeModules, "@danypops", "jittor", "package.json"),
187
+ JSON.stringify({ name: "@danypops/jittor", version: "0.18.1" }),
188
+ );
189
+ mkdirSync(join(nodeModules, "@danypops", "pi-a", "node_modules", "@danypops", "jittor"), { recursive: true });
190
+ writeFileSync(
191
+ join(nodeModules, "@danypops", "pi-a", "node_modules", "@danypops", "jittor", "package.json"),
192
+ JSON.stringify({ name: "@danypops/jittor", version: "0.14.0" }),
193
+ );
194
+
195
+ const report = await runDoctor(home);
196
+
197
+ expect(report.duplicateDependencies).toHaveLength(1);
198
+ expect(report.duplicateDependencies[0]?.name).toBe("@danypops/jittor");
199
+ expect(report.ok).toBe(true); // informational only -- never fails the run by itself
200
+
201
+ const text = formatDoctorReport(report, false);
202
+ expect(text).toContain("DUPLICATE_DEPENDENCY_VERSION @danypops/jittor");
203
+ expect(text).toContain("0.18.1");
204
+ expect(text).toContain("0.14.0");
205
+ });
172
206
  });
173
207
 
174
208
  class NoopRegistry implements Registry {
@@ -313,7 +347,7 @@ describe("doctor.run — module freshness (a long-running daemon process's own s
313
347
  });
314
348
 
315
349
  describe("formatDoctorReport — module freshness rendering", () => {
316
- const base = { ok: true, conflicts: [], extensions: [], scanned: 0, truncated: false, serviceUnits: [] };
350
+ const base = { ok: true, conflicts: [], extensions: [], scanned: 0, truncated: false, serviceUnits: [], duplicateDependencies: [] };
317
351
 
318
352
  it("prints a STALE_MODULE_CACHE line with an actionable restart hint for each stale entry, and nothing for a fresh one", () => {
319
353
  const text = formatDoctorReport(
@@ -0,0 +1,124 @@
1
+ /**
2
+ * duplicate-dependency-doctor.test.ts — the general form of the bug class
3
+ * this session hit four separate times: a stale dependency floor forcing
4
+ * a private nested copy instead of deduping to the shared hoisted one.
5
+ */
6
+ import { afterEach, describe, expect, it } from "bun:test";
7
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { findDuplicateDependencyVersions } from "../src/adoption/duplicate-dependency-doctor.ts";
11
+
12
+ const roots: string[] = [];
13
+ afterEach(() => {
14
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
15
+ });
16
+
17
+ function track(dir: string): string {
18
+ roots.push(dir);
19
+ return dir;
20
+ }
21
+
22
+ function piHome(): string {
23
+ return track(mkdtempSync(join(tmpdir(), "packed-dup-deps-")));
24
+ }
25
+
26
+ function writePackage(dir: string, name: string, version: string): void {
27
+ mkdirSync(dir, { recursive: true });
28
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name, version }));
29
+ }
30
+
31
+ describe("findDuplicateDependencyVersions", () => {
32
+ it("returns nothing for a clean tree with no @danypops packages at all", () => {
33
+ const home = piHome();
34
+ expect(findDuplicateDependencyVersions(home)).toEqual([]);
35
+ });
36
+
37
+ it("returns nothing when every @danypops package resolves to exactly one version, even across several packages", () => {
38
+ const home = piHome();
39
+ const nodeModules = join(home, "npm", "node_modules");
40
+ writePackage(join(nodeModules, "@danypops", "armada"), "@danypops/armada", "0.4.7");
41
+ writePackage(join(nodeModules, "@danypops", "jittor"), "@danypops/jittor", "0.18.1");
42
+
43
+ expect(findDuplicateDependencyVersions(home)).toEqual([]);
44
+ });
45
+
46
+ it("reports the real jittor incident shape: a stale nested copy alongside the correctly-versioned hoisted one", () => {
47
+ const home = piHome();
48
+ const nodeModules = join(home, "npm", "node_modules");
49
+ const hoistedDir = join(nodeModules, "@danypops", "jittor");
50
+ writePackage(hoistedDir, "@danypops/jittor", "0.18.1");
51
+ const nestedDir = join(nodeModules, "@danypops", "pi-papyrus", "node_modules", "@danypops", "jittor");
52
+ writePackage(nestedDir, "@danypops/jittor", "0.14.0");
53
+ writePackage(join(nodeModules, "@danypops", "pi-papyrus"), "@danypops/pi-papyrus", "1.0.0");
54
+
55
+ const diagnostics = findDuplicateDependencyVersions(home);
56
+
57
+ expect(diagnostics).toHaveLength(1);
58
+ expect(diagnostics[0]!.name).toBe("@danypops/jittor");
59
+ const versions = diagnostics[0]!.locations.map((location) => location.version).sort();
60
+ expect(versions).toEqual(["0.14.0", "0.18.1"]);
61
+ const paths = diagnostics[0]!.locations.map((location) => location.path).sort();
62
+ expect(paths).toEqual([hoistedDir, nestedDir].sort());
63
+ });
64
+
65
+ it("does NOT report two locations at the exact same version -- npm simply didn't need to hoist further, not a real split", () => {
66
+ const home = piHome();
67
+ const nodeModules = join(home, "npm", "node_modules");
68
+ writePackage(join(nodeModules, "@danypops", "vehicle-core"), "@danypops/vehicle-core", "0.17.0");
69
+ writePackage(
70
+ join(nodeModules, "@danypops", "pi-tickets", "node_modules", "@danypops", "vehicle-core"),
71
+ "@danypops/vehicle-core",
72
+ "0.17.0",
73
+ );
74
+ writePackage(join(nodeModules, "@danypops", "pi-tickets"), "@danypops/pi-tickets", "1.0.0");
75
+
76
+ expect(findDuplicateDependencyVersions(home)).toEqual([]);
77
+ });
78
+
79
+ it("never descends into a nested copy's OWN node_modules -- one level of nesting is the real, confirmed shape; deeper is npm's own dedup problem", () => {
80
+ const home = piHome();
81
+ const nodeModules = join(home, "npm", "node_modules");
82
+ writePackage(join(nodeModules, "@danypops", "vehicle-server"), "@danypops/vehicle-server", "0.24.1");
83
+ const oneLevelDir = join(nodeModules, "@danypops", "pi-packed", "node_modules", "@danypops", "vehicle-server");
84
+ writePackage(oneLevelDir, "@danypops/vehicle-server", "0.24.1");
85
+ writePackage(join(nodeModules, "@danypops", "pi-packed"), "@danypops/pi-packed", "1.0.0");
86
+ // Two levels deep -- must never be reached.
87
+ writePackage(
88
+ join(oneLevelDir, "node_modules", "@danypops", "vehicle-core"),
89
+ "@danypops/vehicle-core",
90
+ "9.9.9",
91
+ );
92
+ writePackage(join(nodeModules, "@danypops", "vehicle-core"), "@danypops/vehicle-core", "0.17.0");
93
+
94
+ expect(findDuplicateDependencyVersions(home)).toEqual([]);
95
+ });
96
+
97
+ it("sorts multiple diagnostics by package name for stable output", () => {
98
+ const home = piHome();
99
+ const nodeModules = join(home, "npm", "node_modules");
100
+ writePackage(join(nodeModules, "@danypops", "web-spider-daemon"), "@danypops/web-spider-daemon", "0.24.2");
101
+ writePackage(
102
+ join(nodeModules, "@danypops", "pi-web-spider", "node_modules", "@danypops", "web-spider-daemon"),
103
+ "@danypops/web-spider-daemon",
104
+ "0.24.0",
105
+ );
106
+ writePackage(join(nodeModules, "@danypops", "pi-web-spider"), "@danypops/pi-web-spider", "1.0.0");
107
+ writePackage(join(nodeModules, "@danypops", "armada"), "@danypops/armada", "0.4.7");
108
+ writePackage(
109
+ join(nodeModules, "@danypops", "pi-packed", "node_modules", "@danypops", "armada"),
110
+ "@danypops/armada",
111
+ "0.4.3",
112
+ );
113
+ writePackage(join(nodeModules, "@danypops", "pi-packed"), "@danypops/pi-packed", "1.0.0");
114
+
115
+ const diagnostics = findDuplicateDependencyVersions(home);
116
+
117
+ expect(diagnostics.map((diagnostic) => diagnostic.name)).toEqual(["@danypops/armada", "@danypops/web-spider-daemon"]);
118
+ });
119
+
120
+ it("returns [] rather than throwing when piHome/npm/node_modules doesn't exist yet", () => {
121
+ const home = piHome();
122
+ expect(findDuplicateDependencyVersions(home)).toEqual([]);
123
+ });
124
+ });