@pi-archimedes/core 2.10.3 → 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/README.md +3 -3
- package/package.json +7 -4
- package/src/bridge/events.test.ts +438 -0
- package/src/bridge/events.ts +98 -39
- package/src/bridge/index.test.ts +50 -22
- package/src/bridge/index.ts +18 -5
- package/src/bridge/self-usage.test.ts +90 -12
- package/src/bridge/self-usage.ts +19 -6
- package/src/chrome-old-host.test.ts +84 -0
- package/src/chrome.test.ts +435 -2
- package/src/chrome.ts +138 -20
- package/src/events-host-bus.test.ts +113 -0
- package/src/events.test.ts +85 -0
- package/src/events.ts +107 -0
- package/src/index.test.ts +3 -9
- package/src/index.ts +5 -5
- package/src/log-discipline.test.ts +187 -0
- package/src/log.test.ts +172 -0
- package/src/log.ts +69 -0
- package/src/overlay.test.ts +178 -6
- package/src/overlay.ts +46 -62
- package/src/pi-support.test.ts +444 -0
- package/src/pi-support.ts +190 -0
- package/src/profiler.ts +14 -5
- package/src/screen-mode-discipline.test.ts +214 -0
- package/src/screen-mode.test.ts +48 -0
- package/src/screen-mode.ts +45 -0
- package/src/settings-io.test.ts +174 -1
- package/src/settings-io.ts +54 -0
- package/src/text.test.ts +58 -0
- package/src/text.ts +21 -20
- package/src/tool-render.test.ts +48 -0
- package/src/tool-render.ts +44 -0
- package/src/bus.test.ts +0 -154
- package/src/bus.ts +0 -137
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)
|
|
4
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
+
});
|