@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.
- package/dist/codeScanner.d.ts +41 -1
- package/dist/codeScanner.d.ts.map +1 -1
- package/dist/codeScanner.js +90 -12
- package/dist/codeScanner.js.map +1 -1
- package/dist/hookRuntime.d.ts +9 -8
- package/dist/hookRuntime.d.ts.map +1 -1
- package/dist/hookRuntime.js +21 -10
- 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/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/package.json +2 -2
- package/src/codeScanner.ts +107 -9
- package/src/hookRuntime.ts +26 -14
- package/src/hookTrace.ts +588 -0
- package/src/index.ts +4 -0
- package/src/projectIdentityFile.ts +34 -20
- package/src/protectedPaths.ts +18 -7
- package/src/repositoryRoot.ts +188 -82
- 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
package/src/codeScanner.ts
CHANGED
|
@@ -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. (
|
|
223
|
-
* generically by the
|
|
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
|
-
*
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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;
|
package/src/hookRuntime.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
|
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
|
|
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';
|