@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
@@ -2,7 +2,7 @@
2
2
  * Code Scanner - Parse and chunk source files for embedding
3
3
  */
4
4
 
5
- import { readdir, open, lstat, realpath } from 'fs/promises';
5
+ import { readdir, readFile, open, lstat, realpath } from 'fs/promises';
6
6
  import { constants, type Stats } from 'fs';
7
7
  import { join, relative, extname, sep, resolve, dirname } from 'path';
8
8
  import { createHash } from 'crypto';
@@ -11,6 +11,7 @@ import { debug as logDebug, info as logInfo, warn as logWarn } from './logger.js
11
11
  import { withTimeout } from './asyncUtils.js';
12
12
  import { scrubSecrets } from './secretPatterns.js';
13
13
  import { isProtectedLocalPath, protectedLocalPaths } from './protectedPaths.js';
14
+ import { readIdentityFile } from './projectIdentityFile.js';
14
15
  import {
15
16
  MAX_AST_PARSE_BYTES,
16
17
  MAX_SIGNATURE_CHARS,
@@ -219,8 +220,8 @@ export const BUILT_IN_IGNORE_DIRS: readonly string[] = [
219
220
  * at the path root or immediately after a '/', so nested packages/x/tests/
220
221
  * fixtures/ are excluded but integration-tests/fixtures/ is NOT. Fixture
221
222
  * snapshot dirs were being indexed as real source and outranking the genuine
222
- * file in code_search. (Nested clones/worktrees/submodules are excluded
223
- * generically by the isGitBoundary rule below, not a path convention.)
223
+ * file in code_search. (Subprojects and linked worktrees are excluded
224
+ * generically by the isProjectBoundary rule below, not a path convention.)
224
225
  */
225
226
  const BUILT_IN_IGNORE_FILE_PATTERNS: readonly string[] = [
226
227
  // NOTE: the `.env` family and every other secret-bearing FILE KIND lives in
@@ -699,6 +700,85 @@ export async function isGitBoundary(dirPath: string): Promise<boolean> {
699
700
  }
700
701
  }
701
702
 
703
+ /**
704
+ * How many folders deep a project's scan walks. Discovery searches a project
705
+ * for subprojects to the same depth, so a subproject the scan leaves out is
706
+ * always found and scanned as its own project.
707
+ */
708
+ export const SCANNER_MAX_DEPTH = 10;
709
+
710
+ /**
711
+ * True when `dirPath` starts a subtree that is not this project's to index.
712
+ * A file belongs to the project of the nearest `.mnemonik.json` above it, so:
713
+ * - a folder with its own identity file is a subproject, indexed as its own
714
+ * project, never also into this one. A file naming the enclosing project
715
+ * (`enclosingProjectId`, a copy) is the same project, and a
716
+ * configuration-only file (no `projectId`) names none: neither is a
717
+ * boundary. A file that cannot be read as an identity still is;
718
+ * - a linked worktree (a `.git` FILE whose git directory has a `commondir`)
719
+ * is another checkout of a project, indexed from its main checkout.
720
+ * A nested clone or a submodule without its own `.mnemonik.json` belongs to
721
+ * this project and is indexed with it (its `.git` directory is ignored by the
722
+ * built-in rules). The scan ROOT is exempt by construction (callers only test
723
+ * child directories).
724
+ */
725
+ export async function isProjectBoundary(
726
+ dirPath: string,
727
+ enclosingProjectId?: string
728
+ ): Promise<boolean> {
729
+ if (await lstatWithin(join(dirPath, '.mnemonik.json'))) {
730
+ const identity = await withTimeout(
731
+ readIdentityFile(dirPath, { configurationOnlyAbsent: true }),
732
+ FILE_OP_TIMEOUT_MS,
733
+ `identity read timed out: ${dirPath}`
734
+ ).catch(() => undefined);
735
+ if (!identity) return true;
736
+ if (identity.kind === 'ok') {
737
+ if (identity.identity.projectId !== enclosingProjectId) return true;
738
+ } else if (identity.kind !== 'absent') return true;
739
+ }
740
+ return isLinkedWorktreeCheckout(dirPath);
741
+ }
742
+
743
+ /**
744
+ * The project a scan root's own identity file names, or undefined: the
745
+ * enclosing project for isProjectBoundary.
746
+ */
747
+ export async function rootProjectId(rootPath: string): Promise<string | undefined> {
748
+ const identity = await withTimeout(
749
+ readIdentityFile(rootPath),
750
+ FILE_OP_TIMEOUT_MS,
751
+ `identity read timed out: ${rootPath}`
752
+ ).catch(() => undefined);
753
+ return identity?.kind === 'ok' ? identity.identity.projectId : undefined;
754
+ }
755
+
756
+ const lstatWithin = (path: string) =>
757
+ withTimeout(lstat(path), FILE_OP_TIMEOUT_MS, `lstat timed out: ${path}`).catch(() => undefined);
758
+
759
+ /**
760
+ * True when `dirPath` is the top of a linked git worktree: its `.git` is a
761
+ * FILE pointing at a git directory that has a `commondir`. A submodule's
762
+ * `.git` file points at `.git/modules/<name>`, which has none.
763
+ */
764
+ export async function isLinkedWorktreeCheckout(dirPath: string): Promise<boolean> {
765
+ const marker = await lstatWithin(join(dirPath, '.git'));
766
+ if (!marker?.isFile()) return false;
767
+ try {
768
+ const pointer = /^gitdir:\s*(.+?)\s*$/im.exec(
769
+ await withTimeout(
770
+ readFile(join(dirPath, '.git'), 'utf8'),
771
+ FILE_OP_TIMEOUT_MS,
772
+ `read timed out: ${join(dirPath, '.git')}`
773
+ )
774
+ )?.[1];
775
+ if (!pointer) return false;
776
+ return !!(await lstatWithin(join(resolve(dirPath, pointer), 'commondir')));
777
+ } catch {
778
+ return false;
779
+ }
780
+ }
781
+
702
782
  /**
703
783
  * One directory's compiled ignore-file rules, keyed by its path relative to
704
784
  * the scan root (`''` = root). A stack of these, root->leaf, is threaded
@@ -1027,7 +1107,7 @@ export class CodeScanner {
1027
1107
 
1028
1108
  /**
1029
1109
  * True when any directory strictly between `rootPath` and `filePath`
1030
- * carries its own `.git` entry (see isGitBoundary). Paths that don't
1110
+ * starts a subtree this project does not index (see isProjectBoundary). Paths that don't
1031
1111
  * resolve under `rootPath` are not judged here (return false) - the
1032
1112
  * server's path validation handles escapes.
1033
1113
  */
@@ -1041,7 +1121,7 @@ export class CodeScanner {
1041
1121
  const segment = segments[i];
1042
1122
  if (segment === undefined) continue;
1043
1123
  current = join(current, segment);
1044
- if (await isGitBoundary(current)) return true;
1124
+ if (await this.isBoundary(current, rootPath)) return true;
1045
1125
  }
1046
1126
  return false;
1047
1127
  }
@@ -1050,7 +1130,25 @@ export class CodeScanner {
1050
1130
  * Maximum directory depth for recursive scanning
1051
1131
  * Prevents runaway recursion on deep/symlinked structures
1052
1132
  */
1053
- private static readonly MAX_DEPTH = 10;
1133
+ private static readonly MAX_DEPTH = SCANNER_MAX_DEPTH;
1134
+
1135
+ private rootProjectIds = new Map<
1136
+ string,
1137
+ { at: number; projectId: Promise<string | undefined> }
1138
+ >();
1139
+ /**
1140
+ * isProjectBoundary, with the scan root's own project as the enclosing one.
1141
+ * The root's file is read at most once a minute: a walk tests many folders,
1142
+ * and a project relinked since is seen on the next scan.
1143
+ */
1144
+ private async isBoundary(dirPath: string, rootPath: string): Promise<boolean> {
1145
+ let cached = this.rootProjectIds.get(rootPath);
1146
+ if (!cached || Date.now() - cached.at > 60_000) {
1147
+ cached = { at: Date.now(), projectId: rootProjectId(rootPath) };
1148
+ this.rootProjectIds.set(rootPath, cached);
1149
+ }
1150
+ return isProjectBoundary(dirPath, await cached.projectId);
1151
+ }
1054
1152
 
1055
1153
  private canonicalRoots = new Map<string, string>();
1056
1154
  private async canonicalRoot(root: string): Promise<string> {
@@ -1285,7 +1383,7 @@ export class CodeScanner {
1285
1383
  if (stats.isDirectory()) {
1286
1384
  // .gitignore/.mnemonikignore, then soft-default directories
1287
1385
  if (this.skipDirectory(relativePath, localStack)) continue;
1288
- if (await isGitBoundary(fullPath)) continue; // another repo's subtree
1386
+ if (await this.isBoundary(fullPath, rootPath)) continue; // a subproject or worktree
1289
1387
  await this.traversePaths(
1290
1388
  fullPath,
1291
1389
  rootPath,
@@ -1500,7 +1598,7 @@ export class CodeScanner {
1500
1598
  if (stats.isDirectory()) {
1501
1599
  // .gitignore/.mnemonikignore, then soft-default directories
1502
1600
  if (this.skipDirectory(relativePath, localStack)) continue;
1503
- if (await isGitBoundary(fullPath)) continue; // another repo's subtree
1601
+ if (await this.isBoundary(fullPath, rootPath)) continue; // a subproject or worktree
1504
1602
  await this.traverseDirectory(
1505
1603
  fullPath,
1506
1604
  rootPath,
@@ -2423,7 +2521,7 @@ export class CodeScanner {
2423
2521
 
2424
2522
  if (this.ignoredByStack(rel, isDir, localStack)) continue; // .gitignore/.mnemonikignore
2425
2523
  if (isDir) {
2426
- if (await isGitBoundary(full)) continue; // another repo's subtree
2524
+ if (await this.isBoundary(full, projectRoot)) continue; // a subproject or worktree
2427
2525
  await walk(full, depth + 1, localStack);
2428
2526
  } else if (isFile && AUTHORITY_FILE_MATCHERS.some((m) => m(rel))) {
2429
2527
  let file: { path: string; content: string; hash: string } | undefined;
@@ -1,7 +1,20 @@
1
1
  import { protectWindowsDirectory, type Execute } from './runtimeSigners.js';
2
2
  import { stderr, stdin } from 'node:process';
3
3
  import { HOOK_STDIN_TIMEOUT_MS } from './hookTimeouts.js';
4
- import { resolveProjectIdentity, type ProjectIdentityResolution } from './repositoryRoot.js';
4
+ import { resolveNearestProjectIdentity, type ProjectIdentityResolution } from './repositoryRoot.js';
5
+ import { noteHookTraceProject } from './hookTrace.js';
6
+ import { homedir } from 'node:os';
7
+ import { join, resolve } from 'node:path';
8
+
9
+ /**
10
+ * A cwd the model typed, as an absolute folder: a leading `~` is the home
11
+ * folder, and a relative path is taken from the host's cwd.
12
+ */
13
+ export function modelTypedCwd(hostCwd: string, typed: string): string {
14
+ const expanded =
15
+ typed === '~' ? homedir() : /^~[\\/]/.test(typed) ? join(homedir(), typed.slice(2)) : typed;
16
+ return resolve(hostCwd, expanded);
17
+ }
5
18
 
6
19
  export interface ProjectIdentity {
7
20
  projectId: string;
@@ -24,19 +37,12 @@ export function clearProjectIdentityCache(): void {
24
37
  identityCache.clear();
25
38
  }
26
39
 
27
- export async function findProjectIdentity(
28
- startCwd: string,
29
- options: { allowNestedInherit?: boolean; maxDepth?: number } = {}
30
- ): Promise<ProjectIdentity | null> {
40
+ export async function findProjectIdentity(startCwd: string): Promise<ProjectIdentity | null> {
31
41
  if (!startCwd) return null;
32
- const key = JSON.stringify([
33
- startCwd,
34
- options.allowNestedInherit ?? null,
35
- options.maxDepth ?? null,
36
- ]);
42
+ const key = startCwd;
37
43
  let pending = identityCache.get(key);
38
44
  if (!pending) {
39
- pending = findProjectIdentityDetailed(startCwd, options).then((result) =>
45
+ pending = findProjectIdentityDetailed(startCwd).then((result) =>
40
46
  result.kind === 'ok'
41
47
  ? {
42
48
  projectId: result.identity.projectId,
@@ -48,15 +54,20 @@ export async function findProjectIdentity(
48
54
  identityCache.set(key, pending);
49
55
  // A failure is not an answer: the next caller resolves afresh.
50
56
  pending.catch(() => identityCache.delete(key));
57
+ // The hook flight recorder notes the project this invocation resolved.
58
+ pending.then(
59
+ (found) => noteHookTraceProject(found?.projectId),
60
+ () => undefined
61
+ );
51
62
  }
52
63
  return pending;
53
64
  }
54
65
 
66
+ /** The project a hook's working folder belongs to: the nearest `.mnemonik.json` above it. */
55
67
  export async function findProjectIdentityDetailed(
56
- startCwd: string,
57
- options: { allowNestedInherit?: boolean; maxDepth?: number } = {}
68
+ startCwd: string
58
69
  ): Promise<ProjectIdentityResolution> {
59
- return resolveProjectIdentity(startCwd, options);
70
+ return resolveNearestProjectIdentity(startCwd);
60
71
  }
61
72
 
62
73
  /** Largest hook payload read from stdin; a bigger one fails open. */
@@ -283,3 +294,4 @@ export * from './runtimeSigners.js';
283
294
  export * from './projectSetupHandoff.js';
284
295
 
285
296
  export * from './hookConversation.js';
297
+ export * from './hookTrace.js';