ushman-characterize 0.4.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/AGENTS.md +110 -0
- package/CHANGELOG.md +41 -0
- package/LICENSE.md +21 -0
- package/README.md +193 -0
- package/bin/ushman-characterize +19 -0
- package/dist/babel-config.d.ts +7 -0
- package/dist/babel-config.d.ts.map +1 -0
- package/dist/babel-config.js +17 -0
- package/dist/capture-server.d.ts +31 -0
- package/dist/capture-server.d.ts.map +1 -0
- package/dist/capture-server.js +199 -0
- package/dist/capture.d.ts +97 -0
- package/dist/capture.d.ts.map +1 -0
- package/dist/capture.js +620 -0
- package/dist/cli/logger.d.ts +7 -0
- package/dist/cli/logger.d.ts.map +1 -0
- package/dist/cli/logger.js +14 -0
- package/dist/cli/parse-flags.d.ts +8 -0
- package/dist/cli/parse-flags.d.ts.map +1 -0
- package/dist/cli/parse-flags.js +60 -0
- package/dist/cli.d.ts +39 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +439 -0
- package/dist/constants.d.ts +20 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +19 -0
- package/dist/dedupe-contract.d.ts +26 -0
- package/dist/dedupe-contract.d.ts.map +1 -0
- package/dist/dedupe-contract.js +12 -0
- package/dist/default-export.d.ts +6 -0
- package/dist/default-export.d.ts.map +1 -0
- package/dist/default-export.js +52 -0
- package/dist/format-contract.d.ts +25 -0
- package/dist/format-contract.d.ts.map +1 -0
- package/dist/format-contract.js +96 -0
- package/dist/function-utils.d.ts +6 -0
- package/dist/function-utils.d.ts.map +1 -0
- package/dist/function-utils.js +22 -0
- package/dist/generate-replay.d.ts +18 -0
- package/dist/generate-replay.d.ts.map +1 -0
- package/dist/generate-replay.js +158 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/instrument.d.ts +39 -0
- package/dist/instrument.d.ts.map +1 -0
- package/dist/instrument.js +605 -0
- package/dist/ledger.d.ts +19 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +50 -0
- package/dist/puppeteer-harness.d.ts +74 -0
- package/dist/puppeteer-harness.d.ts.map +1 -0
- package/dist/puppeteer-harness.js +248 -0
- package/dist/purity-classifier.d.ts +28 -0
- package/dist/purity-classifier.d.ts.map +1 -0
- package/dist/purity-classifier.js +363 -0
- package/dist/rebind.d.ts +26 -0
- package/dist/rebind.d.ts.map +1 -0
- package/dist/rebind.js +356 -0
- package/dist/replay-report.d.ts +18 -0
- package/dist/replay-report.d.ts.map +1 -0
- package/dist/replay-report.js +12 -0
- package/dist/scene.d.ts +24 -0
- package/dist/scene.d.ts.map +1 -0
- package/dist/scene.js +235 -0
- package/dist/schema-types.d.ts +40 -0
- package/dist/schema-types.d.ts.map +1 -0
- package/dist/schema-types.js +32 -0
- package/dist/seed-scaffolds.d.ts +31 -0
- package/dist/seed-scaffolds.d.ts.map +1 -0
- package/dist/seed-scaffolds.js +96 -0
- package/dist/shared.d.ts +36 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +390 -0
- package/dist/state-dag.d.ts +5 -0
- package/dist/state-dag.d.ts.map +1 -0
- package/dist/state-dag.js +27 -0
- package/dist/stub-pure.d.ts +57 -0
- package/dist/stub-pure.d.ts.map +1 -0
- package/dist/stub-pure.js +987 -0
- package/dist/time.d.ts +3 -0
- package/dist/time.d.ts.map +1 -0
- package/dist/time.js +10 -0
- package/dist/trace-format.d.ts +24 -0
- package/dist/trace-format.d.ts.map +1 -0
- package/dist/trace-format.js +213 -0
- package/dist/trace-serializer.d.ts +94 -0
- package/dist/trace-serializer.d.ts.map +1 -0
- package/dist/trace-serializer.js +607 -0
- package/dist/tracer-runtime.d.ts +25 -0
- package/dist/tracer-runtime.d.ts.map +1 -0
- package/dist/tracer-runtime.js +291 -0
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +0 -0
- package/dist/workspace-paths.d.ts +64 -0
- package/dist/workspace-paths.d.ts.map +1 -0
- package/dist/workspace-paths.js +288 -0
- package/package.json +86 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { DEFAULT_PER_FUNCTION_SHAPE_CAP, DEFAULT_TRACE_DEPTH_LIMIT, TRACE_RUNTIME_VERSION } from "./constants.js";
|
|
2
|
+
import { toTraceDeduplicationPayload } from "./dedupe-contract.js";
|
|
3
|
+
import { buildTraceRuntimeSupportSource } from "./trace-serializer.js";
|
|
4
|
+
const buildSideEffectPatchesSource = (captureSideEffects) => `
|
|
5
|
+
const installDomMutationObserver = () => {
|
|
6
|
+
if (!globalThis.MutationObserver) return;
|
|
7
|
+
const start = () => {
|
|
8
|
+
if (!document.documentElement || globalThis.__USHMAN_TRACER_DOM_OBSERVER__) return;
|
|
9
|
+
const observer = new MutationObserver((mutations) => {
|
|
10
|
+
for (const mutation of mutations) {
|
|
11
|
+
recordSideEffect({
|
|
12
|
+
attributeName: mutation.attributeName ?? null,
|
|
13
|
+
target: mutation.target?.nodeName ?? null,
|
|
14
|
+
type: mutation.type,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
observer.observe(document.documentElement, { attributes: true, childList: true, subtree: true });
|
|
19
|
+
globalThis.__USHMAN_TRACER_DOM_OBSERVER__ = observer;
|
|
20
|
+
};
|
|
21
|
+
if (document.readyState === 'loading') {
|
|
22
|
+
document.addEventListener('DOMContentLoaded', start, { once: true });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
start();
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const wrapAudioContext = (key) => {
|
|
29
|
+
const Original = globalThis[key];
|
|
30
|
+
if (!Original || Original.__ushmanWrapped) return;
|
|
31
|
+
function WrappedAudioContext(...args) {
|
|
32
|
+
recordSideEffect({ type: key + '.construct' });
|
|
33
|
+
return Reflect.construct(Original, args, new.target || WrappedAudioContext);
|
|
34
|
+
}
|
|
35
|
+
WrappedAudioContext.prototype = Original.prototype;
|
|
36
|
+
Object.setPrototypeOf(WrappedAudioContext, Original);
|
|
37
|
+
WrappedAudioContext.__ushmanWrapped = true;
|
|
38
|
+
globalThis[key] = WrappedAudioContext;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const patchSceneGraphIfAvailable = () => {
|
|
42
|
+
const object3DPrototype = globalThis.THREE?.Object3D?.prototype;
|
|
43
|
+
if (!object3DPrototype || object3DPrototype.__ushmanWrapped) return;
|
|
44
|
+
const originalAdd = object3DPrototype.add;
|
|
45
|
+
const originalRemove = object3DPrototype.remove;
|
|
46
|
+
object3DPrototype.add = function (...children) {
|
|
47
|
+
recordSideEffect({
|
|
48
|
+
childNames: children.map((child) => child?.name ?? null),
|
|
49
|
+
childTypes: children.map((child) => child?.type ?? null),
|
|
50
|
+
count: children.length,
|
|
51
|
+
type: 'THREE.Object3D.add',
|
|
52
|
+
});
|
|
53
|
+
return originalAdd.apply(this, children);
|
|
54
|
+
};
|
|
55
|
+
object3DPrototype.remove = function (...children) {
|
|
56
|
+
recordSideEffect({
|
|
57
|
+
childNames: children.map((child) => child?.name ?? null),
|
|
58
|
+
childTypes: children.map((child) => child?.type ?? null),
|
|
59
|
+
count: children.length,
|
|
60
|
+
type: 'THREE.Object3D.remove',
|
|
61
|
+
});
|
|
62
|
+
return originalRemove.apply(this, children);
|
|
63
|
+
};
|
|
64
|
+
object3DPrototype.__ushmanWrapped = true;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const maybePatchSideEffects = () => {
|
|
68
|
+
patchPromisePrototype();
|
|
69
|
+
patchAsyncCallbackRegistrar('queueMicrotask');
|
|
70
|
+
patchAsyncCallbackRegistrar('requestAnimationFrame');
|
|
71
|
+
patchAsyncCallbackRegistrar('setInterval');
|
|
72
|
+
patchAsyncCallbackRegistrar('setTimeout');
|
|
73
|
+
if (!${captureSideEffects ? 'true' : 'false'}) return;
|
|
74
|
+
if (globalThis.fetch && !globalThis.fetch.__ushmanWrapped) {
|
|
75
|
+
const originalFetch = globalThis.fetch.bind(globalThis);
|
|
76
|
+
const wrappedFetch = (...args) => {
|
|
77
|
+
recordSideEffect({ type: 'fetch', url: String(args[0]), method: args[1]?.method ?? 'GET' });
|
|
78
|
+
return originalFetch(...args);
|
|
79
|
+
};
|
|
80
|
+
wrappedFetch.__ushmanWrapped = true;
|
|
81
|
+
globalThis.fetch = wrappedFetch;
|
|
82
|
+
}
|
|
83
|
+
if (globalThis.localStorage && !globalThis.localStorage.__ushmanWrapped) {
|
|
84
|
+
const setItem = globalThis.localStorage.setItem.bind(globalThis.localStorage);
|
|
85
|
+
const removeItem = globalThis.localStorage.removeItem.bind(globalThis.localStorage);
|
|
86
|
+
globalThis.localStorage.setItem = (key, value) => {
|
|
87
|
+
recordSideEffect({ type: 'localStorage.setItem', key, value });
|
|
88
|
+
return setItem(key, value);
|
|
89
|
+
};
|
|
90
|
+
globalThis.localStorage.removeItem = (key) => {
|
|
91
|
+
recordSideEffect({ type: 'localStorage.removeItem', key });
|
|
92
|
+
return removeItem(key);
|
|
93
|
+
};
|
|
94
|
+
globalThis.localStorage.__ushmanWrapped = true;
|
|
95
|
+
}
|
|
96
|
+
wrapAudioContext('AudioContext');
|
|
97
|
+
wrapAudioContext('webkitAudioContext');
|
|
98
|
+
installDomMutationObserver();
|
|
99
|
+
patchSceneGraphIfAvailable();
|
|
100
|
+
};
|
|
101
|
+
`;
|
|
102
|
+
const DEDUPE_PAYLOAD_SOURCE = `const toTraceDeduplicationPayload = ${toTraceDeduplicationPayload.toString()};`;
|
|
103
|
+
export const buildTracerRuntimeSource = ({ captureSideEffects, perFunctionShapeCap = DEFAULT_PER_FUNCTION_SHAPE_CAP, }) => `(() => {
|
|
104
|
+
if (globalThis.__USHMAN_TRACER__ && globalThis.__USHMAN_TRACER__.version === ${TRACE_RUNTIME_VERSION}) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
${buildTraceRuntimeSupportSource()}
|
|
109
|
+
${DEDUPE_PAYLOAD_SOURCE}
|
|
110
|
+
|
|
111
|
+
const recordsByKey = new Map();
|
|
112
|
+
const dirtyKeys = new Set();
|
|
113
|
+
const active = new Map();
|
|
114
|
+
const activeStack = [];
|
|
115
|
+
const invocationCallIds = [];
|
|
116
|
+
const uniqueCountsByFunction = new Map();
|
|
117
|
+
const stats = {
|
|
118
|
+
droppedUniqueCalls: 0,
|
|
119
|
+
rawCalls: 0,
|
|
120
|
+
uniqueCalls: 0,
|
|
121
|
+
};
|
|
122
|
+
let disabled = false;
|
|
123
|
+
let nextId = 1;
|
|
124
|
+
|
|
125
|
+
// Sync chunks must unwind in stack order. Duplicate leave calls are tolerated
|
|
126
|
+
// as a no-op so the await helper and outer wrapper can share the same call id.
|
|
127
|
+
const leaveSyncFrame = (id) => {
|
|
128
|
+
if (activeStack[activeStack.length - 1] === id) {
|
|
129
|
+
activeStack.pop();
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const currentCallId = () => activeStack[activeStack.length - 1] ?? null;
|
|
134
|
+
const peekInvocationCallId = () => invocationCallIds[invocationCallIds.length - 1] ?? null;
|
|
135
|
+
|
|
136
|
+
const pushInvocationCallId = (id) => {
|
|
137
|
+
invocationCallIds.push(id);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const popInvocationCallId = (id) => {
|
|
141
|
+
const index = invocationCallIds.lastIndexOf(id);
|
|
142
|
+
if (index >= 0) {
|
|
143
|
+
invocationCallIds.splice(index, 1);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const currentCall = () => {
|
|
148
|
+
const id = currentCallId();
|
|
149
|
+
return id ? active.get(id) ?? null : null;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const enterAsyncFrame = (id) => {
|
|
153
|
+
if (!active.has(id) || activeStack[activeStack.length - 1] === id) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
activeStack.push(id);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const runWithCallContext = (id, callback, thisArg, args) => {
|
|
160
|
+
if (!id || !active.has(id) || typeof callback !== 'function') {
|
|
161
|
+
return callback?.apply?.(thisArg, args);
|
|
162
|
+
}
|
|
163
|
+
activeStack.push(id);
|
|
164
|
+
try {
|
|
165
|
+
return callback.apply(thisArg, args);
|
|
166
|
+
} finally {
|
|
167
|
+
leaveSyncFrame(id);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const wrapAsyncCallback = (callback, callId) => {
|
|
172
|
+
if (typeof callback !== 'function' || !callId) {
|
|
173
|
+
return callback;
|
|
174
|
+
}
|
|
175
|
+
return function (...args) {
|
|
176
|
+
return runWithCallContext(callId, callback, this, args);
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const patchPromisePrototype = () => {
|
|
181
|
+
const promisePrototype = globalThis.Promise?.prototype;
|
|
182
|
+
if (!promisePrototype || promisePrototype.__ushmanWrapped) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const originalThen = promisePrototype.then;
|
|
186
|
+
promisePrototype.then = function (onFulfilled, onRejected) {
|
|
187
|
+
const callId = currentCallId();
|
|
188
|
+
return originalThen.call(this, wrapAsyncCallback(onFulfilled, callId), wrapAsyncCallback(onRejected, callId));
|
|
189
|
+
};
|
|
190
|
+
promisePrototype.__ushmanWrapped = true;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const patchAsyncCallbackRegistrar = (key) => {
|
|
194
|
+
const original = globalThis[key];
|
|
195
|
+
if (typeof original !== 'function' || original.__ushmanWrapped) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const wrapped = (callback, ...args) => {
|
|
199
|
+
const callId = currentCallId();
|
|
200
|
+
return original.call(globalThis, wrapAsyncCallback(callback, callId), ...args);
|
|
201
|
+
};
|
|
202
|
+
wrapped.__ushmanWrapped = true;
|
|
203
|
+
globalThis[key] = wrapped;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const recordSideEffect = (effect) => {
|
|
207
|
+
const call = currentCall();
|
|
208
|
+
if (!call || disabled) return;
|
|
209
|
+
call.sideEffects.push(canonicalizeTraceValue(effect, { depthLimit: ${DEFAULT_TRACE_DEPTH_LIMIT} }));
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
${buildSideEffectPatchesSource(captureSideEffects)}
|
|
213
|
+
maybePatchSideEffects();
|
|
214
|
+
|
|
215
|
+
const createDedupKey = (record) =>
|
|
216
|
+
buildStableHash(
|
|
217
|
+
JSON.stringify(toTraceDeduplicationPayload(record)),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
globalThis.__USHMAN_TRACER__ = {
|
|
221
|
+
version: ${TRACE_RUNTIME_VERSION},
|
|
222
|
+
disable() {
|
|
223
|
+
disabled = true;
|
|
224
|
+
},
|
|
225
|
+
drain() {
|
|
226
|
+
const records = [...dirtyKeys].map((key) => recordsByKey.get(key)).filter(Boolean);
|
|
227
|
+
dirtyKeys.clear();
|
|
228
|
+
return {
|
|
229
|
+
records,
|
|
230
|
+
stats: { ...stats },
|
|
231
|
+
};
|
|
232
|
+
},
|
|
233
|
+
enter(meta, args, thisArg) {
|
|
234
|
+
maybePatchSideEffects();
|
|
235
|
+
const id = nextId++;
|
|
236
|
+
active.set(id, {
|
|
237
|
+
args: canonicalizeTraceValue(args, { depthLimit: ${DEFAULT_TRACE_DEPTH_LIMIT} }),
|
|
238
|
+
meta,
|
|
239
|
+
sideEffects: [],
|
|
240
|
+
thisArg: canonicalizeTraceValue(thisArg, { depthLimit: ${DEFAULT_TRACE_DEPTH_LIMIT} }),
|
|
241
|
+
});
|
|
242
|
+
activeStack.push(id);
|
|
243
|
+
return id;
|
|
244
|
+
},
|
|
245
|
+
enterAsyncFrame,
|
|
246
|
+
leaveSyncFrame,
|
|
247
|
+
peekInvocationCallId,
|
|
248
|
+
popInvocationCallId,
|
|
249
|
+
pushInvocationCallId,
|
|
250
|
+
exit(id, result, threw) {
|
|
251
|
+
const entry = active.get(id);
|
|
252
|
+
leaveSyncFrame(id);
|
|
253
|
+
if (!entry) return result;
|
|
254
|
+
active.delete(id);
|
|
255
|
+
stats.rawCalls += 1;
|
|
256
|
+
if (disabled) {
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
const record = {
|
|
260
|
+
args: entry.args,
|
|
261
|
+
bindingName: entry.meta.bindingName,
|
|
262
|
+
className: entry.meta.className,
|
|
263
|
+
count: 1,
|
|
264
|
+
expected: canonicalizeTraceValue(result, { depthLimit: ${DEFAULT_TRACE_DEPTH_LIMIT} }),
|
|
265
|
+
functionName: entry.meta.functionName,
|
|
266
|
+
memberKind: entry.meta.memberKind,
|
|
267
|
+
methodName: entry.meta.methodName,
|
|
268
|
+
sideEffects: entry.sideEffects,
|
|
269
|
+
thisArg: entry.thisArg,
|
|
270
|
+
threw: threw === undefined ? null : canonicalizeTraceValue(threw, { depthLimit: ${DEFAULT_TRACE_DEPTH_LIMIT} }),
|
|
271
|
+
};
|
|
272
|
+
const dedupeKey = createDedupKey(record);
|
|
273
|
+
const existing = recordsByKey.get(dedupeKey);
|
|
274
|
+
if (existing) {
|
|
275
|
+
existing.count += 1;
|
|
276
|
+
dirtyKeys.add(dedupeKey);
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
const uniqueCount = uniqueCountsByFunction.get(entry.meta.functionName) ?? 0;
|
|
280
|
+
if (uniqueCount >= ${perFunctionShapeCap}) {
|
|
281
|
+
stats.droppedUniqueCalls += 1;
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
uniqueCountsByFunction.set(entry.meta.functionName, uniqueCount + 1);
|
|
285
|
+
stats.uniqueCalls += 1;
|
|
286
|
+
recordsByKey.set(dedupeKey, record);
|
|
287
|
+
dirtyKeys.add(dedupeKey);
|
|
288
|
+
return result;
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
})();`;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { BrowserPage } from './puppeteer-harness.ts';
|
|
2
|
+
export type CaptureServerHandle = {
|
|
3
|
+
readonly close: () => Promise<void>;
|
|
4
|
+
readonly url: string;
|
|
5
|
+
};
|
|
6
|
+
export type CaptureServerHost = {
|
|
7
|
+
readonly serve: (rootDir: string, port: number) => Promise<CaptureServerHandle>;
|
|
8
|
+
};
|
|
9
|
+
export type SceneInspectorDriver = {
|
|
10
|
+
readonly captureSceneTree: (page: BrowserPage) => Promise<unknown>;
|
|
11
|
+
readonly installPreboot?: (page: BrowserPage) => Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAE1D,MAAM,MAAM,mBAAmB,GAAG;IAC9B,QAAQ,CAAC,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC5B,QAAQ,CAAC,KAAK,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,mBAAmB,CAAC,CAAC;CACnF,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IAC/B,QAAQ,CAAC,gBAAgB,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IACnE,QAAQ,CAAC,cAAc,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAClE,CAAC"}
|
package/dist/types.js
ADDED
|
File without changes
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as v from 'valibot';
|
|
2
|
+
export type WorkspaceLayout = 'unknown' | 'v3' | 'v4';
|
|
3
|
+
export type V4WorkspacePaths = {
|
|
4
|
+
readonly agentSkill: string;
|
|
5
|
+
readonly asl: string;
|
|
6
|
+
readonly aslDonor: string;
|
|
7
|
+
readonly candidateIndex: string;
|
|
8
|
+
readonly candidatePackageJson: string;
|
|
9
|
+
readonly candidatePublic: string;
|
|
10
|
+
readonly candidateSrc: string;
|
|
11
|
+
readonly candidateViteConfig: string;
|
|
12
|
+
readonly capture: string;
|
|
13
|
+
readonly characterize: string;
|
|
14
|
+
readonly lab: string;
|
|
15
|
+
readonly labManifest: string;
|
|
16
|
+
readonly ledger: string;
|
|
17
|
+
readonly parity: string;
|
|
18
|
+
readonly pipelineLockFile: string;
|
|
19
|
+
readonly stripDecisions: string;
|
|
20
|
+
};
|
|
21
|
+
export type V3WorkspaceMarkers = {
|
|
22
|
+
readonly handoffFile: string;
|
|
23
|
+
readonly stages: string;
|
|
24
|
+
readonly ushmanState: string;
|
|
25
|
+
readonly workspaceIdFile: string;
|
|
26
|
+
};
|
|
27
|
+
export type WorkspacePaths = {
|
|
28
|
+
readonly layout: WorkspaceLayout;
|
|
29
|
+
readonly pipelineLockFile: string;
|
|
30
|
+
readonly root: string;
|
|
31
|
+
readonly screenshotsDir: string;
|
|
32
|
+
readonly screenshotsIndexFile: string;
|
|
33
|
+
readonly stateDagFile: string;
|
|
34
|
+
readonly v3: V3WorkspaceMarkers;
|
|
35
|
+
readonly v4: V4WorkspacePaths;
|
|
36
|
+
};
|
|
37
|
+
export declare class WorkspaceLayoutError extends Error {
|
|
38
|
+
readonly exitCode: number;
|
|
39
|
+
readonly layout: WorkspaceLayout;
|
|
40
|
+
readonly workspaceRoot: string;
|
|
41
|
+
constructor({ exitCode, layout, message, workspaceRoot, }: {
|
|
42
|
+
readonly exitCode: number;
|
|
43
|
+
readonly layout: WorkspaceLayout;
|
|
44
|
+
readonly message: string;
|
|
45
|
+
readonly workspaceRoot: string;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
export declare const resolveWorkspacePaths: (root: string) => WorkspacePaths;
|
|
49
|
+
export declare const pathExists: (filePath: string) => Promise<boolean>;
|
|
50
|
+
export declare const detectWorkspaceLayout: (root: string) => Promise<WorkspaceLayout>;
|
|
51
|
+
export declare const buildV3MigrationMessage: (workspaceRoot: string) => string;
|
|
52
|
+
export declare const assertV4Workspace: (root: string) => Promise<WorkspacePaths>;
|
|
53
|
+
export declare const atomicWriteJson: (filePath: string, payload: unknown) => Promise<void>;
|
|
54
|
+
export declare const atomicWriteText: (filePath: string, text: string) => Promise<void>;
|
|
55
|
+
export declare const readVersionedDocument: <TSchema extends v.GenericSchema>(filePath: string, schema: TSchema) => Promise<v.InferOutput<TSchema> | null>;
|
|
56
|
+
export declare const resolveDeterminismSeed: (workspaceRoot: string) => Promise<string>;
|
|
57
|
+
export declare const resolveStateDagFile: (workspaceRoot: string) => Promise<string>;
|
|
58
|
+
export declare const withPipelineLock: <T>({ command, disabled, workspaceRoot, work, }: {
|
|
59
|
+
readonly command: string;
|
|
60
|
+
readonly disabled?: boolean;
|
|
61
|
+
readonly workspaceRoot: string;
|
|
62
|
+
readonly work: () => Promise<T>;
|
|
63
|
+
}) => Promise<T>;
|
|
64
|
+
//# sourceMappingURL=workspace-paths.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspace-paths.d.ts","sourceRoot":"","sources":["../src/workspace-paths.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, open, rename, rm, stat } from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import * as v from 'valibot';
|
|
7
|
+
import { V3WorkspaceIdDocumentSchema } from "./schema-types.js";
|
|
8
|
+
import { nowIso } from "./time.js";
|
|
9
|
+
const LAB_DIR = '.lab';
|
|
10
|
+
const USHMAN_DIR = '.ushman';
|
|
11
|
+
const V4_SCHEMA_VERSION = 'ushman-lab/v4.0';
|
|
12
|
+
const V4_MIGRATION_EXIT_CODE = 3;
|
|
13
|
+
export class WorkspaceLayoutError extends Error {
|
|
14
|
+
exitCode;
|
|
15
|
+
layout;
|
|
16
|
+
workspaceRoot;
|
|
17
|
+
constructor({ exitCode, layout, message, workspaceRoot, }) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.exitCode = exitCode;
|
|
20
|
+
this.layout = layout;
|
|
21
|
+
this.workspaceRoot = workspaceRoot;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const buildV4Paths = (root) => ({
|
|
25
|
+
agentSkill: path.join(root, LAB_DIR, 'AGENT_SKILL.md'),
|
|
26
|
+
asl: path.join(root, 'asl'),
|
|
27
|
+
aslDonor: path.join(root, 'asl', 'donor'),
|
|
28
|
+
candidateIndex: path.join(root, 'index.html'),
|
|
29
|
+
candidatePackageJson: path.join(root, 'package.json'),
|
|
30
|
+
candidatePublic: path.join(root, 'public'),
|
|
31
|
+
candidateSrc: path.join(root, 'src'),
|
|
32
|
+
candidateViteConfig: path.join(root, 'vite.config.ts'),
|
|
33
|
+
capture: path.join(root, LAB_DIR, 'capture'),
|
|
34
|
+
characterize: path.join(root, LAB_DIR, 'characterize'),
|
|
35
|
+
lab: path.join(root, LAB_DIR),
|
|
36
|
+
labManifest: path.join(root, LAB_DIR, 'lab.json'),
|
|
37
|
+
ledger: path.join(root, LAB_DIR, 'ledger'),
|
|
38
|
+
parity: path.join(root, LAB_DIR, 'parity'),
|
|
39
|
+
pipelineLockFile: path.join(root, LAB_DIR, '.lock'),
|
|
40
|
+
stripDecisions: path.join(root, LAB_DIR, 'capture', 'strip-decisions.json'),
|
|
41
|
+
});
|
|
42
|
+
const buildV3Markers = (root) => ({
|
|
43
|
+
handoffFile: path.join(root, 'handoff.json'),
|
|
44
|
+
stages: path.join(root, 'stages'),
|
|
45
|
+
ushmanState: path.join(root, USHMAN_DIR, 'state.json'),
|
|
46
|
+
workspaceIdFile: path.join(root, USHMAN_DIR, 'workspace-id.json'),
|
|
47
|
+
});
|
|
48
|
+
const detectWorkspaceLayoutSync = (root) => {
|
|
49
|
+
const v4 = buildV4Paths(root);
|
|
50
|
+
const v3 = buildV3Markers(root);
|
|
51
|
+
if (existsSync(v4.labManifest)) {
|
|
52
|
+
return 'v4';
|
|
53
|
+
}
|
|
54
|
+
if (existsSync(v3.stages) || existsSync(v3.ushmanState) || existsSync(v3.handoffFile)) {
|
|
55
|
+
return 'v3';
|
|
56
|
+
}
|
|
57
|
+
return 'unknown';
|
|
58
|
+
};
|
|
59
|
+
export const resolveWorkspacePaths = (root) => {
|
|
60
|
+
const v4 = buildV4Paths(root);
|
|
61
|
+
const v3 = buildV3Markers(root);
|
|
62
|
+
const layout = detectWorkspaceLayoutSync(root);
|
|
63
|
+
const pipelineLockFile = layout === 'v3' ? path.join(root, USHMAN_DIR, 'pipeline.lock') : v4.pipelineLockFile;
|
|
64
|
+
return {
|
|
65
|
+
layout,
|
|
66
|
+
pipelineLockFile,
|
|
67
|
+
root,
|
|
68
|
+
screenshotsDir: path.join(root, 'screenshots'),
|
|
69
|
+
screenshotsIndexFile: path.join(root, 'screenshots', 'index.json'),
|
|
70
|
+
stateDagFile: path.join(root, LAB_DIR, 'state-dag.json'),
|
|
71
|
+
v3,
|
|
72
|
+
v4,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
export const pathExists = async (filePath) => {
|
|
76
|
+
try {
|
|
77
|
+
await stat(filePath);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error.code === 'ENOENT') {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
export const detectWorkspaceLayout = async (root) => {
|
|
88
|
+
const paths = resolveWorkspacePaths(root);
|
|
89
|
+
const labFile = Bun.file(paths.v4.labManifest);
|
|
90
|
+
if (await labFile.exists()) {
|
|
91
|
+
try {
|
|
92
|
+
const parsed = (await labFile.json());
|
|
93
|
+
if (parsed.schemaVersion === V4_SCHEMA_VERSION) {
|
|
94
|
+
return 'v4';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return 'unknown';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const [stagesExists, stateExists, handoffExists] = await Promise.all([
|
|
102
|
+
pathExists(paths.v3.stages),
|
|
103
|
+
Bun.file(paths.v3.ushmanState).exists(),
|
|
104
|
+
Bun.file(paths.v3.handoffFile).exists(),
|
|
105
|
+
]);
|
|
106
|
+
if (stagesExists || stateExists || handoffExists) {
|
|
107
|
+
return 'v3';
|
|
108
|
+
}
|
|
109
|
+
return 'unknown';
|
|
110
|
+
};
|
|
111
|
+
export const buildV3MigrationMessage = (workspaceRoot) => [
|
|
112
|
+
'This workspace is in v3 layout (legacy stages/, .ushman/, handoff.json at root).',
|
|
113
|
+
'ushman v4 no longer ships the v3 layout readers or the live migrator.',
|
|
114
|
+
'`ushman migrate-v3-v4` is retained only as a diagnostic stub.',
|
|
115
|
+
'All known v3 workspaces were migrated during the v4 cutover.',
|
|
116
|
+
'If this workspace was not on that migration list, restore shibuk v2.x + ushman v3.x to migrate it first.',
|
|
117
|
+
`Workspace: ${workspaceRoot}`,
|
|
118
|
+
].join('\n');
|
|
119
|
+
const createWorkspaceLayoutError = ({ layout, workspaceRoot, }) => {
|
|
120
|
+
if (layout === 'v3') {
|
|
121
|
+
return new WorkspaceLayoutError({
|
|
122
|
+
exitCode: V4_MIGRATION_EXIT_CODE,
|
|
123
|
+
layout,
|
|
124
|
+
message: buildV3MigrationMessage(workspaceRoot),
|
|
125
|
+
workspaceRoot,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return new WorkspaceLayoutError({
|
|
129
|
+
exitCode: 1,
|
|
130
|
+
layout,
|
|
131
|
+
message: `Workspace at ${workspaceRoot} has layout "${layout}", not v4. Expected ${path.join(workspaceRoot, '.lab', 'lab.json')} with schemaVersion "${V4_SCHEMA_VERSION}".`,
|
|
132
|
+
workspaceRoot,
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
export const assertV4Workspace = async (root) => {
|
|
136
|
+
const layout = await detectWorkspaceLayout(root);
|
|
137
|
+
if (layout !== 'v4') {
|
|
138
|
+
throw createWorkspaceLayoutError({
|
|
139
|
+
layout,
|
|
140
|
+
workspaceRoot: root,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
...resolveWorkspacePaths(root),
|
|
145
|
+
layout,
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
export const atomicWriteJson = async (filePath, payload) => {
|
|
149
|
+
const dir = path.dirname(filePath);
|
|
150
|
+
await mkdir(dir, { recursive: true });
|
|
151
|
+
const tmp = path.join(dir, `.${path.basename(filePath)}.tmp.${process.pid}.${Date.now()}.${randomUUID()}`);
|
|
152
|
+
await Bun.write(tmp, `${JSON.stringify(payload, null, 2)}\n`);
|
|
153
|
+
await rename(tmp, filePath);
|
|
154
|
+
};
|
|
155
|
+
export const atomicWriteText = async (filePath, text) => {
|
|
156
|
+
const dir = path.dirname(filePath);
|
|
157
|
+
await mkdir(dir, { recursive: true });
|
|
158
|
+
const tmp = path.join(dir, `.${path.basename(filePath)}.tmp.${process.pid}.${Date.now()}.${randomUUID()}`);
|
|
159
|
+
await Bun.write(tmp, text);
|
|
160
|
+
await rename(tmp, filePath);
|
|
161
|
+
};
|
|
162
|
+
export const readVersionedDocument = async (filePath, schema) => {
|
|
163
|
+
const file = Bun.file(filePath);
|
|
164
|
+
if (!(await file.exists())) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
return v.parse(schema, (await file.json()));
|
|
168
|
+
};
|
|
169
|
+
export const resolveDeterminismSeed = async (workspaceRoot) => {
|
|
170
|
+
const layout = await detectWorkspaceLayout(workspaceRoot);
|
|
171
|
+
if (layout === 'v4') {
|
|
172
|
+
const file = Bun.file(resolveWorkspacePaths(workspaceRoot).v4.labManifest);
|
|
173
|
+
if (await file.exists()) {
|
|
174
|
+
try {
|
|
175
|
+
const manifest = (await file.json());
|
|
176
|
+
if (typeof manifest.workspaceId === 'string' && manifest.workspaceId.length > 0) {
|
|
177
|
+
return manifest.workspaceId;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return workspaceRoot;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return workspaceRoot;
|
|
185
|
+
}
|
|
186
|
+
const workspaceId = await readVersionedDocument(resolveWorkspacePaths(workspaceRoot).v3.workspaceIdFile, V3WorkspaceIdDocumentSchema);
|
|
187
|
+
return workspaceId?.packageHash ?? workspaceRoot;
|
|
188
|
+
};
|
|
189
|
+
export const resolveStateDagFile = async (workspaceRoot) => {
|
|
190
|
+
return resolveWorkspacePaths(workspaceRoot).stateDagFile;
|
|
191
|
+
};
|
|
192
|
+
const readPipelineLock = async (filePath) => {
|
|
193
|
+
if (!(await pathExists(filePath))) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
return (await Bun.file(filePath).json());
|
|
197
|
+
};
|
|
198
|
+
const pipelineLockPayload = (command) => ({
|
|
199
|
+
command,
|
|
200
|
+
hostname: os.hostname(),
|
|
201
|
+
pid: process.pid,
|
|
202
|
+
startedAt: nowIso(),
|
|
203
|
+
});
|
|
204
|
+
const tryWriteLockfile = async (filePath, payload) => {
|
|
205
|
+
const handle = await open(filePath, 'wx');
|
|
206
|
+
let writeError = null;
|
|
207
|
+
let closeError = null;
|
|
208
|
+
try {
|
|
209
|
+
await handle.writeFile(`${JSON.stringify(payload, null, 2)}\n`);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
writeError = error;
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
try {
|
|
216
|
+
await handle.close();
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
closeError = error;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (writeError) {
|
|
223
|
+
throw writeError;
|
|
224
|
+
}
|
|
225
|
+
if (closeError) {
|
|
226
|
+
throw closeError;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
const reclaimStaleLockOrThrowBusy = async (filePath) => {
|
|
230
|
+
const existing = await readPipelineLock(filePath);
|
|
231
|
+
const existingPid = typeof existing?.pid === 'number' ? existing.pid : null;
|
|
232
|
+
if (existingPid !== null) {
|
|
233
|
+
try {
|
|
234
|
+
process.kill(existingPid, 0);
|
|
235
|
+
throw new Error(`EBUSY: pipeline lock held by PID ${existingPid}`);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
239
|
+
if (message.startsWith('EBUSY:')) {
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
await rm(filePath, { force: true });
|
|
245
|
+
};
|
|
246
|
+
const acquirePipelineLock = async ({ command, workspaceRoot, }) => {
|
|
247
|
+
const { pipelineLockFile } = resolveWorkspacePaths(workspaceRoot);
|
|
248
|
+
await mkdir(path.dirname(pipelineLockFile), { recursive: true });
|
|
249
|
+
const payload = pipelineLockPayload(command);
|
|
250
|
+
try {
|
|
251
|
+
await tryWriteLockfile(pipelineLockFile, payload);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
const errno = error;
|
|
255
|
+
if (errno.code !== 'EEXIST') {
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
await reclaimStaleLockOrThrowBusy(pipelineLockFile);
|
|
259
|
+
try {
|
|
260
|
+
await tryWriteLockfile(pipelineLockFile, payload);
|
|
261
|
+
}
|
|
262
|
+
catch (reclaimError) {
|
|
263
|
+
const reclaimedErrno = reclaimError;
|
|
264
|
+
if (reclaimedErrno.code === 'EEXIST') {
|
|
265
|
+
throw new Error('EBUSY: pipeline lock was reacquired by another process');
|
|
266
|
+
}
|
|
267
|
+
throw reclaimError;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return async () => {
|
|
271
|
+
await rm(pipelineLockFile, { force: true });
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
export const withPipelineLock = async ({ command, disabled = false, workspaceRoot, work, }) => {
|
|
275
|
+
if (disabled) {
|
|
276
|
+
return work();
|
|
277
|
+
}
|
|
278
|
+
const release = await acquirePipelineLock({
|
|
279
|
+
command,
|
|
280
|
+
workspaceRoot,
|
|
281
|
+
});
|
|
282
|
+
try {
|
|
283
|
+
return await work();
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
await release();
|
|
287
|
+
}
|
|
288
|
+
};
|