@mnemonik/shared 7.140.1 → 7.140.2

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 (40) hide show
  1. package/dist/codeScanner.d.ts +41 -1
  2. package/dist/codeScanner.d.ts.map +1 -1
  3. package/dist/codeScanner.js +90 -12
  4. package/dist/codeScanner.js.map +1 -1
  5. package/dist/hookRuntime.d.ts +9 -8
  6. package/dist/hookRuntime.d.ts.map +1 -1
  7. package/dist/hookRuntime.js +21 -10
  8. package/dist/hookRuntime.js.map +1 -1
  9. package/dist/hookTrace.d.ts +149 -0
  10. package/dist/hookTrace.d.ts.map +1 -0
  11. package/dist/hookTrace.js +483 -0
  12. package/dist/hookTrace.js.map +1 -0
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/projectIdentityFile.d.ts +13 -0
  18. package/dist/projectIdentityFile.d.ts.map +1 -1
  19. package/dist/projectIdentityFile.js +31 -18
  20. package/dist/projectIdentityFile.js.map +1 -1
  21. package/dist/protectedPaths.d.ts +6 -0
  22. package/dist/protectedPaths.d.ts.map +1 -1
  23. package/dist/protectedPaths.js +15 -6
  24. package/dist/protectedPaths.js.map +1 -1
  25. package/dist/repositoryRoot.d.ts +20 -8
  26. package/dist/repositoryRoot.d.ts.map +1 -1
  27. package/dist/repositoryRoot.js +167 -79
  28. package/dist/repositoryRoot.js.map +1 -1
  29. package/package.json +2 -2
  30. package/src/codeScanner.ts +107 -9
  31. package/src/hookRuntime.ts +26 -14
  32. package/src/hookTrace.ts +588 -0
  33. package/src/index.ts +4 -0
  34. package/src/projectIdentityFile.ts +34 -20
  35. package/src/protectedPaths.ts +18 -7
  36. package/src/repositoryRoot.ts +188 -82
  37. package/test-fixtures/identity/runner.d.mts +1 -1
  38. package/test-fixtures/identity/runner.mjs +299 -25
  39. package/tests/hookTrace.test.ts +350 -0
  40. package/tests/nearestProjectIdentity.test.ts +253 -0
@@ -1,4 +1,4 @@
1
- import { lstat, readFile } from 'node:fs/promises';
1
+ import { lstat, readFile, realpath } from 'node:fs/promises';
2
2
  import { execFile } from 'node:child_process';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join, resolve } from 'node:path';
@@ -33,12 +33,6 @@ export type ProjectIdentityResolution =
33
33
  | (ResolvedBase & { kind: 'absent' })
34
34
  | (ResolvedBase & { kind: 'unknown_version'; version: unknown; path: string })
35
35
  | (ResolvedBase & { kind: 'malformed'; detail: string; path: string })
36
- | (ResolvedBase & { kind: 'nested'; parentIdentity?: ProjectIdentityFile })
37
- | (ResolvedBase & {
38
- kind: 'conflict';
39
- rootIdentity: ProjectIdentityFile;
40
- nestedIdentity: ProjectIdentityFile;
41
- })
42
36
  | { kind: 'git_unavailable'; detail: string };
43
37
 
44
38
  const GIT_TIMEOUT_MS = 2_000;
@@ -102,7 +96,7 @@ async function confirmedBoundaryAt(path: string): Promise<RepositoryBoundary | u
102
96
  async function boundariesBetween(cwd: string, root: string): Promise<RepositoryBoundary[]> {
103
97
  const boundaries: RepositoryBoundary[] = [];
104
98
  let directory = cwd;
105
- while (directory !== root) {
99
+ while (!samePath(directory, root)) {
106
100
  const boundary = await confirmedBoundaryAt(directory);
107
101
  if (boundary) boundaries.push(boundary);
108
102
  const parent = dirname(directory);
@@ -123,37 +117,45 @@ async function containingRepository(root: string): Promise<RepositoryBoundary |
123
117
  }
124
118
  }
125
119
 
126
- async function canonicalBoundaryRoot(boundary: RepositoryBoundary): Promise<string> {
127
- if (boundary.kind === 'directory') return boundary.path;
128
- const pointer = /^gitdir:\s*(.+)\s*$/im.exec(
129
- await readFile(join(boundary.path, '.git'), 'utf8')
130
- )?.[1];
131
- if (!pointer) return boundary.path;
132
- const gitDir = resolve(boundary.path, pointer);
133
- try {
134
- const commonDir = resolve(gitDir, (await readFile(join(gitDir, 'commondir'), 'utf8')).trim());
135
- return dirname(commonDir);
136
- } catch (error) {
137
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') return boundary.path;
138
- throw error;
139
- }
120
+ /**
121
+ * A path as the file system knows it, symlinks resolved, so it compares equal
122
+ * with the real paths of git's answers. A path that does not exist stays as given.
123
+ */
124
+ async function canonicalPath(path: string): Promise<string> {
125
+ const absolute = resolve(path);
126
+ return realpath(absolute).catch(() => absolute);
127
+ }
128
+
129
+ /** Path equality; Windows paths compare without regard to case. */
130
+ function samePath(left: string, right: string): boolean {
131
+ return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
140
132
  }
141
133
 
142
134
  export async function resolveRepositoryRoot(cwd: string): Promise<RepositoryRootResult> {
143
- const absoluteCwd = resolve(cwd);
135
+ const absoluteCwd = await canonicalPath(cwd);
144
136
  try {
145
137
  const { stdout } = await runGit(absoluteCwd, [
146
138
  'rev-parse',
147
139
  '--show-toplevel',
140
+ '--git-dir',
148
141
  '--git-common-dir',
149
142
  ]);
150
- const [rootLine, commonLine, ...extra] = stdout.trim().split(/\r?\n/);
151
- if (!rootLine || !commonLine || extra.length) {
143
+ const [rootLine, gitDirLine, commonLine, ...extra] = stdout.trim().split(/\r?\n/);
144
+ if (!rootLine || !gitDirLine || !commonLine || extra.length) {
152
145
  return { kind: 'git_unavailable', detail: 'git rev-parse returned an unexpected response' };
153
146
  }
154
- const root = resolve(rootLine);
155
- const commonDir = resolve(root, commonLine);
156
- const isLinkedWorktree = commonDir !== join(root, '.git');
147
+ // git prints --git-dir and --git-common-dir relative to the directory it
148
+ // ran in (`../.git` from a subfolder) or absolute; every git version
149
+ // agrees on that. Real paths make them comparable with each other and
150
+ // with real paths from the file system.
151
+ const fromGit = (line: string) => canonicalPath(resolve(absoluteCwd, line));
152
+ const root = await fromGit(rootLine);
153
+ const gitDir = await fromGit(gitDirLine);
154
+ const commonDir = await fromGit(commonLine);
155
+ // Only a linked worktree keeps its own git directory apart from the common
156
+ // one. A submodule (`.git/modules/<name>`) and a `--separate-git-dir`
157
+ // checkout keep both in one place, wherever that is.
158
+ const isLinkedWorktree = !samePath(gitDir, commonDir);
157
159
  const nested = await boundariesBetween(absoluteCwd, root);
158
160
  if (!isLinkedWorktree && (await containingRepository(root))) {
159
161
  const boundary = await boundaryAt(root);
@@ -175,16 +177,56 @@ export async function resolveRepositoryRoot(cwd: string): Promise<RepositoryRoot
175
177
  }
176
178
  }
177
179
 
178
- async function readPlainIdentity(start: string): Promise<{
179
- root: string;
180
- result: Awaited<ReturnType<typeof readIdentityFile>>;
181
- }> {
182
- const home = resolve(homedir());
180
+ /**
181
+ * The common git directory when `dir` is the top of a linked worktree: its
182
+ * `.git` is a file pointing at a git directory that has a `commondir`. A
183
+ * submodule's `.git` file points at `.git/modules/<name>`, which has none.
184
+ * Read from the files git writes, so it holds without a git command.
185
+ */
186
+ async function linkedWorktreeCommonDir(dir: string): Promise<string | undefined> {
187
+ try {
188
+ if (!(await lstat(join(dir, '.git'))).isFile()) return undefined;
189
+ const pointer = /^gitdir:\s*(.+?)\s*$/im.exec(await readFile(join(dir, '.git'), 'utf8'))?.[1];
190
+ if (!pointer) return undefined;
191
+ const gitDir = resolve(dir, pointer);
192
+ const common = (await readFile(join(gitDir, 'commondir'), 'utf8')).trim();
193
+ return common ? await canonicalPath(resolve(gitDir, common)) : undefined;
194
+ } catch {
195
+ return undefined;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * The project folder of a linked worktree's main checkout: the first entry of
201
+ * `git worktree list --porcelain`. When that entry is a bare repository
202
+ * (`proj/.bare` with worktrees beside it), the project folder is the folder
203
+ * holding it, never the git directory itself. Without git, the folder holding
204
+ * the common git directory, which is the same answer for both layouts.
205
+ */
206
+ async function mainCheckout(worktreeTop: string, commonDir: string): Promise<string> {
207
+ try {
208
+ const { stdout } = await runGit(worktreeTop, ['worktree', 'list', '--porcelain']);
209
+ const first = stdout.split(/\r?\n\r?\n/)[0] ?? '';
210
+ const path = /^worktree (.+)$/m.exec(first)?.[1];
211
+ if (path) {
212
+ const main = await canonicalPath(resolve(worktreeTop, path));
213
+ return /^bare$/m.test(first) ? dirname(main) : main;
214
+ }
215
+ } catch {
216
+ /* fall through */
217
+ }
218
+ return dirname(commonDir);
219
+ }
220
+
221
+ type FoundIdentity = { root: string; result: Awaited<ReturnType<typeof readIdentityFile>> };
222
+
223
+ async function readPlainIdentity(start: string): Promise<FoundIdentity> {
224
+ const home = await canonicalPath(homedir());
183
225
  let directory = start;
184
226
  while (true) {
185
- const result = await readIdentityFile(directory);
227
+ const result = await readIdentityFile(directory, { configurationOnlyAbsent: true });
186
228
  if (result.kind !== 'absent') return { root: directory, result };
187
- if (directory === home) break;
229
+ if (samePath(directory, home)) break;
188
230
  const parent = dirname(directory);
189
231
  if (parent === directory) break;
190
232
  directory = parent;
@@ -192,6 +234,105 @@ async function readPlainIdentity(start: string): Promise<{
192
234
  return { root: start, result: { kind: 'absent' } };
193
235
  }
194
236
 
237
+ /**
238
+ * Walk up from `start` to the nearest identity file, stopping at the home
239
+ * folder (whose own file still counts). A configuration-only file (no
240
+ * `projectId`) is not an identity and the walk passes it. Reaching the top of a linked worktree
241
+ * first ends the walk there: the worktree belongs to its main checkout, and
242
+ * the copy of the identity file at its top does not count.
243
+ */
244
+ async function nearestIdentity(
245
+ start: string
246
+ ): Promise<FoundIdentity | { worktreeTop: string; commonDir: string } | undefined> {
247
+ const home = await canonicalPath(homedir());
248
+ let directory = start;
249
+ while (true) {
250
+ const commonDir = await linkedWorktreeCommonDir(directory);
251
+ if (commonDir) return { worktreeTop: directory, commonDir };
252
+ const result = await readIdentityFile(directory, { configurationOnlyAbsent: true });
253
+ if (result.kind !== 'absent') return { root: directory, result };
254
+ if (samePath(directory, home)) return undefined;
255
+ const parent = dirname(directory);
256
+ if (parent === directory) return undefined;
257
+ directory = parent;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * The project a working folder belongs to: the nearest `.mnemonik.json` above
263
+ * it. Git repositories are not project boundaries; a clone, submodule,
264
+ * subsystem or subproject inside a project belongs to that project unless it
265
+ * has its own identity file. Git has one part in identity: a linked worktree
266
+ * (and anything inside it without an identity file of its own below the
267
+ * worktree's top, nested clones and submodules included) belongs to the
268
+ * project of its main checkout; the worktree's own top-level copy of the
269
+ * identity file does not count.
270
+ */
271
+ export async function resolveNearestProjectIdentity(
272
+ cwd: string
273
+ ): Promise<ProjectIdentityResolution> {
274
+ const start = await canonicalPath(cwd);
275
+ const resolved = await resolveRepositoryRoot(start);
276
+ const repository: ResolvedRepository =
277
+ resolved.kind === 'git_unavailable' ? { kind: 'plain', root: start } : resolved;
278
+ const nested = repository.kind === 'git' ? repository.nested : [];
279
+
280
+ let found = await nearestIdentity(start);
281
+ if (found && 'worktreeTop' in found) {
282
+ found = await readPlainIdentity(await mainCheckout(found.worktreeTop, found.commonDir));
283
+ }
284
+ if (found) {
285
+ return shapedIdentity(found, await projectFolderRepository(found.root, repository), nested);
286
+ }
287
+ return { ...withBase(repository, repository.root, nested), kind: 'absent' };
288
+ }
289
+
290
+ /**
291
+ * The identity of exactly the folder named, for commands that create or link
292
+ * a project there on purpose, even inside another project. The top of a
293
+ * linked worktree stands for its main checkout.
294
+ */
295
+ async function resolveNamedProjectIdentity(
296
+ cwd: string,
297
+ options: { selectedRoot?: boolean }
298
+ ): Promise<ProjectIdentityResolution> {
299
+ const start = await canonicalPath(cwd);
300
+ const resolved = await resolveRepositoryRoot(start);
301
+ const repository: ResolvedRepository =
302
+ resolved.kind === 'git_unavailable' ? { kind: 'plain', root: start } : resolved;
303
+ const commonDir = await linkedWorktreeCommonDir(start);
304
+ const root = commonDir ? await mainCheckout(start, commonDir) : start;
305
+ return shapedIdentity(
306
+ { root, result: await readIdentityFile(root, options) },
307
+ await projectFolderRepository(root, repository),
308
+ repository.kind === 'git' ? repository.nested : []
309
+ );
310
+ }
311
+
312
+ function shapedIdentity(
313
+ found: FoundIdentity,
314
+ repository: ResolvedRepository,
315
+ nested: RepositoryBoundary[]
316
+ ): ProjectIdentityResolution {
317
+ const base = withBase(repository, found.root, nested);
318
+ if (found.result.kind === 'ok') return { ...base, ...found.result };
319
+ if (found.result.kind === 'unknown_version')
320
+ return { ...base, ...found.result, path: found.root };
321
+ if (found.result.kind === 'malformed') return { ...base, ...found.result, path: found.root };
322
+ return { ...base, kind: 'absent' };
323
+ }
324
+
325
+ /** The project folder counts as a git repository only when it is the repository's top. */
326
+ async function projectFolderRepository(
327
+ projectRoot: string,
328
+ known: ResolvedRepository
329
+ ): Promise<ResolvedRepository> {
330
+ if (known.kind === 'git' && samePath(known.root, projectRoot)) return known;
331
+ const repository = await resolveRepositoryRoot(projectRoot);
332
+ if (repository.kind === 'git' && samePath(repository.root, projectRoot)) return repository;
333
+ return { kind: 'plain', root: projectRoot };
334
+ }
335
+
195
336
  function withBase(
196
337
  repository: ResolvedRepository,
197
338
  root: string,
@@ -200,55 +341,20 @@ function withBase(
200
341
  return { root, repository, nested };
201
342
  }
202
343
 
344
+ /**
345
+ * Which project a folder is in. By default this is the rule every reader
346
+ * shares with the hooks (resolveNearestProjectIdentity). `namedRoot` and
347
+ * `selectedRoot` read exactly the folder given instead, for commands that make
348
+ * that folder a project on purpose; `selectedRoot` (folders ticked during
349
+ * install) also treats a configuration-only file as no identity, as the
350
+ * nearest-file walk always does.
351
+ */
203
352
  export async function resolveProjectIdentity(
204
353
  cwd: string,
205
- options: { allowNestedInherit?: boolean; selectedRoot?: boolean } = {}
354
+ options: { selectedRoot?: boolean; namedRoot?: boolean } = {}
206
355
  ): Promise<ProjectIdentityResolution> {
207
- const resolved = await resolveRepositoryRoot(cwd);
208
- // Git is optional. When the git command cannot answer, the folder is still a folder.
209
- const repository: ResolvedRepository =
210
- resolved.kind === 'git_unavailable' ? { kind: 'plain', root: resolve(cwd) } : resolved;
211
-
212
- if (repository.kind === 'plain') {
213
- const { root, result } = options.selectedRoot
214
- ? { root: repository.root, result: await readIdentityFile(repository.root, options) }
215
- : await readPlainIdentity(repository.root);
216
- const base = withBase(repository, root, []);
217
- if (result.kind === 'ok') return { ...base, ...result };
218
- if (result.kind === 'unknown_version') return { ...base, ...result, path: root };
219
- if (result.kind === 'malformed') return { ...base, ...result, path: root };
220
- return { ...base, kind: 'absent' };
221
- }
222
-
223
- const ownRoot = repository.isLinkedWorktree ? dirname(repository.commonDir) : repository.root;
224
- const outer = repository.isLinkedWorktree
225
- ? undefined
226
- : await containingRepository(repository.root);
227
- const own = await readIdentityFile(ownRoot, options);
228
- if (!outer || options.selectedRoot || own.kind === 'ok') {
229
- const base = withBase(repository, ownRoot, repository.nested);
230
- if (own.kind === 'ok') return { ...base, ...own };
231
- if (own.kind === 'unknown_version') return { ...base, ...own, path: ownRoot };
232
- if (own.kind === 'malformed') return { ...base, ...own, path: ownRoot };
233
- return { ...base, kind: 'absent' };
356
+ if (options.selectedRoot || options.namedRoot) {
357
+ return resolveNamedProjectIdentity(cwd, { selectedRoot: options.selectedRoot });
234
358
  }
235
-
236
- const nested = repository.nested;
237
- const parentRoot = await canonicalBoundaryRoot(outer);
238
- const parent = await readIdentityFile(parentRoot);
239
- const base = withBase(repository, parentRoot, nested);
240
-
241
- if (own.kind === 'unknown_version') return { ...base, ...own, path: ownRoot };
242
- if (own.kind === 'malformed') return { ...base, ...own, path: ownRoot };
243
- if (parent.kind === 'unknown_version') return { ...base, ...parent, path: parentRoot };
244
- if (parent.kind === 'malformed') return { ...base, ...parent, path: parentRoot };
245
- if (!options.allowNestedInherit) {
246
- return {
247
- ...base,
248
- kind: 'nested',
249
- ...(parent.kind === 'ok' ? { parentIdentity: parent.identity } : {}),
250
- };
251
- }
252
- if (parent.kind === 'ok') return { ...base, kind: 'ok', identity: parent.identity };
253
- return { ...base, kind: 'absent' };
359
+ return resolveNearestProjectIdentity(cwd);
254
360
  }
@@ -2,7 +2,7 @@ export interface IdentityFixtureAdapter {
2
2
  parse(text: string): unknown;
3
3
  read(dir: string): Promise<unknown>;
4
4
  resolveRoot(cwd: string): Promise<unknown>;
5
- resolve(cwd: string, options?: { allowNestedInherit?: boolean }): Promise<unknown>;
5
+ resolve(cwd: string, options?: { namedRoot?: boolean }): Promise<unknown>;
6
6
  find?(cwd: string): Promise<unknown>;
7
7
  }
8
8