@mnemonik/shared 7.140.1 → 7.140.3
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/dist/codeScanner.d.ts +46 -1
- package/dist/codeScanner.d.ts.map +1 -1
- package/dist/codeScanner.js +100 -12
- package/dist/codeScanner.js.map +1 -1
- package/dist/contextBudget.d.ts +2 -2
- package/dist/contextBudget.d.ts.map +1 -1
- package/dist/contextBudget.js +3 -4
- package/dist/contextBudget.js.map +1 -1
- package/dist/gitEditDetector.d.ts +11 -6
- package/dist/gitEditDetector.d.ts.map +1 -1
- package/dist/gitEditDetector.js +119 -9
- package/dist/gitEditDetector.js.map +1 -1
- package/dist/hookRuntime.d.ts +18 -8
- package/dist/hookRuntime.d.ts.map +1 -1
- package/dist/hookRuntime.js +31 -20
- package/dist/hookRuntime.js.map +1 -1
- package/dist/hookTrace.d.ts +149 -0
- package/dist/hookTrace.d.ts.map +1 -0
- package/dist/hookTrace.js +483 -0
- package/dist/hookTrace.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/instructions.d.ts +1 -1
- package/dist/instructions.d.ts.map +1 -1
- package/dist/instructions.js +1 -1
- package/dist/projectIdentityFile.d.ts +13 -0
- package/dist/projectIdentityFile.d.ts.map +1 -1
- package/dist/projectIdentityFile.js +31 -18
- package/dist/projectIdentityFile.js.map +1 -1
- package/dist/protectedPaths.d.ts +6 -0
- package/dist/protectedPaths.d.ts.map +1 -1
- package/dist/protectedPaths.js +15 -6
- package/dist/protectedPaths.js.map +1 -1
- package/dist/repositoryRoot.d.ts +20 -8
- package/dist/repositoryRoot.d.ts.map +1 -1
- package/dist/repositoryRoot.js +167 -79
- package/dist/repositoryRoot.js.map +1 -1
- package/dist/runtimeReader.d.ts +1 -1
- package/dist/runtimeReader.d.ts.map +1 -1
- package/dist/runtimeReader.js.map +1 -1
- package/package.json +2 -2
- package/src/codeScanner.ts +115 -9
- package/src/contextBudget.ts +3 -4
- package/src/gitEditDetector.ts +113 -8
- package/src/hookRuntime.ts +39 -27
- package/src/hookTrace.ts +588 -0
- package/src/index.ts +4 -0
- package/src/instructions.ts +1 -1
- package/src/projectIdentityFile.ts +34 -20
- package/src/protectedPaths.ts +18 -7
- package/src/repositoryRoot.ts +188 -82
- package/src/runtimeReader.ts +6 -1
- package/test-fixtures/identity/runner.d.mts +1 -1
- package/test-fixtures/identity/runner.mjs +299 -25
- package/tests/hookTrace.test.ts +350 -0
- package/tests/nearestProjectIdentity.test.ts +253 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFile, spawnSync } from 'node:child_process';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { mkdtemp, readFile, rm, stat, writeFile, mkdir } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { after, test } from 'node:test';
|
|
9
|
+
import {
|
|
10
|
+
HOOK_TRACE_MAX_BYTES,
|
|
11
|
+
HookTrace,
|
|
12
|
+
appendHookTraceLine,
|
|
13
|
+
hookTraceEnabled,
|
|
14
|
+
hookTraceFile,
|
|
15
|
+
summarizeHookOutput,
|
|
16
|
+
type HookTraceRecord,
|
|
17
|
+
} from '../src/hookTrace.js';
|
|
18
|
+
|
|
19
|
+
const roots: string[] = [];
|
|
20
|
+
after(async () => {
|
|
21
|
+
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
|
|
22
|
+
});
|
|
23
|
+
async function tempRoot(): Promise<string> {
|
|
24
|
+
const root = await mkdtemp(join(tmpdir(), 'mnemonik-hook-trace-'));
|
|
25
|
+
roots.push(root);
|
|
26
|
+
return root;
|
|
27
|
+
}
|
|
28
|
+
async function lines(file: string): Promise<HookTraceRecord[]> {
|
|
29
|
+
return (await readFile(file, 'utf8'))
|
|
30
|
+
.trim()
|
|
31
|
+
.split('\n')
|
|
32
|
+
.map((line) => JSON.parse(line) as HookTraceRecord);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const PROMPT = 'please refactor the billing module SECRET-PROMPT-BODY';
|
|
36
|
+
const TOOL_BODY = 'TOOL-INPUT-BODY rm -rf /tmp/thing';
|
|
37
|
+
const RESULT_BODY = 'TOOL-OUTPUT-BODY 42 lines';
|
|
38
|
+
const CONTEXT = 'MNEMONIK-CONTEXT-BODY recalled memory text';
|
|
39
|
+
|
|
40
|
+
test('one invocation writes start, input and end lines with names and sizes only', async () => {
|
|
41
|
+
const state = await tempRoot();
|
|
42
|
+
const file = hookTraceFile('cursor', state);
|
|
43
|
+
const trace = new HookTrace('cursor', file, '0.7.0');
|
|
44
|
+
trace.begin(
|
|
45
|
+
{
|
|
46
|
+
hook_event_name: 'postToolUse',
|
|
47
|
+
session_id: 'session-1',
|
|
48
|
+
conversation_id: 'conv-1',
|
|
49
|
+
generation_id: 'gen-1',
|
|
50
|
+
cursor_version: '1.7.2',
|
|
51
|
+
tool_name: 'Shell',
|
|
52
|
+
tool_input: { command: TOOL_BODY },
|
|
53
|
+
tool_output: RESULT_BODY,
|
|
54
|
+
prompt: PROMPT,
|
|
55
|
+
transcript_path: '/home/u/.cursor/transcript.jsonl',
|
|
56
|
+
workspace_roots: ['/work/app'],
|
|
57
|
+
},
|
|
58
|
+
'postToolUse'
|
|
59
|
+
);
|
|
60
|
+
trace.project('11111111-1111-4111-8111-111111111111');
|
|
61
|
+
trace.output(JSON.stringify({ additional_context: CONTEXT, permission: 'allow' }));
|
|
62
|
+
trace.finish(0);
|
|
63
|
+
trace.finish(0); // once only
|
|
64
|
+
|
|
65
|
+
const [start, input, end, ...extra] = await lines(file);
|
|
66
|
+
assert.equal(extra.length, 0);
|
|
67
|
+
assert.equal(start?.phase, 'start');
|
|
68
|
+
assert.equal(input?.phase, 'input');
|
|
69
|
+
assert.equal(end?.phase, 'end');
|
|
70
|
+
if (start.phase !== 'start' || input.phase !== 'input' || end.phase !== 'end')
|
|
71
|
+
throw new Error('phases');
|
|
72
|
+
assert.equal(start.id, input.id);
|
|
73
|
+
assert.equal(start.id, end.id);
|
|
74
|
+
assert.equal(start.hookVersion, '0.7.0');
|
|
75
|
+
assert.equal(typeof start.processMs, 'number');
|
|
76
|
+
assert.equal(input.event, 'postToolUse');
|
|
77
|
+
assert.equal(input.sessionId, 'session-1');
|
|
78
|
+
assert.equal(input.conversationId, 'conv-1');
|
|
79
|
+
assert.equal(input.generationId, 'gen-1');
|
|
80
|
+
assert.equal(input.hostVersion, '1.7.2');
|
|
81
|
+
assert.equal(input.tool, 'Shell');
|
|
82
|
+
assert.equal(input.cwd, '/work/app');
|
|
83
|
+
assert.equal(input.fields.prompt, Buffer.byteLength(PROMPT));
|
|
84
|
+
assert.ok(input.fields.tool_input! > 0);
|
|
85
|
+
assert.equal(end.projectId, '11111111-1111-4111-8111-111111111111');
|
|
86
|
+
assert.equal(end.output?.permission, 'allow');
|
|
87
|
+
assert.equal(end.output?.sizes.additional_context, Buffer.byteLength(CONTEXT));
|
|
88
|
+
assert.equal(end.exitCode, 0);
|
|
89
|
+
|
|
90
|
+
const raw = await readFile(file, 'utf8');
|
|
91
|
+
for (const body of [PROMPT, TOOL_BODY, RESULT_BODY, CONTEXT, 'transcript.jsonl'])
|
|
92
|
+
assert.ok(!raw.includes(body), `trace must not contain ${body}`);
|
|
93
|
+
if (process.platform !== 'win32') {
|
|
94
|
+
assert.equal((await stat(file)).mode & 0o777, 0o600);
|
|
95
|
+
assert.equal((await stat(join(state, 'hook-trace'))).mode & 0o777, 0o700);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('an error is recorded as its name or code and a scrubbed message of at most 80 characters', async () => {
|
|
100
|
+
const state = await tempRoot();
|
|
101
|
+
const file = hookTraceFile('codex', state);
|
|
102
|
+
const trace = new HookTrace('codex', file);
|
|
103
|
+
trace.begin({ hook_event_name: 'Stop' });
|
|
104
|
+
trace.fail(
|
|
105
|
+
new TypeError(
|
|
106
|
+
`request failed: Authorization: Bearer abcdefghijklmnopqrstuvwxyz0123456789 ${'tail '.repeat(40)}`
|
|
107
|
+
)
|
|
108
|
+
);
|
|
109
|
+
trace.finish(0);
|
|
110
|
+
const end = (await lines(file)).at(-1);
|
|
111
|
+
assert.equal(end?.phase, 'end');
|
|
112
|
+
if (end?.phase !== 'end') throw new Error('phase');
|
|
113
|
+
assert.equal(end.errorKind, 'TypeError');
|
|
114
|
+
assert.match(end.error ?? '', /\[REDACTED\]/);
|
|
115
|
+
assert.ok(!end.error?.includes('abcdefghijklmnopqrstuvwxyz0123456789'));
|
|
116
|
+
assert.ok((end.error ?? '').length <= 80);
|
|
117
|
+
|
|
118
|
+
const codedFile = hookTraceFile('codex', await tempRoot());
|
|
119
|
+
const coded = new HookTrace('codex', codedFile);
|
|
120
|
+
coded.fail(Object.assign(new Error('connect refused'), { code: 'ECONNREFUSED' }));
|
|
121
|
+
coded.finish(0);
|
|
122
|
+
const codedEnd = (await lines(codedFile)).at(-1);
|
|
123
|
+
assert.equal(codedEnd?.phase === 'end' && codedEnd.errorKind, 'ECONNREFUSED');
|
|
124
|
+
assert.equal(codedEnd?.phase === 'end' && codedEnd.error, 'connect refused');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('an event name that fails the name check is not recorded', async () => {
|
|
128
|
+
const state = await tempRoot();
|
|
129
|
+
const file = hookTraceFile('cursor', state);
|
|
130
|
+
const trace = new HookTrace('cursor', file);
|
|
131
|
+
trace.begin({ hook_event_name: 'stop' }, 'before submit\nPROMPT-TEXT');
|
|
132
|
+
trace.finish(0);
|
|
133
|
+
const [, input, end] = await lines(file);
|
|
134
|
+
assert.equal(input?.phase === 'input' && input.event, 'stop');
|
|
135
|
+
assert.equal(end?.phase === 'end' && end.event, 'stop');
|
|
136
|
+
assert.ok(!(await readFile(file, 'utf8')).includes('PROMPT-TEXT'));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('a full file rotates to one .1 file', async () => {
|
|
140
|
+
const state = await tempRoot();
|
|
141
|
+
const file = hookTraceFile('claude-code', state);
|
|
142
|
+
await mkdir(join(state, 'hook-trace'), { recursive: true, mode: 0o700 });
|
|
143
|
+
await writeFile(file, `${'x'.repeat(HOOK_TRACE_MAX_BYTES - 10)}\n`, { mode: 0o600 });
|
|
144
|
+
const trace = new HookTrace('claude-code', file);
|
|
145
|
+
trace.begin({ hook_event_name: 'SessionStart' });
|
|
146
|
+
trace.finish(0);
|
|
147
|
+
const rotated = await readFile(`${file}.1`, 'utf8');
|
|
148
|
+
assert.ok(rotated.startsWith('xxx'));
|
|
149
|
+
const current = await lines(file);
|
|
150
|
+
assert.deepEqual(
|
|
151
|
+
current.map((record) => record.phase),
|
|
152
|
+
['start', 'input', 'end']
|
|
153
|
+
);
|
|
154
|
+
assert.ok((await stat(file)).size < 4096);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('an unwritable state folder never throws into the hook', async () => {
|
|
158
|
+
const root = await tempRoot();
|
|
159
|
+
const notADirectory = join(root, 'state-file');
|
|
160
|
+
await writeFile(notADirectory, 'occupied');
|
|
161
|
+
const trace = new HookTrace('cursor', hookTraceFile('cursor', notADirectory));
|
|
162
|
+
assert.doesNotThrow(() => {
|
|
163
|
+
trace.open();
|
|
164
|
+
trace.begin({ hook_event_name: 'stop' });
|
|
165
|
+
trace.output('{"continue":true}');
|
|
166
|
+
trace.fail(new Error('boom'));
|
|
167
|
+
trace.call('http://x.invalid/api/v1/hooks/injections', performance.now(), 200);
|
|
168
|
+
trace.finish(1);
|
|
169
|
+
});
|
|
170
|
+
assert.throws(() =>
|
|
171
|
+
appendHookTraceLine(hookTraceFile('cursor', notADirectory), {
|
|
172
|
+
v: 1,
|
|
173
|
+
phase: 'start',
|
|
174
|
+
ts: '',
|
|
175
|
+
host: 'cursor',
|
|
176
|
+
pid: 1,
|
|
177
|
+
id: 'x',
|
|
178
|
+
processMs: 0,
|
|
179
|
+
})
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('MNEMONIK_HOOK_TRACE=off disables the recorder; anything else leaves it on', () => {
|
|
184
|
+
assert.equal(hookTraceEnabled({ MNEMONIK_HOOK_TRACE: 'off' }), false);
|
|
185
|
+
assert.equal(hookTraceEnabled({ MNEMONIK_HOOK_TRACE: '0' }), false);
|
|
186
|
+
assert.equal(hookTraceEnabled({}), true);
|
|
187
|
+
assert.equal(hookTraceEnabled({ MNEMONIK_HOOK_TRACE: 'on' }), true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('the output summary keeps field sizes and decision enums, never text', () => {
|
|
191
|
+
const summary = summarizeHookOutput(
|
|
192
|
+
JSON.stringify({
|
|
193
|
+
decision: 'block',
|
|
194
|
+
reason: 'REASON-TEXT',
|
|
195
|
+
hookSpecificOutput: {
|
|
196
|
+
hookEventName: 'PreToolUse',
|
|
197
|
+
permissionDecision: 'deny',
|
|
198
|
+
permissionDecisionReason: 'WHY-TEXT',
|
|
199
|
+
additionalContext: CONTEXT,
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
);
|
|
203
|
+
assert.equal(summary.decision, 'block');
|
|
204
|
+
assert.equal(summary.permission, 'deny');
|
|
205
|
+
assert.equal(summary.sizes['hookSpecificOutput.additionalContext'], Buffer.byteLength(CONTEXT));
|
|
206
|
+
assert.ok(!JSON.stringify(summary).includes('TEXT'));
|
|
207
|
+
assert.deepEqual(summarizeHookOutput('plain text'), { bytes: 10, sizes: {} });
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('startHookTrace times each fetch and writes the end line on process exit', async () => {
|
|
211
|
+
const state = await tempRoot();
|
|
212
|
+
const server = createServer((request, response) => {
|
|
213
|
+
response.statusCode = request.url?.includes('missing') ? 404 : 200;
|
|
214
|
+
response.end('{}');
|
|
215
|
+
});
|
|
216
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
217
|
+
const port = (server.address() as { port: number }).port;
|
|
218
|
+
const dist = new URL('../dist/hookTrace.js', import.meta.url).href;
|
|
219
|
+
const script = `
|
|
220
|
+
const { startHookTrace } = await import(${JSON.stringify(dist)});
|
|
221
|
+
const trace = startHookTrace({ host: 'codex', hookVersion: '9.9.9' });
|
|
222
|
+
trace.begin({ hook_event_name: 'UserPromptSubmit', prompt: 'PROMPT-BODY', session_id: 's' });
|
|
223
|
+
await fetch('http://127.0.0.1:${port}/api/v1/hooks/injections?key=QUERY-SECRET');
|
|
224
|
+
await fetch('http://127.0.0.1:${port}/api/v1/component-credentials/fam0123456789abcdef/missing');
|
|
225
|
+
process.stdout.write('{"continue":true}');
|
|
226
|
+
process.exit(0);`;
|
|
227
|
+
const child = await new Promise<{ stdout: string }>((resolve, reject) => {
|
|
228
|
+
execFile(
|
|
229
|
+
process.execPath,
|
|
230
|
+
['--input-type=module', '-e', script],
|
|
231
|
+
{ env: { ...process.env, MNEMONIK_STATE_DIR: state, MNEMONIK_HOOK_TRACE: '' } },
|
|
232
|
+
(error, stdout) => (error ? reject(error) : resolve({ stdout }))
|
|
233
|
+
);
|
|
234
|
+
}).finally(() => server.close());
|
|
235
|
+
assert.equal(child.stdout, '{"continue":true}');
|
|
236
|
+
const [start, input, end] = await lines(hookTraceFile('codex', state));
|
|
237
|
+
assert.equal(start?.phase, 'start');
|
|
238
|
+
assert.equal(input?.phase, 'input');
|
|
239
|
+
assert.equal(end?.phase, 'end');
|
|
240
|
+
if (end?.phase !== 'end') throw new Error('phase');
|
|
241
|
+
assert.deepEqual(
|
|
242
|
+
end.calls.map(({ path, status }) => ({ path, status })),
|
|
243
|
+
[
|
|
244
|
+
{ path: '/api/v1/hooks/injections', status: 200 },
|
|
245
|
+
{ path: '/api/v1/component-credentials/:id/missing', status: 404 },
|
|
246
|
+
]
|
|
247
|
+
);
|
|
248
|
+
const raw = await readFile(hookTraceFile('codex', state), 'utf8');
|
|
249
|
+
assert.ok(!raw.includes('QUERY-SECRET'));
|
|
250
|
+
assert.ok(!raw.includes('PROMPT-BODY'));
|
|
251
|
+
assert.ok(!raw.includes('fam0123456789abcdef'));
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('a call still waiting at exit is recorded as pending', async () => {
|
|
255
|
+
const state = await tempRoot();
|
|
256
|
+
const server = createServer(() => {
|
|
257
|
+
/* Never answers. */
|
|
258
|
+
});
|
|
259
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
260
|
+
const port = (server.address() as { port: number }).port;
|
|
261
|
+
const dist = new URL('../dist/hookTrace.js', import.meta.url).href;
|
|
262
|
+
const script = `
|
|
263
|
+
const { startHookTrace } = await import(${JSON.stringify(dist)});
|
|
264
|
+
const trace = startHookTrace({ host: 'cursor' });
|
|
265
|
+
trace.begin({ hook_event_name: 'stop' });
|
|
266
|
+
fetch('http://127.0.0.1:${port}/api/v1/hooks/injections').catch(() => {});
|
|
267
|
+
setTimeout(() => process.exit(0), 200);`;
|
|
268
|
+
await new Promise<void>((resolve, reject) => {
|
|
269
|
+
execFile(
|
|
270
|
+
process.execPath,
|
|
271
|
+
['--input-type=module', '-e', script],
|
|
272
|
+
{ env: { ...process.env, MNEMONIK_STATE_DIR: state, MNEMONIK_HOOK_TRACE: '' } },
|
|
273
|
+
(error) => (error ? reject(error) : resolve())
|
|
274
|
+
);
|
|
275
|
+
}).finally(() => {
|
|
276
|
+
server.closeAllConnections();
|
|
277
|
+
server.close();
|
|
278
|
+
});
|
|
279
|
+
const end = (await lines(hookTraceFile('cursor', state))).at(-1);
|
|
280
|
+
assert.equal(end?.phase, 'end');
|
|
281
|
+
if (end?.phase !== 'end') throw new Error('phase');
|
|
282
|
+
assert.equal(end.calls.length, 1);
|
|
283
|
+
assert.equal(end.calls[0]?.path, '/api/v1/hooks/injections');
|
|
284
|
+
assert.equal(end.calls[0]?.pending, true);
|
|
285
|
+
assert.equal(end.calls[0]?.status, undefined);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('a run killed before its event arrives leaves the start line alone', async () => {
|
|
289
|
+
const dist = fileURLToPath(new URL('../dist/hookTrace.js', import.meta.url));
|
|
290
|
+
const href = JSON.stringify(new URL(`file://${dist}`).href);
|
|
291
|
+
const run = async (body: string) => {
|
|
292
|
+
const state = await tempRoot();
|
|
293
|
+
spawnSync(
|
|
294
|
+
process.execPath,
|
|
295
|
+
[
|
|
296
|
+
'--input-type=module',
|
|
297
|
+
'-e',
|
|
298
|
+
`const { startHookTrace } = await import(${href});
|
|
299
|
+
const trace = startHookTrace({ host: 'cursor' });
|
|
300
|
+
${body}
|
|
301
|
+
process.kill(process.pid, 'SIGKILL');`,
|
|
302
|
+
],
|
|
303
|
+
{ env: { ...process.env, MNEMONIK_STATE_DIR: state, MNEMONIK_HOOK_TRACE: '' } }
|
|
304
|
+
);
|
|
305
|
+
return (await lines(hookTraceFile('cursor', state))).map((record) => record.phase);
|
|
306
|
+
};
|
|
307
|
+
assert.deepEqual(await run(''), ['start']);
|
|
308
|
+
assert.deepEqual(await run(`trace.begin({ hook_event_name: 'beforeSubmitPrompt' });`), [
|
|
309
|
+
'start',
|
|
310
|
+
'input',
|
|
311
|
+
]);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('writers crossing the size limit together rotate once and keep the full file', async () => {
|
|
315
|
+
const state = await tempRoot();
|
|
316
|
+
const file = hookTraceFile('cursor', state);
|
|
317
|
+
await mkdir(join(state, 'hook-trace'), { recursive: true, mode: 0o700 });
|
|
318
|
+
await writeFile(`${file}.1`, 'OLDER-HISTORY\n', { mode: 0o600 });
|
|
319
|
+
await writeFile(file, `${'#'.repeat(HOOK_TRACE_MAX_BYTES - 50)}\n`, { mode: 0o600 });
|
|
320
|
+
const dist = new URL('../dist/hookTrace.js', import.meta.url).href;
|
|
321
|
+
const writers = 12;
|
|
322
|
+
const each = 40;
|
|
323
|
+
const startAt = Date.now() + 1000;
|
|
324
|
+
const script = `
|
|
325
|
+
const { appendHookTraceLine } = await import(${JSON.stringify(dist)});
|
|
326
|
+
while (Date.now() < ${startAt}) {}
|
|
327
|
+
for (let i = 0; i < ${each}; i++)
|
|
328
|
+
appendHookTraceLine(${JSON.stringify(file)}, {
|
|
329
|
+
v: 1, phase: 'start', ts: new Date().toISOString(), host: 'cursor',
|
|
330
|
+
pid: process.pid, id: process.pid + '-' + i, processMs: 0,
|
|
331
|
+
});`;
|
|
332
|
+
await Promise.all(
|
|
333
|
+
Array.from(
|
|
334
|
+
{ length: writers },
|
|
335
|
+
() =>
|
|
336
|
+
new Promise<void>((resolve, reject) =>
|
|
337
|
+
execFile(process.execPath, ['--input-type=module', '-e', script], (error) =>
|
|
338
|
+
error ? reject(error) : resolve()
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
);
|
|
343
|
+
const rotated = await readFile(`${file}.1`, 'utf8');
|
|
344
|
+
const current = await readFile(file, 'utf8');
|
|
345
|
+
assert.ok(rotated.startsWith('####'), 'the full file survives as .1');
|
|
346
|
+
const ids = new Set<string>();
|
|
347
|
+
for (const line of `${rotated}${current}`.split('\n'))
|
|
348
|
+
if (line.startsWith('{')) ids.add((JSON.parse(line) as HookTraceRecord).id);
|
|
349
|
+
assert.equal(ids.size, writers * each, 'no line is lost');
|
|
350
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { chmod, mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import {
|
|
8
|
+
resolveNearestProjectIdentity,
|
|
9
|
+
resolveProjectIdentity,
|
|
10
|
+
resolveRepositoryRoot,
|
|
11
|
+
} from '../src/repositoryRoot.js';
|
|
12
|
+
|
|
13
|
+
const OUTER = '11111111-1111-4111-8111-111111111111';
|
|
14
|
+
const INNER = '22222222-2222-4222-8222-222222222222';
|
|
15
|
+
|
|
16
|
+
function git(cwd: string, ...args: string[]): void {
|
|
17
|
+
execFileSync('git', args, {
|
|
18
|
+
cwd,
|
|
19
|
+
stdio: 'ignore',
|
|
20
|
+
env: {
|
|
21
|
+
...process.env,
|
|
22
|
+
GIT_AUTHOR_NAME: 't',
|
|
23
|
+
GIT_AUTHOR_EMAIL: 't@example.com',
|
|
24
|
+
GIT_COMMITTER_NAME: 't',
|
|
25
|
+
GIT_COMMITTER_EMAIL: 't@example.com',
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function identityFile(dir: string, projectId: string, projectName: string): Promise<void> {
|
|
31
|
+
await writeFile(
|
|
32
|
+
join(dir, '.mnemonik.json'),
|
|
33
|
+
JSON.stringify({ schemaVersion: 1, projectId, projectName })
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function repository(): Promise<{ base: string; project: string }> {
|
|
38
|
+
const base = await realpath(await mkdtemp(join(tmpdir(), 'nearest-identity-')));
|
|
39
|
+
const project = join(base, 'project');
|
|
40
|
+
await mkdir(join(project, 'src', 'deep'), { recursive: true });
|
|
41
|
+
git(project, 'init', '-q');
|
|
42
|
+
await identityFile(project, OUTER, 'outer');
|
|
43
|
+
await writeFile(join(project, 'src', 'deep', 'file.txt'), 'x');
|
|
44
|
+
git(project, 'add', '.');
|
|
45
|
+
git(project, 'commit', '-qm', 'init');
|
|
46
|
+
return { base, project };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function projectOf(cwd: string): Promise<{ id?: string; root?: string; kind: string }> {
|
|
50
|
+
const result = await resolveNearestProjectIdentity(cwd);
|
|
51
|
+
return {
|
|
52
|
+
kind: result.kind,
|
|
53
|
+
...(result.kind === 'ok' ? { id: result.identity.projectId, root: result.root } : {}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test('a subfolder of an ordinary git checkout resolves the repository root (common dir is relative to cwd)', async () => {
|
|
58
|
+
const { base, project } = await repository();
|
|
59
|
+
try {
|
|
60
|
+
const sub = join(project, 'src', 'deep');
|
|
61
|
+
const root = await resolveRepositoryRoot(sub);
|
|
62
|
+
assert.equal(root.kind, 'git');
|
|
63
|
+
assert.equal(root.kind === 'git' && root.isLinkedWorktree, false);
|
|
64
|
+
assert.equal(root.kind === 'git' && root.commonDir, join(project, '.git'));
|
|
65
|
+
assert.deepEqual(await projectOf(sub), { kind: 'ok', id: OUTER, root: project });
|
|
66
|
+
} finally {
|
|
67
|
+
await rm(base, { recursive: true, force: true });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('a subfolder with its own identity file is its own project; the nearest file wins', async () => {
|
|
72
|
+
const { base, project } = await repository();
|
|
73
|
+
try {
|
|
74
|
+
const inner = join(project, 'src');
|
|
75
|
+
await identityFile(inner, INNER, 'inner');
|
|
76
|
+
assert.deepEqual(await projectOf(join(inner, 'deep')), { kind: 'ok', id: INNER, root: inner });
|
|
77
|
+
const own = await resolveNearestProjectIdentity(inner);
|
|
78
|
+
assert.equal(own.kind === 'ok' && own.repository.kind, 'plain');
|
|
79
|
+
assert.deepEqual(await projectOf(project), { kind: 'ok', id: OUTER, root: project });
|
|
80
|
+
} finally {
|
|
81
|
+
await rm(base, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a git clone inside a project without its own identity file belongs to that project', async () => {
|
|
86
|
+
const { base, project } = await repository();
|
|
87
|
+
try {
|
|
88
|
+
const clone = join(project, 'vendor', 'upstream');
|
|
89
|
+
await mkdir(join(clone, 'lib'), { recursive: true });
|
|
90
|
+
git(clone, 'init', '-q');
|
|
91
|
+
assert.deepEqual(await projectOf(join(clone, 'lib')), { kind: 'ok', id: OUTER, root: project });
|
|
92
|
+
} finally {
|
|
93
|
+
await rm(base, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('a git clone inside a project with its own identity file is its own project', async () => {
|
|
98
|
+
const { base, project } = await repository();
|
|
99
|
+
try {
|
|
100
|
+
const clone = join(project, 'vendor', 'upstream');
|
|
101
|
+
await mkdir(join(clone, 'lib'), { recursive: true });
|
|
102
|
+
git(clone, 'init', '-q');
|
|
103
|
+
await identityFile(clone, INNER, 'upstream');
|
|
104
|
+
const result = await resolveNearestProjectIdentity(join(clone, 'lib'));
|
|
105
|
+
assert.equal(result.kind, 'ok');
|
|
106
|
+
assert.equal(result.kind === 'ok' && result.identity.projectId, INNER);
|
|
107
|
+
assert.equal(result.kind === 'ok' && result.repository.kind, 'git');
|
|
108
|
+
} finally {
|
|
109
|
+
await rm(base, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('a worktree outside the project folder, without a committed identity file, belongs to its main checkout', async () => {
|
|
114
|
+
const base = await realpath(await mkdtemp(join(tmpdir(), 'nearest-identity-wt-')));
|
|
115
|
+
const project = join(base, 'project');
|
|
116
|
+
try {
|
|
117
|
+
await mkdir(project, { recursive: true });
|
|
118
|
+
git(project, 'init', '-q');
|
|
119
|
+
await writeFile(join(project, 'README'), 'x');
|
|
120
|
+
git(project, 'add', 'README');
|
|
121
|
+
git(project, 'commit', '-qm', 'init');
|
|
122
|
+
// Identity file left uncommitted, so the worktree copy has none of its own.
|
|
123
|
+
await identityFile(project, OUTER, 'outer');
|
|
124
|
+
const worktree = join(base, 'elsewhere', 'any-name');
|
|
125
|
+
git(project, 'worktree', 'add', '-q', worktree);
|
|
126
|
+
await mkdir(join(worktree, 'sub'), { recursive: true });
|
|
127
|
+
assert.deepEqual(await projectOf(join(worktree, 'sub')), {
|
|
128
|
+
kind: 'ok',
|
|
129
|
+
id: OUTER,
|
|
130
|
+
root: project,
|
|
131
|
+
});
|
|
132
|
+
} finally {
|
|
133
|
+
await rm(base, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a folder with no identity file anywhere above it is not a project', async () => {
|
|
138
|
+
const base = await realpath(await mkdtemp(join(tmpdir(), 'nearest-identity-none-')));
|
|
139
|
+
try {
|
|
140
|
+
await mkdir(join(base, 'a', 'b'), { recursive: true });
|
|
141
|
+
git(join(base, 'a'), 'init', '-q');
|
|
142
|
+
assert.equal((await projectOf(join(base, 'a', 'b'))).kind, 'absent');
|
|
143
|
+
assert.equal((await projectOf(base)).kind, 'absent');
|
|
144
|
+
} finally {
|
|
145
|
+
await rm(base, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('the walk stops at the home folder, inside a git repository too', async () => {
|
|
150
|
+
const base = await realpath(await mkdtemp(join(tmpdir(), 'nearest-identity-home-')));
|
|
151
|
+
const savedHome = process.env.HOME;
|
|
152
|
+
try {
|
|
153
|
+
const home = join(base, 'home');
|
|
154
|
+
const repo = join(home, 'code', 'repo');
|
|
155
|
+
await mkdir(join(repo, 'src'), { recursive: true });
|
|
156
|
+
git(repo, 'init', '-q');
|
|
157
|
+
await identityFile(base, OUTER, 'above-home');
|
|
158
|
+
process.env.HOME = home;
|
|
159
|
+
const result = await resolveNearestProjectIdentity(join(repo, 'src'));
|
|
160
|
+
assert.equal(result.kind, 'absent');
|
|
161
|
+
assert.equal(result.kind === 'absent' && result.root, repo);
|
|
162
|
+
// An identity file in the home folder itself is still read.
|
|
163
|
+
await identityFile(home, INNER, 'home');
|
|
164
|
+
assert.deepEqual(await projectOf(join(repo, 'src')), { kind: 'ok', id: INNER, root: home });
|
|
165
|
+
} finally {
|
|
166
|
+
if (savedHome === undefined) delete process.env.HOME;
|
|
167
|
+
else process.env.HOME = savedHome;
|
|
168
|
+
await rm(base, { recursive: true, force: true });
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('works with a git older than 2.31, which has no --path-format', async () => {
|
|
173
|
+
if (process.platform === 'win32') return;
|
|
174
|
+
const { base, project } = await repository();
|
|
175
|
+
const savedPath = process.env.PATH;
|
|
176
|
+
try {
|
|
177
|
+
const realGit = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim();
|
|
178
|
+
const bin = join(base, 'old-git');
|
|
179
|
+
await mkdir(bin);
|
|
180
|
+
// git < 2.31 echoes an unknown rev-parse option back as an answer line.
|
|
181
|
+
await writeFile(
|
|
182
|
+
join(bin, 'git'),
|
|
183
|
+
`#!/bin/sh\ncase "$*" in *--path-format*) echo "--path-format=absolute";; esac\nexec "${realGit}" "$@"\n`
|
|
184
|
+
);
|
|
185
|
+
await chmod(join(bin, 'git'), 0o755);
|
|
186
|
+
const worktree = join(base, 'elsewhere', 'feature');
|
|
187
|
+
git(project, 'worktree', 'add', '-q', worktree);
|
|
188
|
+
const inner = join(project, 'src');
|
|
189
|
+
await identityFile(inner, INNER, 'inner');
|
|
190
|
+
process.env.PATH = `${bin}:${savedPath ?? ''}`;
|
|
191
|
+
|
|
192
|
+
const sub = await resolveRepositoryRoot(join(project, 'src', 'deep'));
|
|
193
|
+
assert.equal(sub.kind, 'git');
|
|
194
|
+
assert.equal(sub.kind === 'git' && sub.root, project);
|
|
195
|
+
assert.equal(sub.kind === 'git' && sub.commonDir, join(project, '.git'));
|
|
196
|
+
assert.equal(sub.kind === 'git' && sub.isLinkedWorktree, false);
|
|
197
|
+
const linked = await resolveRepositoryRoot(join(worktree, 'src'));
|
|
198
|
+
assert.equal(linked.kind === 'git' && linked.isLinkedWorktree, true);
|
|
199
|
+
assert.equal(linked.kind === 'git' && linked.commonDir, join(project, '.git'));
|
|
200
|
+
assert.deepEqual(await projectOf(join(inner, 'deep')), { kind: 'ok', id: INNER, root: inner });
|
|
201
|
+
assert.deepEqual(await projectOf(join(worktree, 'src')), {
|
|
202
|
+
kind: 'ok',
|
|
203
|
+
id: OUTER,
|
|
204
|
+
root: project,
|
|
205
|
+
});
|
|
206
|
+
} finally {
|
|
207
|
+
process.env.PATH = savedPath;
|
|
208
|
+
await rm(base, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('a named folder is read exactly, even inside another project; a worktree top names its main checkout', async () => {
|
|
213
|
+
const { base, project } = await repository();
|
|
214
|
+
try {
|
|
215
|
+
const sub = join(project, 'src');
|
|
216
|
+
const named = await resolveProjectIdentity(sub, { namedRoot: true });
|
|
217
|
+
assert.equal(named.kind, 'absent');
|
|
218
|
+
assert.equal(named.kind === 'absent' && named.root, sub);
|
|
219
|
+
assert.equal(named.kind === 'absent' && named.repository.kind, 'plain');
|
|
220
|
+
// The default read is the rule: the subfolder is in the project around it.
|
|
221
|
+
assert.deepEqual(await projectOf(sub), { kind: 'ok', id: OUTER, root: project });
|
|
222
|
+
|
|
223
|
+
const worktree = join(base, 'elsewhere', 'feature');
|
|
224
|
+
git(project, 'worktree', 'add', '-q', worktree);
|
|
225
|
+
const top = await resolveProjectIdentity(worktree, { namedRoot: true });
|
|
226
|
+
assert.equal(top.kind, 'ok');
|
|
227
|
+
assert.equal(top.kind === 'ok' && top.root, project);
|
|
228
|
+
assert.equal(
|
|
229
|
+
top.kind === 'ok' && top.repository.kind === 'git' && top.repository.isLinkedWorktree,
|
|
230
|
+
false
|
|
231
|
+
);
|
|
232
|
+
const inside = await resolveProjectIdentity(join(worktree, 'src'), { namedRoot: true });
|
|
233
|
+
assert.equal(inside.kind, 'absent');
|
|
234
|
+
assert.equal(inside.kind === 'absent' && inside.root, join(worktree, 'src'));
|
|
235
|
+
} finally {
|
|
236
|
+
await rm(base, { recursive: true, force: true });
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('a configuration-only identity file in a subfolder names no project: the walk passes it', async () => {
|
|
241
|
+
const { base, project } = await repository();
|
|
242
|
+
try {
|
|
243
|
+
const sub = join(project, 'tools');
|
|
244
|
+
await mkdir(join(sub, 'lib'), { recursive: true });
|
|
245
|
+
await writeFile(join(sub, '.mnemonik.json'), JSON.stringify({ schemaVersion: 1 }));
|
|
246
|
+
assert.deepEqual(await projectOf(join(sub, 'lib')), { kind: 'ok', id: OUTER, root: project });
|
|
247
|
+
// An unreadable file still answers as itself.
|
|
248
|
+
await writeFile(join(sub, '.mnemonik.json'), '{ not json');
|
|
249
|
+
assert.equal((await projectOf(join(sub, 'lib'))).kind, 'malformed');
|
|
250
|
+
} finally {
|
|
251
|
+
await rm(base, { recursive: true, force: true });
|
|
252
|
+
}
|
|
253
|
+
});
|