@pi-archimedes/diff 2.10.4 → 2.11.1
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 +5 -5
- package/src/ansi/codes.test.ts +27 -3
- package/src/ansi/codes.ts +30 -10
- package/src/ansi/colors.test.ts +189 -7
- package/src/ansi/colors.ts +160 -37
- package/src/default-export.test.ts +16 -6
- package/src/index.ts +64 -23
- package/src/log.test.ts +68 -0
- package/src/log.ts +34 -0
- package/src/pi-support.test.ts +113 -0
- package/src/pi-support.ts +98 -0
- package/src/register-diff-tools.test.ts +81 -0
- package/src/tools/edit.test.ts +215 -0
- package/src/tools/edit.ts +97 -61
- package/src/tools/index.ts +8 -3
- package/src/tools/register.test.ts +265 -0
- package/src/tools/write.test.ts +161 -0
- package/src/tools/write.ts +99 -42
package/src/index.ts
CHANGED
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import type { SettingItem } from "@earendil-works/pi-tui";
|
|
8
|
+
import type { Component, SettingItem } from "@earendil-works/pi-tui";
|
|
9
9
|
|
|
10
10
|
import { setConfigGetter as setShikiConfig } from "./shiki.js";
|
|
11
11
|
import { setConfigGetter as setRenderConfig } from "./render/index.js";
|
|
12
12
|
import { DiffComponent } from "./diff-component.js";
|
|
13
13
|
import type { DiffBg, DiffColors } from "./ansi/index.js";
|
|
14
14
|
import { resetDiffColors } from "./ansi/index.js";
|
|
15
|
-
import {
|
|
15
|
+
import { installWriteTool, installEditTool } from "./tools/index.js";
|
|
16
|
+
import { diag } from "./log.js";
|
|
17
|
+
import { reportPiSupport } from "./pi-support.js";
|
|
16
18
|
|
|
17
19
|
// ---------------------------------------------------------------------------
|
|
18
20
|
// Re-exports
|
|
@@ -81,33 +83,58 @@ export function getDiffSettingsItems(overrideConfig?: DiffConfig): SettingItem[]
|
|
|
81
83
|
// Extension entry point
|
|
82
84
|
// ---------------------------------------------------------------------------
|
|
83
85
|
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
let _TextComponent:
|
|
86
|
+
// `Text` is the component the `write` row reuses across repaints. It is loaded
|
|
87
|
+
// at module evaluation time (top-level await) so it is ready before any
|
|
88
|
+
// `session_start`; a failure here means the rows are skipped rather than
|
|
89
|
+
// half-installed.
|
|
90
|
+
let _TextComponent:
|
|
91
|
+
| (new (text?: string, paddingX?: number, paddingY?: number) => Component)
|
|
92
|
+
| null = null;
|
|
89
93
|
|
|
90
94
|
try {
|
|
91
|
-
const
|
|
92
|
-
import("@earendil-works/pi-coding-agent"),
|
|
93
|
-
import("@earendil-works/pi-tui"),
|
|
94
|
-
]);
|
|
95
|
-
_sdk = {
|
|
96
|
-
createWriteTool: sdk.createWriteTool,
|
|
97
|
-
createEditTool: sdk.createEditTool,
|
|
98
|
-
};
|
|
95
|
+
const tui = await import("@earendil-works/pi-tui");
|
|
99
96
|
_TextComponent = tui.Text;
|
|
100
97
|
} catch (error) {
|
|
101
|
-
|
|
102
|
-
|
|
98
|
+
// A missing SDK means the diff tool is not registered at all, which is a
|
|
99
|
+
// developer-facing structural failure — the log file, never the console, and
|
|
100
|
+
// with the stack: this error is swallowed, so nothing else ever prints it.
|
|
101
|
+
// (Inlined rather than imported: `describeError` lives in core, which a
|
|
102
|
+
// standalone install does not have — see `pi-support.ts`.)
|
|
103
|
+
diag(
|
|
104
|
+
"diff",
|
|
105
|
+
`[diff] failed to load Pi SDK: ${error instanceof Error ? error.stack ?? error.message : String(error)}`,
|
|
103
106
|
);
|
|
104
107
|
}
|
|
105
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Install the diff-drawing `edit` and `write` rows.
|
|
111
|
+
*
|
|
112
|
+
* Idempotent per `pi` handle, and it has to be: pi keeps a LIST of resolvers and
|
|
113
|
+
* of event handlers per extension, so registering twice on the same extension
|
|
114
|
+
* stacks a duplicate. It is keyed to the handle rather than to this module
|
|
115
|
+
* because the two lifetimes differ — pi caches the compiled extension MODULE
|
|
116
|
+
* across sessions while every session replacement (`/new`, `/fork`, `/resume`)
|
|
117
|
+
* builds a fresh extension and hands registration a new handle. A module-wide
|
|
118
|
+
* flag would then install for the first session only, and every session after
|
|
119
|
+
* it would get pi's native `edit`/`write` rows and none of the result details
|
|
120
|
+
* the rows read. The theme and config readers are per-session, so those are
|
|
121
|
+
* refreshed on every call.
|
|
122
|
+
*/
|
|
123
|
+
const _installedExtensions = new WeakSet<object>();
|
|
124
|
+
|
|
106
125
|
export function registerDiffTools(
|
|
107
126
|
pi: ExtensionAPI,
|
|
108
127
|
_getTheme: () => Theme,
|
|
109
128
|
readConfig: () => DiffConfig,
|
|
110
129
|
): void {
|
|
130
|
+
// Floor check first, before anything can fail: pi 1.x APIs this package now
|
|
131
|
+
// relies on are not guaranteed below 1.2.0, and a peer range cannot warn
|
|
132
|
+
// anyone because pi never installs or solves peers (ADR 0026). Warn and
|
|
133
|
+
// carry on — a `diff` that declined to load would make /plugins report an
|
|
134
|
+
// enabled package that registered nothing. Idempotent per package, and this
|
|
135
|
+
// runs again on every session_start.
|
|
136
|
+
reportPiSupport("diff", "1.2.0");
|
|
137
|
+
|
|
111
138
|
_readConfig = readConfig;
|
|
112
139
|
|
|
113
140
|
// Reset theme-derived colors so they are re-derived from the current theme
|
|
@@ -117,19 +144,33 @@ export function registerDiffTools(
|
|
|
117
144
|
setShikiConfig(() => getConfig());
|
|
118
145
|
setRenderConfig(() => getConfig());
|
|
119
146
|
|
|
120
|
-
|
|
121
|
-
|
|
147
|
+
// Already installed on this extension: re-registering would stack a duplicate
|
|
148
|
+
// resolver and handler set. A different handle is a different extension, so
|
|
149
|
+
// it installs even when this module instance is a reused one.
|
|
150
|
+
if (_installedExtensions.has(pi)) return;
|
|
151
|
+
|
|
152
|
+
if (!_TextComponent) {
|
|
153
|
+
diag("diff", "[diff] SDK not available, skipping diff tool registration");
|
|
122
154
|
return;
|
|
123
155
|
}
|
|
124
156
|
|
|
125
157
|
const cwd = process.cwd();
|
|
126
158
|
const home = process.env.HOME ?? "";
|
|
127
159
|
|
|
128
|
-
//
|
|
129
|
-
|
|
160
|
+
// Capability check, not a version wish: without renderer resolution there is
|
|
161
|
+
// nothing to install, and pi's own rows stand in. The floor check above is
|
|
162
|
+
// what tells the user why.
|
|
163
|
+
if (typeof pi.registerToolRenderer !== "function" || typeof pi.on !== "function") {
|
|
164
|
+
diag("diff", "[diff] pi has no registerToolRenderer(), skipping diff tool installation");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
130
167
|
|
|
131
|
-
//
|
|
132
|
-
|
|
168
|
+
// The rows are chosen by pi's renderer resolution, and the result details
|
|
169
|
+
// each row reads arrive through `tool_result` (ADR 0029). Neither registers a
|
|
170
|
+
// tool, so pi's own `edit` and `write` stay in place.
|
|
171
|
+
installEditTool(pi, cwd, home);
|
|
172
|
+
installWriteTool(pi, cwd, home, _TextComponent);
|
|
173
|
+
_installedExtensions.add(pi);
|
|
133
174
|
}
|
|
134
175
|
|
|
135
176
|
// ── Default export (for standalone pi.extensions loading) ─────────────────
|
package/src/log.test.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
import { diag } from "./log.js";
|
|
8
|
+
|
|
9
|
+
// Same contract as `@pi-archimedes/core/log` — see `packages/core/src/log.test.ts`.
|
|
10
|
+
// Kept in sync deliberately: this package is standalone, so the sink is a
|
|
11
|
+
// sibling implementation, and a contract test is what keeps them siblings
|
|
12
|
+
// rather than drift.
|
|
13
|
+
const SANDBOX = mkdtempSync(join(tmpdir(), `archimedes-diff-log-${randomUUID()}-`));
|
|
14
|
+
const ENV = "PI_ARCHIMEDES_LOG";
|
|
15
|
+
const previous = process.env[ENV];
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
if (previous === undefined) delete process.env[ENV];
|
|
19
|
+
else process.env[ENV] = previous;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("diff diag — the diagnostics sink", () => {
|
|
23
|
+
it("writes nothing when the env var is unset", () => {
|
|
24
|
+
const target = join(SANDBOX, "unset.log");
|
|
25
|
+
process.env[ENV] = target;
|
|
26
|
+
diag("diff", "enabled");
|
|
27
|
+
|
|
28
|
+
delete process.env[ENV];
|
|
29
|
+
expect(() => diag("diff", "a message")).not.toThrow();
|
|
30
|
+
expect(readFileSync(target, "utf8").trimEnd().split("\n")).toHaveLength(1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("appends a timestamped, scoped line", () => {
|
|
34
|
+
const target = join(SANDBOX, "write.log");
|
|
35
|
+
process.env[ENV] = target;
|
|
36
|
+
|
|
37
|
+
diag("diff", "SDK not available");
|
|
38
|
+
|
|
39
|
+
const lines = readFileSync(target, "utf8").trimEnd().split("\n");
|
|
40
|
+
expect(lines).toHaveLength(1);
|
|
41
|
+
const match = /^\[(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\] \[(.+?)\] (.*)$/.exec(lines[0]!);
|
|
42
|
+
expect(match, `unexpected line shape: ${lines[0]}`).not.toBeNull();
|
|
43
|
+
expect(match![2]).toBe("diff");
|
|
44
|
+
expect(match![3]).toBe("SDK not available");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("appends across calls", () => {
|
|
48
|
+
const target = join(SANDBOX, "append.log");
|
|
49
|
+
process.env[ENV] = target;
|
|
50
|
+
|
|
51
|
+
diag("diff", "one");
|
|
52
|
+
diag("diff", "two");
|
|
53
|
+
|
|
54
|
+
expect(readFileSync(target, "utf8").trimEnd().split("\n")).toHaveLength(2);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("never throws on an unwritable target, and creates no directories", () => {
|
|
58
|
+
const missing = join(SANDBOX, "nope", "log.txt");
|
|
59
|
+
process.env[ENV] = missing;
|
|
60
|
+
expect(() => diag("diff", "ENOENT")).not.toThrow();
|
|
61
|
+
expect(existsSync(join(SANDBOX, "nope"))).toBe(false);
|
|
62
|
+
|
|
63
|
+
const dir = join(SANDBOX, "directory");
|
|
64
|
+
mkdirSync(dir, { recursive: true });
|
|
65
|
+
process.env[ENV] = dir;
|
|
66
|
+
expect(() => diag("diff", "EISDIR")).not.toThrow();
|
|
67
|
+
});
|
|
68
|
+
});
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
// ── Diagnostics sink (sibling of `@pi-archimedes/core/log`) ─────────────────
|
|
4
|
+
//
|
|
5
|
+
// Nothing in this package writes to the console: on fullscreen screen a stray
|
|
6
|
+
// write lands inside the differential frame and smears the viewport, and inside
|
|
7
|
+
// a subagent child it is discarded anyway (`subagent/src/stream.ts` drops every
|
|
8
|
+
// non-JSON stdout line). Diagnostics therefore go to `$PI_ARCHIMEDES_LOG`, and
|
|
9
|
+
// messages the user must see go to the notification surface.
|
|
10
|
+
//
|
|
11
|
+
// Why a copy rather than the import: `diff` is standalone, and `pi-support.ts`
|
|
12
|
+
// is the one place that states what that costs and why it is worth it. The
|
|
13
|
+
// canonical implementation is `packages/core/src/log.ts`; keep the two in step
|
|
14
|
+
// (pinned here by `log.test.ts`, which mirrors `core/src/log.test.ts`).
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Append one diagnostic line to `$PI_ARCHIMEDES_LOG`.
|
|
18
|
+
*
|
|
19
|
+
* No-op when the variable is unset or empty. Never throws: losing a log line is
|
|
20
|
+
* always cheaper than failing a render or a tool call, which is what these
|
|
21
|
+
* calls sit next to.
|
|
22
|
+
*
|
|
23
|
+
* @param scope the package the line came from (`"diff"`)
|
|
24
|
+
* @param message the diagnostic text
|
|
25
|
+
*/
|
|
26
|
+
export function diag(scope: string, message: string): void {
|
|
27
|
+
const file = process.env.PI_ARCHIMEDES_LOG;
|
|
28
|
+
if (!file) return;
|
|
29
|
+
try {
|
|
30
|
+
appendFileSync(file, `[${new Date().toISOString()}] [${scope}] ${message}\n`);
|
|
31
|
+
} catch {
|
|
32
|
+
// Logging must never be the reason something failed.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ── What this file pins ─────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// `diff` carries its own copy of the pi-version guard because it is a standalone
|
|
6
|
+
// package with no dependency on `@pi-archimedes/core` (see the header of
|
|
7
|
+
// `pi-support.ts`). A copy is only safe if it is pinned twice: once here, and
|
|
8
|
+
// once in `packages/core/src/pi-support.test.ts`. The two ends of the contract
|
|
9
|
+
// that matters — the `Symbol.for` key and the entry shape — are asserted in both
|
|
10
|
+
// files, so the copies cannot drift apart silently and leave `diff`'s warning
|
|
11
|
+
// out of the suite's aggregated notice.
|
|
12
|
+
|
|
13
|
+
const host = vi.hoisted(() => ({ version: "1.2.0" }));
|
|
14
|
+
|
|
15
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
16
|
+
get VERSION() {
|
|
17
|
+
return host.version;
|
|
18
|
+
},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
import { reportPiSupport, piSupportWarnings } from "./pi-support.js";
|
|
22
|
+
|
|
23
|
+
const WARNINGS_KEY = Symbol.for("archimedes:piSupportWarnings");
|
|
24
|
+
const globals = globalThis as Record<symbol, unknown>;
|
|
25
|
+
|
|
26
|
+
function wipeRegistry(): void {
|
|
27
|
+
delete globals[WARNINGS_KEY];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
wipeRegistry();
|
|
32
|
+
host.version = "1.2.0";
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
wipeRegistry();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("diff's pi-support guard", () => {
|
|
40
|
+
it("says nothing on a supported host, at the floor or above", () => {
|
|
41
|
+
host.version = "1.2.0";
|
|
42
|
+
reportPiSupport("diff", "1.2.0");
|
|
43
|
+
host.version = "1.10.0";
|
|
44
|
+
reportPiSupport("diff", "1.2.0");
|
|
45
|
+
expect(piSupportWarnings()).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("records the package, the floor and the running version below the floor", () => {
|
|
49
|
+
host.version = "0.99.2";
|
|
50
|
+
reportPiSupport("diff", "1.2.0");
|
|
51
|
+
expect(piSupportWarnings()).toEqual([{ pkg: "diff", min: "1.2.0", actual: "0.99.2" }]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("orders by number, not by string (1.10.0 is not older than 1.2.0)", () => {
|
|
55
|
+
host.version = "1.10.0";
|
|
56
|
+
reportPiSupport("diff", "1.2.0");
|
|
57
|
+
expect(piSupportWarnings()).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("is idempotent per package — session_start re-registers on /new and /reload", () => {
|
|
61
|
+
host.version = "0.99.2";
|
|
62
|
+
reportPiSupport("diff", "1.2.0");
|
|
63
|
+
reportPiSupport("diff", "1.2.0");
|
|
64
|
+
expect(piSupportWarnings()).toHaveLength(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("never throws, whatever the host reports as its version", () => {
|
|
68
|
+
// A guard that can throw can break registration, which is the one thing it
|
|
69
|
+
// must never do.
|
|
70
|
+
host.version = "nonsense";
|
|
71
|
+
expect(() => reportPiSupport("diff", "1.2.0")).not.toThrow();
|
|
72
|
+
delete (host as { version?: string }).version;
|
|
73
|
+
expect(() => reportPiSupport("diff", "1.2.0")).not.toThrow();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("writes nothing to the console, on either path", () => {
|
|
77
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
78
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
79
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
80
|
+
|
|
81
|
+
host.version = "1.2.0";
|
|
82
|
+
reportPiSupport("diff", "1.2.0");
|
|
83
|
+
host.version = "0.99.2";
|
|
84
|
+
reportPiSupport("diff", "1.2.0");
|
|
85
|
+
|
|
86
|
+
expect(log).not.toHaveBeenCalled();
|
|
87
|
+
expect(warn).not.toHaveBeenCalled();
|
|
88
|
+
expect(error).not.toHaveBeenCalled();
|
|
89
|
+
log.mockRestore();
|
|
90
|
+
warn.mockRestore();
|
|
91
|
+
error.mockRestore();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("the registry contract with @pi-archimedes/core", () => {
|
|
96
|
+
it("writes into the same Symbol.for key and entry shape core reads", () => {
|
|
97
|
+
host.version = "0.99.2";
|
|
98
|
+
reportPiSupport("diff", "1.2.0");
|
|
99
|
+
const registry = globals[WARNINGS_KEY] as Record<string, { pkg: string; min: string; actual: string }>;
|
|
100
|
+
expect(registry).toBeTypeOf("object");
|
|
101
|
+
// The exact shape `core/pi-support.ts` builds its aggregated notice from —
|
|
102
|
+
// if this changes, meta stops naming `diff` and no test would notice
|
|
103
|
+
// without this assertion.
|
|
104
|
+
expect(registry["diff"]).toEqual({ pkg: "diff", min: "1.2.0", actual: "0.99.2" });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("leaves entries written by another package alone (co-existence, not ownership)", () => {
|
|
108
|
+
globals[WARNINGS_KEY] = { ui: { pkg: "ui", min: "1.2.0", actual: "0.99.2" } };
|
|
109
|
+
host.version = "0.99.2";
|
|
110
|
+
reportPiSupport("diff", "1.2.0");
|
|
111
|
+
expect(piSupportWarnings().map((w) => w.pkg)).toEqual(["ui", "diff"]);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { VERSION } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { diag } from "./log.js";
|
|
3
|
+
|
|
4
|
+
// ── pi-version support guard (sibling of `@pi-archimedes/core/pi-support`) ───
|
|
5
|
+
//
|
|
6
|
+
// `diff` is a standalone, independently installable package: it declares no
|
|
7
|
+
// dependency on `@pi-archimedes/core` (AGENTS.md, README), so anything it needs
|
|
8
|
+
// from the suite it carries itself. That trade now has exactly three entries —
|
|
9
|
+
// the xterm colour table in `ansi/colors.ts`, the diagnostics sink in `log.ts`,
|
|
10
|
+
// and this version guard. This file is the one place that states the reason, so
|
|
11
|
+
// the other two point here rather than each repeating it; the reason is simply
|
|
12
|
+
// that `pi install @pi-archimedes/diff` must keep working with nothing else
|
|
13
|
+
// from the suite present.
|
|
14
|
+
//
|
|
15
|
+
// Why the check exists at all: a `peerDependencies` range reaches nobody. `pi
|
|
16
|
+
// install` omits peers, and the loader aliases host specifiers to pi's own
|
|
17
|
+
// copies, so the only thing that can tell a user their pi is older than this
|
|
18
|
+
// package was built for is a comparison against the `VERSION` the running host
|
|
19
|
+
// exports (ADR 0026). The answer is a warning, never a refusal — a `diff` that
|
|
20
|
+
// declined to load would make `/plugins` report an enabled package that
|
|
21
|
+
// registered nothing.
|
|
22
|
+
//
|
|
23
|
+
// How the warning is seen: this writes into the same `globalThis` registry core
|
|
24
|
+
// owns, under the same `Symbol.for` key and the same plain-object entry shape.
|
|
25
|
+
// A symbol-keyed global is the only shared memory a standalone package and the
|
|
26
|
+
// suite have, and it means an installed suite still aggregates `diff` into its
|
|
27
|
+
// one notice while a standalone `diff` leaves the entry for `piSupportWarnings()`
|
|
28
|
+
// and the log file. `pi-support.test.ts` here and
|
|
29
|
+
// `packages/core/src/pi-support.test.ts` pin both ends of that contract; keep
|
|
30
|
+
// the two copies in step.
|
|
31
|
+
|
|
32
|
+
/** Below-floor report. Shape shared with `core/pi-support.ts` — do not drift. */
|
|
33
|
+
export interface PiSupportWarning {
|
|
34
|
+
pkg: string;
|
|
35
|
+
min: string;
|
|
36
|
+
actual: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The key `@pi-archimedes/core/pi-support` reads to build its notice. */
|
|
40
|
+
const WARNINGS_KEY = Symbol.for("archimedes:piSupportWarnings");
|
|
41
|
+
|
|
42
|
+
type Registry = Record<string, PiSupportWarning>;
|
|
43
|
+
|
|
44
|
+
function registry(): Registry {
|
|
45
|
+
const existing = (globalThis as Record<symbol, unknown>)[WARNINGS_KEY];
|
|
46
|
+
return (existing as Registry | undefined) ?? {};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Numeric three-component comparison, copied from
|
|
51
|
+
* `packages/core/src/pi-support.ts`: -1 / 0 / 1, leading `v` and missing
|
|
52
|
+
* components tolerated, a non-numeric component counting as 0 rather than NaN.
|
|
53
|
+
* Numeric, not lexical, because `1.10.0` must outrank `1.2.0`.
|
|
54
|
+
*/
|
|
55
|
+
function compareVersions(a: string, b: string): number {
|
|
56
|
+
const part = (v: string, i: number): number => {
|
|
57
|
+
const n = Number(v.replace(/^v/, "").split(".")[i] ?? "0");
|
|
58
|
+
return Number.isFinite(n) ? n : 0;
|
|
59
|
+
};
|
|
60
|
+
for (let i = 0; i < 3; i++) {
|
|
61
|
+
const left = part(a, i);
|
|
62
|
+
const right = part(b, i);
|
|
63
|
+
if (left > right) return 1;
|
|
64
|
+
if (left < right) return -1;
|
|
65
|
+
}
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Record a below-floor warning. Idempotent per package, silent by construction
|
|
71
|
+
* (no console write on either path), and never throws — this runs at the top of
|
|
72
|
+
* registration, where the only acceptable failure mode is "nothing happened,
|
|
73
|
+
* carry on registering".
|
|
74
|
+
*/
|
|
75
|
+
export function reportPiSupport(pkg: string, min: string): void {
|
|
76
|
+
try {
|
|
77
|
+
const actual = typeof VERSION === "string" ? VERSION : "";
|
|
78
|
+
if (compareVersions(actual, min) >= 0) return;
|
|
79
|
+
const current = registry();
|
|
80
|
+
if (pkg in current) return;
|
|
81
|
+
current[pkg] = { pkg, min, actual };
|
|
82
|
+
(globalThis as Record<symbol, unknown>)[WARNINGS_KEY] = current;
|
|
83
|
+
// A standalone install has no aggregator, so the log file is the only other
|
|
84
|
+
// place this can go.
|
|
85
|
+
diag(pkg, `pi ${actual} is below this package's floor (>=${min}); loaded anyway`);
|
|
86
|
+
} catch {
|
|
87
|
+
// A guard that can throw is a guard that can break registration.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Recorded warnings, in report order — the standalone view of the registry. */
|
|
92
|
+
export function piSupportWarnings(): PiSupportWarning[] {
|
|
93
|
+
try {
|
|
94
|
+
return Object.values(registry());
|
|
95
|
+
} catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `registerDiffTools` install guard (ADR 0029).
|
|
3
|
+
*
|
|
4
|
+
* pi keeps a LIST of resolvers and of event handlers per extension, so a second
|
|
5
|
+
* registration on the SAME extension would stack a duplicate. But a session
|
|
6
|
+
* replacement (`/new`, `/fork`, `/resume`) reuses pi's cached extension MODULE
|
|
7
|
+
* while creating a FRESH extension — meta's own comment records exactly that —
|
|
8
|
+
* so installation has to be keyed to the `pi` handle it is registering on, not
|
|
9
|
+
* to the module. A module-wide flag hands every session after the first pi's
|
|
10
|
+
* native `edit`/`write` rows and no result details at all.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it, vi } from "vitest";
|
|
14
|
+
import { registerDiffTools } from "./index.js";
|
|
15
|
+
|
|
16
|
+
/** A `pi` handle that records what this registration asked for. */
|
|
17
|
+
function fakePi() {
|
|
18
|
+
const renderers: ((name: string, next: () => unknown) => unknown)[] = [];
|
|
19
|
+
const events: string[] = [];
|
|
20
|
+
return {
|
|
21
|
+
pi: {
|
|
22
|
+
registerTool: vi.fn(),
|
|
23
|
+
registerToolRenderer: (r: (name: string, next: () => unknown) => unknown) => {
|
|
24
|
+
renderers.push(r);
|
|
25
|
+
},
|
|
26
|
+
on: (event: string) => {
|
|
27
|
+
events.push(event);
|
|
28
|
+
return () => {};
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
renderers,
|
|
32
|
+
events,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const themeGetter = () => ({}) as never;
|
|
37
|
+
const configGetter = () => ({
|
|
38
|
+
diffTheme: "github-dark",
|
|
39
|
+
diffSplitMinWidth: 150,
|
|
40
|
+
diffSplitMinCodeWidth: 60,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("registerDiffTools install scope", () => {
|
|
44
|
+
it("does not stack a second set of renderers on the same pi handle", () => {
|
|
45
|
+
const a = fakePi();
|
|
46
|
+
// Two `session_start`s on one extension (the case the guard exists for).
|
|
47
|
+
registerDiffTools(a.pi as never, themeGetter, configGetter);
|
|
48
|
+
registerDiffTools(a.pi as never, themeGetter, configGetter);
|
|
49
|
+
|
|
50
|
+
expect(a.renderers).toHaveLength(2); // edit + write, once each
|
|
51
|
+
expect(a.events.filter((e) => e === "tool_result")).toHaveLength(2);
|
|
52
|
+
expect(a.events.filter((e) => e === "tool_call")).toHaveLength(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("installs again for a fresh pi handle sharing the cached module", () => {
|
|
56
|
+
const first = fakePi();
|
|
57
|
+
registerDiffTools(first.pi as never, themeGetter, configGetter);
|
|
58
|
+
|
|
59
|
+
// A session replacement: same imported module, brand new extension.
|
|
60
|
+
const second = fakePi();
|
|
61
|
+
registerDiffTools(second.pi as never, themeGetter, configGetter);
|
|
62
|
+
|
|
63
|
+
expect(second.renderers).toHaveLength(2);
|
|
64
|
+
expect(second.events.filter((e) => e === "tool_result")).toHaveLength(2);
|
|
65
|
+
expect(second.events.filter((e) => e === "tool_call")).toHaveLength(1);
|
|
66
|
+
|
|
67
|
+
// The renderers the new extension was handed actually resolve, so the rows
|
|
68
|
+
// are the Archimedes ones rather than pi's natives.
|
|
69
|
+
const native = Object.freeze({ native: true });
|
|
70
|
+
for (const name of ["edit", "write"]) {
|
|
71
|
+
const resolved = second.renderers.map((r) => r(name, () => native));
|
|
72
|
+
const mine = resolved.filter((x) => x !== native);
|
|
73
|
+
expect(mine).toHaveLength(1);
|
|
74
|
+
expect(typeof (mine[0] as { renderCall?: unknown }).renderCall).toBe("function");
|
|
75
|
+
expect(typeof (mine[0] as { renderResult?: unknown }).renderResult).toBe("function");
|
|
76
|
+
// Tools this extension does not own fall through to the next resolver.
|
|
77
|
+
const other = second.renderers.map((r) => r("read", () => native));
|
|
78
|
+
expect(other.every((x) => x === native)).toBe(true);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|