@decohere/devkit 0.0.1-dev.125 → 0.0.1-dev.5

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.
Files changed (121) hide show
  1. package/package.json +3 -12
  2. package/src/cli/action.ts +77 -0
  3. package/src/cli/command.ts +173 -0
  4. package/src/cli/index.ts +30 -0
  5. package/src/cli/module.type.ts +48 -0
  6. package/src/cli/options.ts +81 -0
  7. package/src/diagnostics/trace.ts +92 -0
  8. package/src/interop/docker/docker.ts +57 -0
  9. package/src/interop/errors/interop.error.ts +1 -0
  10. package/src/interop/git/git.ts +98 -0
  11. package/{types/src/interop/git/git.types.d.ts → src/interop/git/git.types.ts} +9 -6
  12. package/{es6/src/interop/index.js → src/interop/index.ts} +1 -0
  13. package/src/interop/node/node.ts +38 -0
  14. package/src/interop/npm/npm.ts +168 -0
  15. package/src/interop/npm/npm.types.ts +28 -0
  16. package/src/interop/pnpm/pnpm.ts +22 -0
  17. package/{types/src/interop/pnpm/pnpm.types.d.ts → src/interop/pnpm/pnpm.types.ts} +1 -1
  18. package/src/interop/shell/shell.argv.ts +31 -0
  19. package/src/interop/shell/shell.ts +100 -0
  20. package/src/interop/shell/shell.types.ts +9 -0
  21. package/src/interop/types/detect-package-manager.ts +45 -0
  22. package/src/interop/types/package-manager.ts +7 -0
  23. package/src/interop/yarn/yarn.ts +28 -0
  24. package/{types/src/interop/yarn/yarn.types.d.ts → src/interop/yarn/yarn.types.ts} +1 -1
  25. package/src/io/find-directories.ts +13 -0
  26. package/src/io/find-file.ts +37 -0
  27. package/src/io/watch.ts +290 -0
  28. package/tests/cli/command.model.spec.ts +91 -0
  29. package/tests/diagnostics/trace.spec.ts +319 -0
  30. package/tests/interop/detect-package-manager.spec.ts +70 -0
  31. package/tests/interop/npm.spec.ts +129 -0
  32. package/tests/interop/pnpm.spec.ts +30 -0
  33. package/tests/interop/shell.spec.ts +47 -0
  34. package/tests/interop/yarn.spec.ts +18 -0
  35. package/tests/io/find-file.spec.ts +188 -0
  36. package/tests/io/watch.real-fs.spec.ts +76 -0
  37. package/tests/io/watch.spec.ts +291 -0
  38. package/LICENSE +0 -15
  39. package/commonjs/src/cli/action.js +0 -65
  40. package/commonjs/src/cli/command.js +0 -127
  41. package/commonjs/src/cli/index.js +0 -61
  42. package/commonjs/src/cli/module.type.js +0 -18
  43. package/commonjs/src/cli/options.js +0 -115
  44. package/commonjs/src/diagnostics/index.js +0 -22
  45. package/commonjs/src/diagnostics/trace.js +0 -79
  46. package/commonjs/src/interop/docker/docker.js +0 -54
  47. package/commonjs/src/interop/errors/interop.error.js +0 -12
  48. package/commonjs/src/interop/git/git.js +0 -134
  49. package/commonjs/src/interop/git/git.types.js +0 -4
  50. package/commonjs/src/interop/index.js +0 -97
  51. package/commonjs/src/interop/node/index.js +0 -18
  52. package/commonjs/src/interop/node/node.js +0 -45
  53. package/commonjs/src/interop/npm/npm.js +0 -231
  54. package/commonjs/src/interop/npm/npm.types.js +0 -16
  55. package/commonjs/src/interop/pnpm/pnpm.js +0 -34
  56. package/commonjs/src/interop/pnpm/pnpm.types.js +0 -4
  57. package/commonjs/src/interop/shell/shell.argv.js +0 -45
  58. package/commonjs/src/interop/shell/shell.js +0 -105
  59. package/commonjs/src/interop/shell/shell.types.js +0 -4
  60. package/commonjs/src/interop/types/detect-package-manager.js +0 -71
  61. package/commonjs/src/interop/types/package-manager.js +0 -4
  62. package/commonjs/src/interop/yarn/yarn.js +0 -34
  63. package/commonjs/src/interop/yarn/yarn.types.js +0 -4
  64. package/commonjs/src/io/find-directories.js +0 -21
  65. package/commonjs/src/io/find-file.js +0 -45
  66. package/commonjs/src/io/index.js +0 -20
  67. package/commonjs/src/io/watch.js +0 -261
  68. package/es6/src/cli/action.js +0 -47
  69. package/es6/src/cli/command.js +0 -106
  70. package/es6/src/cli/index.js +0 -4
  71. package/es6/src/cli/module.type.js +0 -11
  72. package/es6/src/cli/options.js +0 -76
  73. package/es6/src/diagnostics/trace.js +0 -61
  74. package/es6/src/interop/docker/docker.js +0 -44
  75. package/es6/src/interop/errors/interop.error.js +0 -2
  76. package/es6/src/interop/git/git.js +0 -124
  77. package/es6/src/interop/git/git.types.js +0 -1
  78. package/es6/src/interop/node/node.js +0 -35
  79. package/es6/src/interop/npm/npm.js +0 -180
  80. package/es6/src/interop/npm/npm.types.js +0 -6
  81. package/es6/src/interop/pnpm/pnpm.js +0 -24
  82. package/es6/src/interop/pnpm/pnpm.types.js +0 -1
  83. package/es6/src/interop/shell/shell.argv.js +0 -35
  84. package/es6/src/interop/shell/shell.js +0 -95
  85. package/es6/src/interop/shell/shell.types.js +0 -1
  86. package/es6/src/interop/types/detect-package-manager.js +0 -50
  87. package/es6/src/interop/types/package-manager.js +0 -1
  88. package/es6/src/interop/yarn/yarn.js +0 -24
  89. package/es6/src/interop/yarn/yarn.types.js +0 -1
  90. package/es6/src/io/find-directories.js +0 -11
  91. package/es6/src/io/find-file.js +0 -27
  92. package/es6/src/io/watch.js +0 -238
  93. package/types/src/cli/action.d.ts +0 -36
  94. package/types/src/cli/command.d.ts +0 -34
  95. package/types/src/cli/index.d.ts +0 -7
  96. package/types/src/cli/module.type.d.ts +0 -36
  97. package/types/src/cli/options.d.ts +0 -28
  98. package/types/src/diagnostics/index.d.ts +0 -1
  99. package/types/src/diagnostics/trace.d.ts +0 -10
  100. package/types/src/interop/docker/docker.d.ts +0 -19
  101. package/types/src/interop/errors/interop.error.d.ts +0 -2
  102. package/types/src/interop/git/git.d.ts +0 -16
  103. package/types/src/interop/index.d.ts +0 -14
  104. package/types/src/interop/node/index.d.ts +0 -1
  105. package/types/src/interop/node/node.d.ts +0 -5
  106. package/types/src/interop/npm/npm.d.ts +0 -29
  107. package/types/src/interop/npm/npm.types.d.ts +0 -24
  108. package/types/src/interop/pnpm/pnpm.d.ts +0 -9
  109. package/types/src/interop/shell/shell.argv.d.ts +0 -5
  110. package/types/src/interop/shell/shell.d.ts +0 -6
  111. package/types/src/interop/shell/shell.types.d.ts +0 -9
  112. package/types/src/interop/types/detect-package-manager.d.ts +0 -4
  113. package/types/src/interop/types/package-manager.d.ts +0 -10
  114. package/types/src/interop/yarn/yarn.d.ts +0 -9
  115. package/types/src/io/find-directories.d.ts +0 -2
  116. package/types/src/io/find-file.d.ts +0 -9
  117. package/types/src/io/index.d.ts +0 -3
  118. package/types/src/io/watch.d.ts +0 -16
  119. /package/{es6/src/diagnostics/index.js → src/diagnostics/index.ts} +0 -0
  120. /package/{es6/src/interop/node/index.js → src/interop/node/index.ts} +0 -0
  121. /package/{es6/src/io/index.js → src/io/index.ts} +0 -0
@@ -0,0 +1,290 @@
1
+ import { type FSWatcher, watch as nativeWatch, readdirSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import picomatch from "picomatch";
4
+ import { type File, FileEx, type Watcher } from "@decohere/core/io";
5
+
6
+ export type WatchOptions = {
7
+ include?: string[];
8
+ ignore?: string[];
9
+ cwd?: string;
10
+ ignoreInitial?: boolean;
11
+ };
12
+
13
+ export type WatchEventName = "add" | "change" | "addDir" | "unlink" | "unlinkDir";
14
+ export const WatchEvents = {
15
+ add: "add",
16
+ change: "change",
17
+ addDir: "addDir",
18
+ unlink: "unlink",
19
+ unlinkDir: "unlinkDir",
20
+ } satisfies Record<string, WatchEventName>;
21
+ export function watch(directory: string, options: WatchOptions = { cwd: "." }) {
22
+ const { include, ignore, ignoreInitial = false } = options;
23
+
24
+ const changeCoalesceMs = 25;
25
+
26
+ const handlers = {
27
+ ready: new Set<() => unknown>(),
28
+ add: new Set<(file: File) => unknown>(),
29
+ change: new Set<(file: File) => unknown>(),
30
+ addDir: new Set<(file: File) => unknown>(),
31
+ unlink: new Set<(file: File) => unknown>(),
32
+ unlinkDir: new Set<(file: File) => unknown>(),
33
+ };
34
+
35
+ let resolveReady: (() => void) | undefined;
36
+ const ready = new Promise<void>((resolve) => {
37
+ resolveReady = resolve;
38
+ });
39
+
40
+ const snapshot = buildSnapshot(directory);
41
+
42
+ const isFiltered = (relativePath: string) => {
43
+ const posixRelativePath = toPosixPath(relativePath);
44
+ const posixFullPath = toPosixPath(path.resolve(directory, relativePath));
45
+ const matchesEitherPath = (patterns: string[]) =>
46
+ picomatch.isMatch(posixRelativePath, patterns) ||
47
+ picomatch.isMatch(posixFullPath, patterns);
48
+
49
+ if (include && include.length > 0) {
50
+ return !matchesEitherPath(include);
51
+ }
52
+
53
+ if (ignore && ignore.length > 0) {
54
+ return matchesEitherPath(ignore);
55
+ }
56
+
57
+ return false;
58
+ };
59
+
60
+ const emit = (event: WatchEventName, relativePath: string) => {
61
+ if (isFiltered(relativePath)) {
62
+ return;
63
+ }
64
+
65
+ const fullPath = path.resolve(directory, relativePath);
66
+ for (const handler of handlers[event]) {
67
+ handler(new FileEx({ fullPath, relativePath }));
68
+ }
69
+ };
70
+
71
+ const pendingChangeTimers = new Map<string, ReturnType<typeof setTimeout>>();
72
+
73
+ const emitChange = (relativePath: string) => {
74
+ const existing = pendingChangeTimers.get(relativePath);
75
+ if (existing != null) {
76
+ clearTimeout(existing);
77
+ }
78
+
79
+ const timer = setTimeout(() => {
80
+ pendingChangeTimers.delete(relativePath);
81
+ emit("change", relativePath);
82
+ }, changeCoalesceMs);
83
+
84
+ pendingChangeTimers.set(relativePath, timer);
85
+ };
86
+
87
+ const emitReady = () => {
88
+ for (const handler of handlers.ready) {
89
+ handler();
90
+ }
91
+ };
92
+
93
+ queueMicrotask(() => {
94
+ if (!ignoreInitial) {
95
+ for (const [relativePath, entry] of snapshot) {
96
+ emit(entry.isDirectory ? "addDir" : "add", relativePath);
97
+ }
98
+ }
99
+
100
+ emitReady();
101
+ resolveReady?.();
102
+ resolveReady = undefined;
103
+ });
104
+
105
+ // One non-recursive watcher per directory. Node's recursive mode on Linux tracks files
106
+ // individually and goes silent for a file once an editor replaces it with an atomic save
107
+ // (write temp file, rename over the original); directory watchers keep seeing it (#505).
108
+ const directoryWatchers = new Map<string, FSWatcher>();
109
+
110
+ const watchDirectory = (relativeDir: string) => {
111
+ if (directoryWatchers.has(relativeDir)) {
112
+ return;
113
+ }
114
+ try {
115
+ const watcher = nativeWatch(
116
+ path.resolve(directory, relativeDir),
117
+ (_eventType, fileName) => {
118
+ if (fileName != null) {
119
+ onFsEvent(path.posix.join(relativeDir, toPosixPath(fileName.toString())));
120
+ }
121
+ },
122
+ );
123
+ watcher.on("error", () => unwatchDirectory(relativeDir));
124
+ directoryWatchers.set(relativeDir, watcher);
125
+ } catch {
126
+ // The directory vanished before we could watch it; its unlink event covers it.
127
+ }
128
+ };
129
+
130
+ const unwatchDirectory = (relativeDir: string) => {
131
+ for (const [watchedDir, watcher] of directoryWatchers) {
132
+ if (watchedDir === relativeDir || watchedDir.startsWith(`${relativeDir}/`)) {
133
+ watcher.close();
134
+ directoryWatchers.delete(watchedDir);
135
+ }
136
+ }
137
+ };
138
+
139
+ /** A directory appeared: watch it, and report what was created in it before we were watching. */
140
+ const addDirectory = (relativeDir: string) => {
141
+ watchDirectory(relativeDir);
142
+ const found = new Map<string, { isDirectory: boolean; fingerprint: string }>();
143
+ try {
144
+ collectSnapshotEntries(directory, path.resolve(directory, relativeDir), found);
145
+ } catch {
146
+ return;
147
+ }
148
+ for (const [relativePath, entry] of found) {
149
+ if (snapshot.has(relativePath)) continue;
150
+ snapshot.set(relativePath, entry);
151
+ if (entry.isDirectory) watchDirectory(relativePath);
152
+ emit(entry.isDirectory ? "addDir" : "add", relativePath);
153
+ }
154
+ };
155
+
156
+ /** A directory went away: stop watching it and forget everything that was under it. */
157
+ const removeDirectoryContents = (relativeDir: string) => {
158
+ unwatchDirectory(relativeDir);
159
+ // oxlint-disable-next-line unicorn/no-useless-spread -- iterate a copy: the loop deletes from the snapshot
160
+ for (const [relativePath, entry] of [...snapshot]) {
161
+ if (relativePath.startsWith(`${relativeDir}/`)) {
162
+ snapshot.delete(relativePath);
163
+ emit(entry.isDirectory ? "unlinkDir" : "unlink", relativePath);
164
+ }
165
+ }
166
+ };
167
+
168
+ const onFsEvent = (eventPath: string) => {
169
+ const relativePath = eventPath === "." ? "" : eventPath;
170
+ if (relativePath.length === 0) {
171
+ return;
172
+ }
173
+
174
+ const fullPath = path.resolve(directory, relativePath);
175
+ const previous = snapshot.get(relativePath);
176
+
177
+ try {
178
+ const stat = statSync(fullPath);
179
+ const current = toSnapshotEntry(stat);
180
+ snapshot.set(relativePath, current);
181
+
182
+ if (previous == null) {
183
+ emit(current.isDirectory ? "addDir" : "add", relativePath);
184
+ if (current.isDirectory) addDirectory(relativePath);
185
+ return;
186
+ }
187
+
188
+ if (previous.isDirectory !== current.isDirectory) {
189
+ if (previous.isDirectory) removeDirectoryContents(relativePath);
190
+ emit(previous.isDirectory ? "unlinkDir" : "unlink", relativePath);
191
+ emit(current.isDirectory ? "addDir" : "add", relativePath);
192
+ return;
193
+ }
194
+
195
+ if (!current.isDirectory && previous.fingerprint !== current.fingerprint) {
196
+ emitChange(relativePath);
197
+ }
198
+ } catch {
199
+ if (previous == null) {
200
+ return;
201
+ }
202
+
203
+ snapshot.delete(relativePath);
204
+ const pending = pendingChangeTimers.get(relativePath);
205
+ if (pending != null) {
206
+ clearTimeout(pending);
207
+ pendingChangeTimers.delete(relativePath);
208
+ }
209
+ emit(previous.isDirectory ? "unlinkDir" : "unlink", relativePath);
210
+ }
211
+ };
212
+
213
+ watchDirectory("");
214
+ for (const [relativePath, entry] of snapshot) {
215
+ if (entry.isDirectory) watchDirectory(relativePath);
216
+ }
217
+
218
+ const watchApi: Watcher = {
219
+ ready,
220
+ on(event: "ready" | WatchEventName, handler: ((file: File) => unknown) | (() => unknown)) {
221
+ if (event === "ready") {
222
+ handlers.ready.add(handler as () => unknown);
223
+ return watchApi;
224
+ }
225
+
226
+ handlers[event].add(handler as (file: File) => unknown);
227
+ return watchApi;
228
+ },
229
+ close: () => {
230
+ for (const pending of pendingChangeTimers.values()) {
231
+ clearTimeout(pending);
232
+ }
233
+ pendingChangeTimers.clear();
234
+ for (const watcher of directoryWatchers.values()) {
235
+ watcher.close();
236
+ }
237
+ directoryWatchers.clear();
238
+ },
239
+ };
240
+
241
+ return watchApi;
242
+ }
243
+
244
+ function buildSnapshot(
245
+ directoryPath: string,
246
+ ): Map<string, { isDirectory: boolean; fingerprint: string }> {
247
+ const snapshot = new Map<string, { isDirectory: boolean; fingerprint: string }>();
248
+ collectSnapshotEntries(directoryPath, directoryPath, snapshot);
249
+ return snapshot;
250
+ }
251
+
252
+ /** Never source, and huge: watching every folder inside them could exhaust OS watch limits. */
253
+ const UNWATCHED_DIRECTORIES = new Set(["node_modules", ".git"]);
254
+
255
+ function collectSnapshotEntries(
256
+ rootDirectoryPath: string,
257
+ currentDirectoryPath: string,
258
+ snapshot: Map<string, { isDirectory: boolean; fingerprint: string }>,
259
+ ) {
260
+ for (const entry of readdirSync(currentDirectoryPath, { encoding: "utf8" })) {
261
+ const name = typeof entry === "string" ? entry : String(entry);
262
+ const fullPath = path.join(currentDirectoryPath, name);
263
+ if (UNWATCHED_DIRECTORIES.has(name)) {
264
+ continue;
265
+ }
266
+ const relativePath = toPosixPath(path.relative(rootDirectoryPath, fullPath));
267
+ const stat = statSync(fullPath);
268
+ const snapshotEntry = toSnapshotEntry(stat);
269
+
270
+ snapshot.set(relativePath, snapshotEntry);
271
+
272
+ if (!snapshotEntry.isDirectory) {
273
+ continue;
274
+ }
275
+
276
+ collectSnapshotEntries(rootDirectoryPath, fullPath, snapshot);
277
+ }
278
+ }
279
+
280
+ function toSnapshotEntry(stat: { isDirectory(): boolean; mtimeMs: number; size: number }) {
281
+ const isDirectory = stat.isDirectory();
282
+ return {
283
+ isDirectory,
284
+ fingerprint: isDirectory ? `dir:${stat.mtimeMs}` : `file:${stat.mtimeMs}:${stat.size}`,
285
+ };
286
+ }
287
+
288
+ function toPosixPath(filePath: string) {
289
+ return filePath.split(path.sep).join(path.posix.sep);
290
+ }
@@ -0,0 +1,91 @@
1
+ import type { OptionValues } from "commander";
2
+ import { expect, it, vi } from "vitest";
3
+ import type { Action } from "../../src/cli/index.js";
4
+ import { ActionResponse, DecohereCommand } from "../../src/cli/index.js";
5
+
6
+ type TestActions = {
7
+ a: Action;
8
+ b: Action;
9
+ };
10
+
11
+ class AAction implements Action {
12
+ public constructor(
13
+ private readonly response: ActionResponse = ActionResponse.Success("a"),
14
+ ) {}
15
+
16
+ public async run(): Promise<ActionResponse> {
17
+ return this.response;
18
+ }
19
+ }
20
+
21
+ class BAction implements Action {
22
+ public constructor(
23
+ private readonly response: ActionResponse = ActionResponse.Success("b"),
24
+ ) {}
25
+
26
+ public async run(): Promise<ActionResponse> {
27
+ return this.response;
28
+ }
29
+ }
30
+
31
+ class TestCommand extends DecohereCommand<TestActions> {
32
+ public override get actions() {
33
+ return [AAction, BAction] as const;
34
+ }
35
+
36
+ public constructor() {
37
+ super("test");
38
+ }
39
+ }
40
+
41
+ it("does not throw when an action is skipped", async () => {
42
+ const command = new TestCommand();
43
+ const actions: TestActions = {
44
+ a: new AAction(ActionResponse.Skipped("a")),
45
+ b: new BAction(ActionResponse.Success("b")),
46
+ };
47
+ const runA = vi.spyOn(actions.a, "run");
48
+ const runB = vi.spyOn(actions.b, "run");
49
+
50
+ const response = await command.run(actions, {} as OptionValues);
51
+ expect(runA).toHaveBeenCalledTimes(1);
52
+ expect(runB).toHaveBeenCalledTimes(1);
53
+ expect(response.a?.skipped).toBe(true);
54
+ expect(response.b?.succeeded).toBe(true);
55
+ });
56
+
57
+ it("throws when an action returns a non-skipped error", async () => {
58
+ const command = new TestCommand();
59
+ const actions: TestActions = {
60
+ a: new AAction(ActionResponse.Error("a", "a failed")),
61
+ b: new BAction(ActionResponse.Skipped("b")),
62
+ };
63
+
64
+ await expect(command.run(actions, {} as OptionValues)).rejects.toThrow(
65
+ "test command failed",
66
+ );
67
+ });
68
+
69
+ it("throws when mapped action constructor is missing", async () => {
70
+ class MissingBCommand extends DecohereCommand<TestActions> {
71
+ public override get actions() {
72
+ return [AAction, BAction] as const;
73
+ }
74
+
75
+ public constructor() {
76
+ super("test");
77
+ }
78
+ }
79
+
80
+ const command = new MissingBCommand();
81
+ const actions: TestActions = {
82
+ a: new AAction(),
83
+ b: {
84
+ run: async () => ActionResponse.Success("b"),
85
+ },
86
+ };
87
+
88
+ await expect(command.run(actions, {} as OptionValues)).rejects.toThrow(
89
+ "missing action instance for BAction",
90
+ );
91
+ });
@@ -0,0 +1,319 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // vi.resetModules() clears Vitest's module registry so that the next dynamic
4
+ // import() re-evaluates trace.ts, picking up process.env.FORGE_TRACE into the
5
+ // module-level `const isTraceEnabled` at load time.
6
+ // vi.spyOn is set up after import — trace.ts calls scribe.logger() only when
7
+ // the decorator is applied (inside the test body), not at module evaluation time.
8
+ const importTrace = async () => {
9
+ const [decorators, { scribe }] = await Promise.all([
10
+ import("@decohere/devkit/diagnostics") as Promise<
11
+ typeof import("@decohere/devkit/diagnostics")
12
+ >,
13
+ import("@decohere/scribe"),
14
+ ]);
15
+ // Spy on the shared scribe object — same reference trace.ts holds.
16
+ const loggerSpy = vi.spyOn(scribe, "logger").mockReturnValue({
17
+ trace: vi.fn(),
18
+ info: vi.fn(),
19
+ } as never);
20
+ return { ...decorators, loggerSpy };
21
+ };
22
+
23
+ describe("@timed decorator", () => {
24
+ beforeEach(() => {
25
+ vi.resetModules();
26
+ });
27
+
28
+ afterEach(() => {
29
+ vi.unstubAllEnvs();
30
+ vi.restoreAllMocks();
31
+ });
32
+
33
+ it("wraps the method when FORGE_TRACE is not set", async () => {
34
+ const { timed } = await importTrace();
35
+ class Subject {
36
+ public myMethod() {
37
+ return "result";
38
+ }
39
+ }
40
+ const descriptor = Object.getOwnPropertyDescriptor(Subject.prototype, "myMethod")!;
41
+ const original = descriptor.value;
42
+ timed(Subject.prototype, "myMethod", descriptor);
43
+ expect(descriptor.value).not.toBe(original);
44
+ expect(descriptor.value.call(new Subject())).toBe("result");
45
+ });
46
+
47
+ it("logs timing at info for sync methods", async () => {
48
+ const { timed, loggerSpy } = await importTrace();
49
+ const infoFn = vi.fn();
50
+ const traceFn = vi.fn();
51
+ loggerSpy.mockReturnValue({ info: infoFn, trace: traceFn } as never);
52
+ class Subject {
53
+ public myMethod() {
54
+ return "result";
55
+ }
56
+ }
57
+ const descriptor = Object.getOwnPropertyDescriptor(Subject.prototype, "myMethod")!;
58
+ timed(Subject.prototype, "myMethod", descriptor);
59
+ descriptor.value.call(new Subject());
60
+ expect(infoFn).toHaveBeenCalledWith(expect.stringMatching(/^myMethod\(.*\): [\d.]+ms$/));
61
+ expect(traceFn).not.toHaveBeenCalled();
62
+ });
63
+
64
+ it("logs timing at info after async method resolves", async () => {
65
+ const { timed, loggerSpy } = await importTrace();
66
+ const infoFn = vi.fn();
67
+ loggerSpy.mockReturnValue({ info: infoFn, trace: vi.fn() } as never);
68
+ class Subject {
69
+ public async myMethod() {
70
+ return "result";
71
+ }
72
+ }
73
+ const descriptor = Object.getOwnPropertyDescriptor(Subject.prototype, "myMethod")!;
74
+ timed(Subject.prototype, "myMethod", descriptor);
75
+ expect(await descriptor.value.call(new Subject())).toBe("result");
76
+ expect(infoFn).toHaveBeenCalledWith(expect.stringMatching(/^myMethod\(.*\): [\d.]+ms$/));
77
+ });
78
+
79
+ it("logs timing at info even when async method rejects", async () => {
80
+ const { timed, loggerSpy } = await importTrace();
81
+ const infoFn = vi.fn();
82
+ loggerSpy.mockReturnValue({ info: infoFn, trace: vi.fn() } as never);
83
+ class Subject {
84
+ public async myMethod() {
85
+ throw new Error("boom");
86
+ }
87
+ }
88
+ const descriptor = Object.getOwnPropertyDescriptor(Subject.prototype, "myMethod")!;
89
+ timed(Subject.prototype, "myMethod", descriptor);
90
+ await expect(descriptor.value.call(new Subject())).rejects.toThrow("boom");
91
+ expect(infoFn).toHaveBeenCalledWith(expect.stringMatching(/^myMethod\(.*\): [\d.]+ms$/));
92
+ });
93
+ });
94
+
95
+ describe("@trace / @Trace decorators", () => {
96
+ beforeEach(() => {
97
+ vi.resetModules();
98
+ });
99
+
100
+ afterEach(() => {
101
+ vi.unstubAllEnvs();
102
+ vi.restoreAllMocks();
103
+ });
104
+
105
+ describe("when FORGE_TRACE is not set", () => {
106
+ it("trace: returns descriptor unchanged (no wrapping)", async () => {
107
+ const { trace } = await importTrace();
108
+ const fn = vi.fn().mockReturnValue("result");
109
+ const descriptor = { value: fn };
110
+ trace({} as object, "myMethod", descriptor as PropertyDescriptor);
111
+ expect(descriptor.value).toBe(fn);
112
+ });
113
+
114
+ it("Trace: does not wrap class methods", async () => {
115
+ const { Trace } = await importTrace();
116
+ class Subject {
117
+ public greet() {
118
+ return "hi";
119
+ }
120
+ }
121
+ const original = Subject.prototype.greet;
122
+ Trace(Subject);
123
+ expect(Subject.prototype.greet).toBe(original);
124
+ });
125
+ });
126
+
127
+ describe("when FORGE_TRACE is set", () => {
128
+ beforeEach(() => {
129
+ vi.stubEnv("FORGE_TRACE", "1");
130
+ });
131
+
132
+ describe("trace (method decorator)", () => {
133
+ it("replaces the descriptor value with a wrapper", async () => {
134
+ const { trace } = await importTrace();
135
+ const fn = vi.fn().mockReturnValue("result");
136
+ class Subject {
137
+ public myMethod() {
138
+ return fn();
139
+ }
140
+ }
141
+ const descriptor = Object.getOwnPropertyDescriptor(Subject.prototype, "myMethod")!;
142
+ trace(Subject.prototype, "myMethod", descriptor);
143
+ expect(descriptor.value).not.toBe(fn);
144
+ });
145
+
146
+ it("preserves sync return value", async () => {
147
+ const { trace } = await importTrace();
148
+ const fn = vi.fn().mockReturnValue("result");
149
+ class Subject {
150
+ public myMethod() {
151
+ return fn();
152
+ }
153
+ }
154
+ const descriptor = Object.getOwnPropertyDescriptor(Subject.prototype, "myMethod")!;
155
+ trace(Subject.prototype, "myMethod", descriptor);
156
+ expect(descriptor.value.call(new Subject())).toBe("result");
157
+ });
158
+
159
+ it("logs timing for sync methods", async () => {
160
+ const { trace, loggerSpy } = await importTrace();
161
+ const traceFn = vi.fn();
162
+ loggerSpy.mockReturnValue({ trace: traceFn } as never);
163
+ const fn = vi.fn().mockReturnValue("result");
164
+ class Subject {
165
+ public myMethod() {
166
+ return fn();
167
+ }
168
+ }
169
+ const descriptor = Object.getOwnPropertyDescriptor(Subject.prototype, "myMethod")!;
170
+ trace(Subject.prototype, "myMethod", descriptor);
171
+ descriptor.value.call(new Subject());
172
+ expect(traceFn).toHaveBeenCalledWith(
173
+ expect.stringMatching(/^myMethod\(.*\): [\d.]+ms$/),
174
+ );
175
+ });
176
+
177
+ it("preserves async return value", async () => {
178
+ const { trace } = await importTrace();
179
+ const fn = vi.fn().mockResolvedValue("async-result");
180
+ class Subject {
181
+ public async myMethod() {
182
+ return fn();
183
+ }
184
+ }
185
+ const descriptor = Object.getOwnPropertyDescriptor(Subject.prototype, "myMethod")!;
186
+ trace(Subject.prototype, "myMethod", descriptor);
187
+ expect(await descriptor.value.call(new Subject())).toBe("async-result");
188
+ });
189
+
190
+ it("logs timing after async method resolves", async () => {
191
+ const { trace, loggerSpy } = await importTrace();
192
+ const traceFn = vi.fn();
193
+ loggerSpy.mockReturnValue({ trace: traceFn } as never);
194
+ const fn = vi.fn().mockResolvedValue("result");
195
+ class Subject {
196
+ public async myMethod() {
197
+ return fn();
198
+ }
199
+ }
200
+ const descriptor = Object.getOwnPropertyDescriptor(Subject.prototype, "myMethod")!;
201
+ trace(Subject.prototype, "myMethod", descriptor);
202
+ await descriptor.value.call(new Subject());
203
+ expect(traceFn).toHaveBeenCalledWith(
204
+ expect.stringMatching(/^myMethod\(.*\): [\d.]+ms$/),
205
+ );
206
+ });
207
+
208
+ it("logs timing even when async method rejects", async () => {
209
+ const { trace, loggerSpy } = await importTrace();
210
+ const traceFn = vi.fn();
211
+ loggerSpy.mockReturnValue({ trace: traceFn } as never);
212
+ const fn = vi.fn().mockRejectedValue(new Error("boom"));
213
+ class Subject {
214
+ public async myMethod() {
215
+ return fn();
216
+ }
217
+ }
218
+ const descriptor = Object.getOwnPropertyDescriptor(Subject.prototype, "myMethod")!;
219
+ trace(Subject.prototype, "myMethod", descriptor);
220
+ await expect(descriptor.value.call(new Subject())).rejects.toThrow("boom");
221
+ expect(traceFn).toHaveBeenCalledWith(
222
+ expect.stringMatching(/^myMethod\(.*\): [\d.]+ms$/),
223
+ );
224
+ });
225
+ });
226
+
227
+ describe("Trace (class decorator)", () => {
228
+ it("wraps all methods on the prototype", async () => {
229
+ const { Trace } = await importTrace();
230
+ class Subject {
231
+ public methodA() {
232
+ return "a";
233
+ }
234
+ public methodB() {
235
+ return "b";
236
+ }
237
+ }
238
+ const originalA = Subject.prototype.methodA;
239
+ const originalB = Subject.prototype.methodB;
240
+ Trace(Subject);
241
+ expect(Subject.prototype.methodA).not.toBe(originalA);
242
+ expect(Subject.prototype.methodB).not.toBe(originalB);
243
+ });
244
+
245
+ it("does not wrap the constructor", async () => {
246
+ const { Trace } = await importTrace();
247
+ const ctorSpy = vi.fn();
248
+ class Subject {
249
+ public constructor() {
250
+ ctorSpy();
251
+ }
252
+ }
253
+ expect(() => Trace(Subject)).not.toThrow();
254
+ new Subject();
255
+ expect(ctorSpy).toHaveBeenCalledOnce();
256
+ });
257
+
258
+ it("preserves method return values after wrapping", async () => {
259
+ const { Trace } = await importTrace();
260
+ class Subject {
261
+ public greet(name: string) {
262
+ return `hello ${name}`;
263
+ }
264
+ }
265
+ Trace(Subject);
266
+ expect(new Subject().greet("world")).toBe("hello world");
267
+ });
268
+
269
+ it("logs timing for each method call", async () => {
270
+ const { Trace, loggerSpy } = await importTrace();
271
+ const traceFn = vi.fn();
272
+ loggerSpy.mockReturnValue({ trace: traceFn } as never);
273
+ class Subject {
274
+ public methodA() {
275
+ return "a";
276
+ }
277
+ public methodB() {
278
+ return "b";
279
+ }
280
+ }
281
+ Trace(Subject);
282
+ const instance = new Subject();
283
+ instance.methodA();
284
+ instance.methodB();
285
+ expect(traceFn.mock.calls).toHaveLength(2);
286
+ });
287
+ });
288
+ });
289
+ });
290
+
291
+ describe("importing @decohere/devkit/diagnostics (#524)", () => {
292
+ afterEach(() => {
293
+ vi.unstubAllEnvs();
294
+ vi.restoreAllMocks();
295
+ });
296
+
297
+ it("leaves scribe alone even with FORGE_TRACE set: a library has no side effects on import", async () => {
298
+ vi.resetModules();
299
+ vi.stubEnv("FORGE_TRACE", "1");
300
+ const { scribe } = await import("@decohere/scribe");
301
+ const configure = vi.spyOn(scribe, "configure");
302
+
303
+ await import("@decohere/devkit/diagnostics");
304
+
305
+ expect(configure).not.toHaveBeenCalled();
306
+ });
307
+
308
+ it("reads FORGE_TRACE when a method is decorated, not when the module loads", async () => {
309
+ vi.resetModules();
310
+ const { trace } = await importTrace();
311
+ vi.stubEnv("FORGE_TRACE", "1");
312
+ const fn = vi.fn();
313
+ const descriptor = { value: fn };
314
+
315
+ trace({} as object, "myMethod", descriptor as PropertyDescriptor);
316
+
317
+ expect(descriptor.value).not.toBe(fn);
318
+ });
319
+ });