@pi-archimedes/core 2.10.4 → 2.11.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/src/overlay.ts CHANGED
@@ -1,49 +1,53 @@
1
1
  /**
2
2
  * Shared overlay chrome — border wrapping, text width math, header/footer
3
- * rendering. Used by the /agents manager (subagent) and the /archimedes
4
- * settings overlay (meta) so both screens look identical.
3
+ * rendering. Used by the /agents manager (subagent), the /archimedes settings
4
+ * overlay and the /plugins + onboarding overlays (meta) so all of them look
5
+ * identical.
6
+ *
7
+ * All terminal-width maths here is delegated to `@earendil-works/pi-tui`:
8
+ * `visibleWidth` for measurement, `truncateToWidth` for cutting and padding,
9
+ * `wrapTextWithAnsi` for word wrapping. `docs/tui.md` asks for exactly that,
10
+ * and a local re-implementation cannot be right: measuring `.length` after
11
+ * stripping only `\x1b[…m` counts pi's `CURSOR_MARKER` (an APC sequence) as
12
+ * seven drawn columns, counts an OSC 8 hyperlink's URL as drawn text, and
13
+ * counts a CJK glyph as one column when the terminal draws two. The exported
14
+ * names below are the shared surface; only their bodies changed.
5
15
  */
6
16
 
17
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
18
+
7
19
  /** Minimal structural theme — anything with `fg` satisfies it (the real pi-coding-agent Theme, agent-manager's local interface, or a test mock). */
8
20
  export interface OverlayTheme {
9
21
  fg(token: string, text: string): string;
10
22
  }
11
23
 
24
+ /**
25
+ * Visible terminal columns of a string. pi-tui's measurement, re-exported so
26
+ * every consumer shares one implementation: it ignores SGR, OSC (hyperlinks,
27
+ * prompt titles) and APC (pi's cursor marker) and honours east-asian widths.
28
+ */
29
+ export { visibleWidth };
30
+
31
+ /**
32
+ * Word-wrap `text` to `width` visible columns, preserving ANSI styling across
33
+ * the break. Delegates to pi-tui's `wrapTextWithAnsi`, so the break point is
34
+ * chosen by drawn columns — wide characters count as two, escapes as zero — and
35
+ * a word that cannot fit is broken rather than allowed to overflow.
36
+ */
12
37
  export function wrapText(text: string, width: number): string[] {
13
38
  if (width <= 0) return [];
14
- const lines: string[] = [];
15
- const paragraphs = text.split("\n");
16
- for (const para of paragraphs) {
17
- if (para.length === 0) {
18
- lines.push("");
19
- continue;
20
- }
21
- const words = para.split(/(\s+)/).filter(Boolean);
22
- let current = "";
23
- for (const word of words) {
24
- const test = current === "" ? word : current + word;
25
- if (test.length > width && current.length > 0) {
26
- lines.push(current);
27
- current = word;
28
- } else {
29
- current = test;
30
- }
31
- }
32
- if (current) lines.push(current);
33
- }
34
- return lines;
39
+ return wrapTextWithAnsi(text, width);
35
40
  }
36
41
 
42
+ /**
43
+ * Make `text` exactly `width` columns wide: pad with spaces when short, cut
44
+ * when long. Delegates to `truncateToWidth` with an empty ellipsis, so a line
45
+ * that was too long lands on the frame instead of hanging off it (the row is
46
+ * never longer than `width`, which is what keeps the closing border on-screen).
47
+ */
37
48
  export function padEnd(text: string, width: number): string {
38
49
  if (width <= 0) return "";
39
- const vw = visibleWidth(text);
40
- if (vw >= width) return text;
41
- return text + " ".repeat(width - vw);
42
- }
43
-
44
- export function visibleWidth(text: string): number {
45
- // Strip ANSI escape sequences for width calculation
46
- return text.replace(/\x1b\[[0-9;]*m/g, "").length;
50
+ return truncateToWidth(text, width, "", true);
47
51
  }
48
52
 
49
53
  export function renderHeader(text: string, width: number, theme: OverlayTheme): string {
@@ -66,35 +70,14 @@ export const OVERLAY_CHROME = { anchor: "center", width: 84, maxHeight: "80%" }
66
70
 
67
71
  // ── Border wrapper ────────────────────────────────────────────────────────────
68
72
 
69
- /** Hard-truncate by visible width — no "..." suffix. Strips ANSI, truncates, rebuilds. */
73
+ /**
74
+ * Hard-truncate to `maxVisible` columns — no "..." suffix. pi-tui walks the
75
+ * string by grapheme, so a cursor marker, an OSC 8 hyperlink or a wide glyph is
76
+ * never cut in half; any styling the cut landed inside is closed, which also
77
+ * stops it bleeding into the padding and the border.
78
+ */
70
79
  export function hardTruncate(text: string, maxVisible: number): string {
71
- if (visibleWidth(text) <= maxVisible) return text;
72
- // Walk the string, skipping ANSI escape sequences, and stop after
73
- // maxVisible visible characters; copy SGR codes through so styling survives,
74
- // and append a reset at the end so styling doesn't bleed.
75
- let result = "";
76
- let plainPos = 0;
77
- let i = 0;
78
- let copiedSgr = false;
79
- while (i < text.length && plainPos < maxVisible) {
80
- if (text[i] === "\x1b" && text[i + 1] === "[") {
81
- // Copy the escape sequence
82
- let j = i;
83
- while (j < text.length && text[j] !== "m") j++;
84
- result += text.slice(i, j + 1);
85
- copiedSgr = true;
86
- i = j + 1;
87
- } else {
88
- result += text[i];
89
- plainPos++;
90
- i++;
91
- }
92
- }
93
- // Ensure styling doesn't bleed: append reset if we copied SGR and result doesn't end with one
94
- if (copiedSgr && !/\x1b\[0?m$/.test(result)) {
95
- result += "\x1b[0m";
96
- }
97
- return result;
80
+ return truncateToWidth(text, maxVisible, "");
98
81
  }
99
82
 
100
83
  export function wrapWithBorder(lines: string[], width: number, theme: OverlayTheme): string[] {
@@ -106,9 +89,10 @@ export function wrapWithBorder(lines: string[], width: number, theme: OverlayThe
106
89
  const bottom = theme.fg("dim", `└${"─".repeat(innerWidth)}┘`);
107
90
  const result: string[] = [top];
108
91
  for (const line of lines) {
109
- const clamped = hardTruncate(line, contentWidth);
110
- const padded = " " + padEnd(clamped, contentWidth) + " ";
111
- result.push(left + padded + right);
92
+ // One measured call truncates and pads, so the cell is exactly contentWidth
93
+ // columns and every row lands on the frame width.
94
+ const padded = padEnd(line, contentWidth);
95
+ result.push(left + " " + padded + " " + right);
112
96
  }
113
97
  result.push(bottom);
114
98
  return result;
@@ -0,0 +1,444 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import * as fc from "fast-check";
6
+
7
+ // ── What this file pins ─────────────────────────────────────────────────────
8
+ //
9
+ // A `peerDependencies` range protects nobody: `pi install` never installs or
10
+ // solves peers and the loader aliases every host specifier to pi's own copy, so
11
+ // the only thing that can tell a user "your pi is older than this package was
12
+ // built for" is a runtime comparison against the exported `VERSION` (ADR 0026).
13
+ // The behaviour that matters, in order of how much it costs if it breaks:
14
+ //
15
+ // * warn, never refuse — a package that declines to register makes `/plugins`
16
+ // report something enabled that loaded nothing;
17
+ // * silent by construction — no console write on either path (a stray write
18
+ // smears the fullscreen frame, and a subagent child discards it), the
19
+ // message goes into a registry `meta` prints once;
20
+ // * reload-stable — pi builds a fresh jiti/module registry on every extension
21
+ // load, so module-level `let warned = false` re-arms on `/reload` and the
22
+ // aggregated notice would print a second time. The state lives on
23
+ // `globalThis` under `Symbol.for(...)`, which is the pattern `bus.ts` and
24
+ // `profiler.ts` already use for the same reason.
25
+
26
+ // ── Mock the host: VERSION is the whole input to the guard ──────────────────
27
+ //
28
+ // A getter, not a value: the point is to change the running pi version between
29
+ // assertions without reloading the module under test. `checkPiSupport` reads
30
+ // `VERSION` per call, so the getter is observed each time.
31
+ const host = vi.hoisted(() => ({ version: "1.2.0" }));
32
+
33
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
34
+ get VERSION() {
35
+ return host.version;
36
+ },
37
+ }));
38
+
39
+ import {
40
+ compareVersions,
41
+ checkPiSupport,
42
+ reportPiSupport,
43
+ piSupportWarnings,
44
+ takePiSupportNotice,
45
+ } from "./pi-support.js";
46
+
47
+ // ── The global contract ─────────────────────────────────────────────────────
48
+ //
49
+ // `packages/diff` cannot import this module — it is a standalone package with
50
+ // no dependency on core (AGENTS.md) — yet its warning still has to reach meta's
51
+ // aggregated notice. The two implementations meet on these two `Symbol.for`
52
+ // keys and the plain-object entry shape below, so the keys and the shape are
53
+ // part of the public surface and are pinned here as well as in
54
+ // `packages/diff/src/pi-support.test.ts`.
55
+
56
+ const WARNINGS_KEY = Symbol.for("archimedes:piSupportWarnings");
57
+ const NOTICED_KEY = Symbol.for("archimedes:piSupportNoticed");
58
+
59
+ const globals = globalThis as Record<symbol, unknown>;
60
+
61
+ function wipeRegistry(): void {
62
+ delete globals[WARNINGS_KEY];
63
+ delete globals[NOTICED_KEY];
64
+ }
65
+
66
+ beforeEach(() => {
67
+ wipeRegistry();
68
+ host.version = "1.2.0";
69
+ });
70
+
71
+ afterEach(() => {
72
+ wipeRegistry();
73
+ });
74
+
75
+ // ── compareVersions (moved here from ui/src/startup/version.ts) ─────────────
76
+
77
+ const versionArb = fc.tuple(fc.nat(100), fc.nat(100), fc.nat(100)).map(
78
+ ([major, minor, patch]) => `${major}.${minor}.${patch}`,
79
+ );
80
+
81
+ describe("compareVersions", () => {
82
+ it("returns 0 for equal versions", () => {
83
+ expect(compareVersions("1.0.0", "1.0.0")).toBe(0);
84
+ });
85
+
86
+ it("returns 1 when first version is greater", () => {
87
+ expect(compareVersions("2.0.0", "1.9.9")).toBe(1);
88
+ });
89
+
90
+ it("returns -1 when second version is greater", () => {
91
+ expect(compareVersions("1.0.0", "2.0.0")).toBe(-1);
92
+ });
93
+
94
+ it("handles v prefix on first argument", () => {
95
+ expect(compareVersions("v1.2.3", "1.2.3")).toBe(0);
96
+ });
97
+
98
+ it("handles v prefix on second argument", () => {
99
+ expect(compareVersions("1.2.3", "v1.2.3")).toBe(0);
100
+ });
101
+
102
+ it("handles v prefix on both arguments", () => {
103
+ expect(compareVersions("v2.0.0", "v1.0.0")).toBe(1);
104
+ });
105
+
106
+ it("handles partial versions (2 parts vs 3 parts)", () => {
107
+ expect(compareVersions("1.0", "1.0.0")).toBe(0);
108
+ });
109
+
110
+ it("handles partial versions with difference", () => {
111
+ expect(compareVersions("1.1", "1.0.5")).toBe(1);
112
+ });
113
+
114
+ it("handles zero-padded versions", () => {
115
+ expect(compareVersions("01.02.03", "1.2.3")).toBe(0);
116
+ });
117
+
118
+ it("compares minor version correctly", () => {
119
+ expect(compareVersions("1.2.0", "1.1.9")).toBe(1);
120
+ expect(compareVersions("1.1.0", "1.2.0")).toBe(-1);
121
+ });
122
+
123
+ it("compares patch version correctly", () => {
124
+ expect(compareVersions("1.0.5", "1.0.3")).toBe(1);
125
+ expect(compareVersions("1.0.3", "1.0.5")).toBe(-1);
126
+ });
127
+
128
+ it("orders a two-digit minor above a single-digit one (numeric, not lexical)", () => {
129
+ // The whole reason this comparator exists: "1.10.0" > "1.2.0" numerically,
130
+ // and a string comparison would get it backwards.
131
+ expect(compareVersions("1.10.0", "1.2.0")).toBe(1);
132
+ expect(compareVersions("1.2.0", "1.10.0")).toBe(-1);
133
+ });
134
+
135
+ it("treats a non-numeric component as zero rather than NaN", () => {
136
+ // pi publishes lockstep numeric versions, but a suffix must not turn the
137
+ // comparison into NaN — NaN compares false against everything, which would
138
+ // silently make every floor "satisfied" *and* "violated" at once.
139
+ expect(compareVersions("1.2.0-rc.1", "1.2.0")).toBe(0);
140
+ expect(compareVersions("1.x.0", "1.0.0")).toBe(0);
141
+ expect(compareVersions("1.x.0", "1.9.0")).toBe(-1);
142
+ });
143
+
144
+ it("handles all-zero versions", () => {
145
+ expect(compareVersions("0.0.0", "0.0.0")).toBe(0);
146
+ });
147
+
148
+ // ── Property tests ──────────────────────────────────────────────────────
149
+
150
+ it("property: reflexive — compareVersions(a, a) === 0", () => {
151
+ fc.assert(
152
+ fc.property(versionArb, (a) => {
153
+ return compareVersions(a, a) === 0;
154
+ }),
155
+ );
156
+ });
157
+
158
+ it("property: antisymmetric — compareVersions(a, b) === -compareVersions(b, a)", () => {
159
+ fc.assert(
160
+ fc.property(versionArb, versionArb, (a, b) => {
161
+ return compareVersions(a, b) === -compareVersions(b, a);
162
+ }),
163
+ );
164
+ });
165
+
166
+ it("property: transitivity — if a<b and b<c then a<c", () => {
167
+ fc.assert(
168
+ fc.property(
169
+ fc.tuple(versionArb, versionArb, versionArb),
170
+ ([a, b, c]) => {
171
+ const ab = compareVersions(a, b);
172
+ const bc = compareVersions(b, c);
173
+ const ac = compareVersions(a, c);
174
+ if (ab < 0 && bc < 0) {
175
+ return ac < 0;
176
+ }
177
+ return true;
178
+ },
179
+ ),
180
+ );
181
+ });
182
+ });
183
+
184
+ // ── checkPiSupport ──────────────────────────────────────────────────────────
185
+
186
+ describe("checkPiSupport", () => {
187
+ it("accepts the floor exactly (a floor is inclusive)", () => {
188
+ host.version = "1.2.0";
189
+ expect(checkPiSupport("1.2.0")).toEqual({ ok: true, actual: "1.2.0", min: "1.2.0" });
190
+ });
191
+
192
+ it("accepts a higher minor", () => {
193
+ host.version = "1.10.0";
194
+ expect(checkPiSupport("1.2.0").ok).toBe(true);
195
+ });
196
+
197
+ it("rejects a host below the floor", () => {
198
+ host.version = "0.99.2";
199
+ expect(checkPiSupport("1.2.0")).toEqual({ ok: false, actual: "0.99.2", min: "1.2.0" });
200
+ });
201
+
202
+ it("reports the running version and the floor it was measured against", () => {
203
+ host.version = "1.2.0";
204
+ const result = checkPiSupport("0.85.0");
205
+ expect(result).toEqual({ ok: true, actual: "1.2.0", min: "0.85.0" });
206
+ });
207
+ });
208
+
209
+ // ── reportPiSupport: the registry ───────────────────────────────────────────
210
+
211
+ describe("reportPiSupport", () => {
212
+ it("records nothing on a supported host", () => {
213
+ host.version = "1.2.0";
214
+ reportPiSupport("ui", "1.2.0");
215
+ expect(piSupportWarnings()).toEqual([]);
216
+ });
217
+
218
+ it("records the package, the floor and the running version below the floor", () => {
219
+ host.version = "0.99.2";
220
+ reportPiSupport("ui", "1.2.0");
221
+ expect(piSupportWarnings()).toEqual([{ pkg: "ui", min: "1.2.0", actual: "0.99.2" }]);
222
+ });
223
+
224
+ it("is idempotent per package (register() runs again on /reload and on /new)", () => {
225
+ host.version = "0.99.2";
226
+ reportPiSupport("ui", "1.2.0");
227
+ reportPiSupport("ui", "1.2.0");
228
+ reportPiSupport("ui", "1.2.0");
229
+ expect(piSupportWarnings()).toHaveLength(1);
230
+ });
231
+
232
+ it("keeps one entry per package, so several mismatches do not multiply", () => {
233
+ host.version = "0.99.2";
234
+ reportPiSupport("ui", "1.2.0");
235
+ reportPiSupport("subagent", "1.2.0");
236
+ reportPiSupport("ui", "1.2.0");
237
+ expect(piSupportWarnings().map((w) => w.pkg)).toEqual(["ui", "subagent"]);
238
+ });
239
+
240
+ it("stores plain data under the shared Symbol.for key (the contract diff relies on)", () => {
241
+ host.version = "0.99.2";
242
+ reportPiSupport("diff", "1.2.0");
243
+ const registry = globals[WARNINGS_KEY] as Record<string, { pkg: string; min: string; actual: string }>;
244
+ expect(registry).toBeTypeOf("object");
245
+ expect(registry["diff"]).toEqual({ pkg: "diff", min: "1.2.0", actual: "0.99.2" });
246
+ });
247
+
248
+ it("writes nothing to the console, on either path", () => {
249
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
250
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
251
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
252
+
253
+ host.version = "1.2.0";
254
+ reportPiSupport("ui", "1.2.0");
255
+ host.version = "0.99.2";
256
+ reportPiSupport("ui", "1.2.0");
257
+ takePiSupportNotice();
258
+
259
+ expect(log).not.toHaveBeenCalled();
260
+ expect(warn).not.toHaveBeenCalled();
261
+ expect(error).not.toHaveBeenCalled();
262
+ log.mockRestore();
263
+ warn.mockRestore();
264
+ error.mockRestore();
265
+ });
266
+ });
267
+
268
+ // ── takePiSupportNotice: once, aggregated ───────────────────────────────────
269
+
270
+ describe("takePiSupportNotice", () => {
271
+ it("returns nothing when no package reported", () => {
272
+ expect(takePiSupportNotice()).toBeUndefined();
273
+ });
274
+
275
+ it("names every reported package and the running version in ONE message", () => {
276
+ host.version = "0.99.2";
277
+ reportPiSupport("ui", "1.2.0");
278
+ reportPiSupport("subagent", "1.2.0");
279
+ const notice = takePiSupportNotice() ?? "";
280
+ expect(notice).toContain("0.99.2");
281
+ expect(notice).toContain("ui");
282
+ expect(notice).toContain("subagent");
283
+ expect(notice).toContain("1.2.0");
284
+ // One message, not one line per package: the whole point of aggregating.
285
+ expect(notice.split("\n").length).toBeLessThanOrEqual(2);
286
+ });
287
+
288
+ it("is spent after the first read (meta notifies once, not per package or per session_start)", () => {
289
+ host.version = "0.99.2";
290
+ reportPiSupport("ui", "1.2.0");
291
+ expect(takePiSupportNotice()).toBeDefined();
292
+ expect(takePiSupportNotice()).toBeUndefined();
293
+ expect(takePiSupportNotice()).toBeUndefined();
294
+ });
295
+
296
+ it("stays spent even when a later package reports after the notice went out", () => {
297
+ host.version = "0.99.2";
298
+ reportPiSupport("ui", "1.2.0");
299
+ takePiSupportNotice();
300
+ reportPiSupport("subagent", "1.2.0");
301
+ expect(takePiSupportNotice()).toBeUndefined();
302
+ });
303
+
304
+ it("does not spend itself on an empty registry (a warning raised later must still be heard)", () => {
305
+ expect(takePiSupportNotice()).toBeUndefined();
306
+ host.version = "0.99.2";
307
+ reportPiSupport("ui", "1.2.0");
308
+ expect(takePiSupportNotice()).toBeDefined();
309
+ });
310
+ });
311
+
312
+ // ── /reload stability ───────────────────────────────────────────────────────
313
+
314
+ describe("/reload: a fresh module registry must not re-arm the notice", () => {
315
+ it("keeps the registry and the spent notice on globalThis across a module reload", async () => {
316
+ host.version = "0.99.2";
317
+ reportPiSupport("ui", "1.2.0");
318
+ expect(takePiSupportNotice()).toBeDefined();
319
+
320
+ // Pi creates a fresh jiti/module registry on every extension load, so
321
+ // `/reload` re-evaluates pi-support.ts with brand-new module bindings.
322
+ // `vi.resetModules()` is the same shape of event: same process, same
323
+ // globalThis, new module instances.
324
+ vi.resetModules();
325
+ const reloaded = await import("./pi-support.js");
326
+
327
+ // register() ran again in the reloaded extension: the guard re-reports.
328
+ reloaded.reportPiSupport("ui", "1.2.0");
329
+ expect(reloaded.piSupportWarnings()).toHaveLength(1);
330
+ // …and meta must not print the aggregated notice a second time.
331
+ expect(reloaded.takePiSupportNotice()).toBeUndefined();
332
+ });
333
+ });
334
+
335
+ // ── One comparator per dependency island ─────────────────────────────────────
336
+ //
337
+ // The plan's original criterion was "one version comparator in the repo". It is
338
+ // not achievable: `packages/diff` is standalone by design (AGENTS.md — no
339
+ // dependency on core), and its guard needs the same arithmetic, so the honest
340
+ // rule is one comparator per dependency island — core, and diff. This test keeps
341
+ // that at two instead of letting a third copy appear somewhere else, which is
342
+ // where `1.10.0 < 1.2.0` bugs come from.
343
+
344
+ describe("the comparator exists once per dependency island", () => {
345
+ const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
346
+
347
+ const DECLARATION = /^\s*(export\s+)?function compareVersions\b/m;
348
+
349
+ function sourceFiles(): string[] {
350
+ const out: string[] = [];
351
+ const walk = (dir: string): void => {
352
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
353
+ const full = join(dir, entry.name);
354
+ if (entry.isDirectory()) {
355
+ if (entry.name !== "node_modules") walk(full);
356
+ } else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) {
357
+ out.push(relative(REPO_ROOT, full).split(/[\\/]/).join("/"));
358
+ }
359
+ }
360
+ };
361
+ const roots: string[] = [];
362
+ const packagesDir = join(REPO_ROOT, "packages");
363
+ for (const pkg of readdirSync(packagesDir, { withFileTypes: true })) {
364
+ if (!pkg.isDirectory()) continue;
365
+ const src = join(packagesDir, pkg.name, "src");
366
+ if (existsSync(src)) roots.push(src);
367
+ }
368
+ const metaSrc = join(REPO_ROOT, "meta", "src");
369
+ if (existsSync(metaSrc)) roots.push(metaSrc);
370
+ for (const dir of roots) walk(dir);
371
+ return out.sort();
372
+ }
373
+
374
+ it("finds exactly two declarations in the whole suite: core and diff", () => {
375
+ const files = sourceFiles();
376
+ // Non-vacuity: the walk really covers the suite.
377
+ expect(existsSync(join(REPO_ROOT, "pnpm-workspace.yaml"))).toBe(true);
378
+ expect(files.length).toBeGreaterThan(50);
379
+
380
+ const owners = files.filter((f) => DECLARATION.test(readFileSync(join(REPO_ROOT, f), "utf8")));
381
+ expect(owners).toEqual([
382
+ "packages/core/src/pi-support.ts",
383
+ // The standalone island's copy. It is a copy on purpose — see the header
384
+ // of the file, and the ruling in docs/roadmap/pi-1-x-alignment.md.
385
+ "packages/diff/src/pi-support.ts",
386
+ ]);
387
+ });
388
+
389
+ it("leaves `diff` with no dependency on core (the reason the copy exists)", () => {
390
+ const manifest = JSON.parse(
391
+ readFileSync(join(REPO_ROOT, "packages/diff/package.json"), "utf8"),
392
+ ) as { dependencies?: Record<string, string>; peerDependencies?: Record<string, string> };
393
+ expect(Object.keys(manifest.dependencies ?? {})).not.toContain("@pi-archimedes/core");
394
+ expect(Object.keys(manifest.peerDependencies ?? {})).not.toContain("@pi-archimedes/core");
395
+ // …and nothing in its source reaches across the boundary either. Comments
396
+ // may name the sibling module (they have to, to explain the copy); an
397
+ // import is the thing that would break standalone installation.
398
+ const importCore = /from\s+["']@pi-archimedes\/core/;
399
+ expect(
400
+ sourceFiles()
401
+ .filter((f) => f.startsWith("packages/diff/"))
402
+ .filter((f) => importCore.test(readFileSync(join(REPO_ROOT, f), "utf8"))),
403
+ ).toEqual([]);
404
+ });
405
+ });
406
+
407
+ // ── Reading an entry the OTHER island wrote ─────────────────────────────────
408
+ //
409
+ // `packages/diff` writes into this registry without importing this module, so
410
+ // the notice is built from data this file's own writer did not produce — and a
411
+ // standalone `diff` install and an installed suite are independently versioned,
412
+ // so the writer may even be a different build. Two things follow: core must
413
+ // render exactly what diff writes (the pin below), and it must never turn an
414
+ // entry it cannot read into user-visible text.
415
+
416
+ describe("takePiSupportNotice reads the registry as foreign data", () => {
417
+ it("renders an entry written in the shape `diff` writes it", () => {
418
+ // The literal `packages/diff/src/pi-support.ts` produces, byte for byte —
419
+ // not one this module built. If the two ends ever disagree, this fails.
420
+ globals[WARNINGS_KEY] = {
421
+ diff: { pkg: "diff", min: "1.2.0", actual: "0.99.2" },
422
+ };
423
+
424
+ const notice = takePiSupportNotice() ?? "";
425
+ expect(notice).toContain("diff");
426
+ expect(notice).toContain("1.2.0");
427
+ expect(notice).toContain("0.99.2");
428
+ expect(notice).not.toContain("undefined");
429
+ });
430
+
431
+ it("skips an entry it cannot read, and says so in the log instead of printing it", () => {
432
+ // An older or newer sibling under the same symbol key is the whole risk of
433
+ // a shared registry. Building the sentence from missing fields used to
434
+ // produce "pi undefined is older than undefined (>=undefined) need." — a
435
+ // user-visible notice made of nothing.
436
+ globals[WARNINGS_KEY] = { sibling: { version: "1.0", floor: "2.0" } };
437
+
438
+ expect(takePiSupportNotice()).toBeUndefined();
439
+ // Nothing was spent: a registry with nothing reportable in it must not
440
+ // silence a warning that arrives later.
441
+ globals[WARNINGS_KEY] = { ui: { pkg: "ui", min: "1.2.0", actual: "0.99.2" } };
442
+ expect(takePiSupportNotice()).toContain("ui");
443
+ });
444
+ });