@mnemonik/shared 7.97.2 → 7.98.1

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 (130) hide show
  1. package/dist/ambientPointer.d.ts.map +1 -1
  2. package/dist/ambientPointer.js +2 -1
  3. package/dist/ambientPointer.js.map +1 -1
  4. package/dist/apiOrigin.d.ts +3 -0
  5. package/dist/apiOrigin.d.ts.map +1 -0
  6. package/dist/apiOrigin.js +5 -0
  7. package/dist/apiOrigin.js.map +1 -0
  8. package/dist/ast/astChunker.d.ts +12 -12
  9. package/dist/ast/astChunker.js +32 -32
  10. package/dist/ast/grammars.d.ts +9 -9
  11. package/dist/ast/grammars.js +15 -15
  12. package/dist/codeScanner.d.ts +56 -43
  13. package/dist/codeScanner.d.ts.map +1 -1
  14. package/dist/codeScanner.js +262 -143
  15. package/dist/codeScanner.js.map +1 -1
  16. package/dist/contextBudget.js +2 -2
  17. package/dist/contextBudget.js.map +1 -1
  18. package/dist/gitEditDetector.d.ts +6 -6
  19. package/dist/gitEditDetector.js +7 -7
  20. package/dist/hookRuntime.d.ts +35 -2
  21. package/dist/hookRuntime.d.ts.map +1 -1
  22. package/dist/hookRuntime.js +127 -33
  23. package/dist/hookRuntime.js.map +1 -1
  24. package/dist/hookTimeouts.d.ts +6 -6
  25. package/dist/hookTimeouts.js +6 -6
  26. package/dist/hostAdapter.d.ts +135 -0
  27. package/dist/hostAdapter.d.ts.map +1 -0
  28. package/dist/hostAdapter.js +323 -0
  29. package/dist/hostAdapter.js.map +1 -0
  30. package/dist/hostBinary.d.ts +29 -0
  31. package/dist/hostBinary.d.ts.map +1 -0
  32. package/dist/hostBinary.js +179 -0
  33. package/dist/hostBinary.js.map +1 -0
  34. package/dist/index.d.ts +11 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +10 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/instructions.d.ts +26 -26
  39. package/dist/instructions.js +26 -26
  40. package/dist/jitDeliveryAck.d.ts.map +1 -1
  41. package/dist/jitDeliveryAck.js +2 -0
  42. package/dist/jitDeliveryAck.js.map +1 -1
  43. package/dist/projectIdentityFile.d.ts +25 -0
  44. package/dist/projectIdentityFile.d.ts.map +1 -0
  45. package/dist/projectIdentityFile.js +87 -0
  46. package/dist/projectIdentityFile.js.map +1 -0
  47. package/dist/projectSetupHandoff.d.ts +24 -0
  48. package/dist/projectSetupHandoff.d.ts.map +1 -0
  49. package/dist/projectSetupHandoff.js +249 -0
  50. package/dist/projectSetupHandoff.js.map +1 -0
  51. package/dist/protectedPaths.d.ts +9 -0
  52. package/dist/protectedPaths.d.ts.map +1 -0
  53. package/dist/protectedPaths.js +66 -0
  54. package/dist/protectedPaths.js.map +1 -0
  55. package/dist/readiness.d.ts +143 -0
  56. package/dist/readiness.d.ts.map +1 -0
  57. package/dist/readiness.js +304 -0
  58. package/dist/readiness.js.map +1 -0
  59. package/dist/repositoryFingerprint.d.ts +50 -0
  60. package/dist/repositoryFingerprint.d.ts.map +1 -0
  61. package/dist/repositoryFingerprint.js +160 -0
  62. package/dist/repositoryFingerprint.js.map +1 -0
  63. package/dist/repositoryRoot.d.ts +56 -0
  64. package/dist/repositoryRoot.d.ts.map +1 -0
  65. package/dist/repositoryRoot.js +226 -0
  66. package/dist/repositoryRoot.js.map +1 -0
  67. package/dist/runtimeReader.d.ts +102 -0
  68. package/dist/runtimeReader.d.ts.map +1 -0
  69. package/dist/runtimeReader.js +408 -0
  70. package/dist/runtimeReader.js.map +1 -0
  71. package/dist/runtimeSigners.d.ts +37 -0
  72. package/dist/runtimeSigners.d.ts.map +1 -0
  73. package/dist/runtimeSigners.js +507 -0
  74. package/dist/runtimeSigners.js.map +1 -0
  75. package/dist/scannerSupervisor.d.ts +35 -0
  76. package/dist/scannerSupervisor.d.ts.map +1 -0
  77. package/dist/scannerSupervisor.js +2 -0
  78. package/dist/scannerSupervisor.js.map +1 -0
  79. package/dist/secretPatterns.d.ts +9 -9
  80. package/dist/secretPatterns.d.ts.map +1 -1
  81. package/dist/secretPatterns.js +27 -25
  82. package/dist/secretPatterns.js.map +1 -1
  83. package/dist/testing/hostAdapterConformance.d.ts +17 -0
  84. package/dist/testing/hostAdapterConformance.d.ts.map +1 -0
  85. package/dist/testing/hostAdapterConformance.js +477 -0
  86. package/dist/testing/hostAdapterConformance.js.map +1 -0
  87. package/dist/usageGuide.d.ts +1 -1
  88. package/dist/usageGuide.d.ts.map +1 -1
  89. package/dist/usageGuide.js +4 -4
  90. package/package.json +7 -2
  91. package/queries/bash.tags.scm +1 -1
  92. package/queries/groovy.tags.scm +1 -1
  93. package/scripts/vendor-grammars.ts +13 -13
  94. package/src/ambientPointer.ts +2 -1
  95. package/src/apiOrigin.ts +4 -0
  96. package/src/ast/astChunker.ts +39 -39
  97. package/src/ast/grammars.ts +16 -16
  98. package/src/codeScanner.ts +274 -149
  99. package/src/contextBudget.ts +2 -2
  100. package/src/gitEditDetector.ts +7 -7
  101. package/src/hookRuntime.ts +162 -34
  102. package/src/hookTimeouts.ts +6 -6
  103. package/src/hostAdapter.ts +468 -0
  104. package/src/hostBinary.ts +205 -0
  105. package/src/index.ts +20 -0
  106. package/src/instructions.ts +26 -26
  107. package/src/jitDeliveryAck.ts +2 -0
  108. package/src/projectIdentityFile.ts +100 -0
  109. package/src/projectSetupHandoff.ts +275 -0
  110. package/src/protectedPaths.ts +88 -0
  111. package/src/readiness.ts +516 -0
  112. package/src/repositoryFingerprint.ts +199 -0
  113. package/src/repositoryRoot.ts +261 -0
  114. package/src/runtimeReader.ts +495 -0
  115. package/src/runtimeSigners.ts +577 -0
  116. package/src/scannerSupervisor.ts +26 -0
  117. package/src/secretPatterns.ts +27 -25
  118. package/src/testing/hostAdapterConformance.ts +538 -0
  119. package/src/usageGuide.ts +4 -4
  120. package/test-fixtures/identity/runner.d.mts +12 -0
  121. package/test-fixtures/identity/runner.mjs +282 -0
  122. package/tests/fixtures/repository-fingerprint-v1.golden.json +834 -0
  123. package/tests/projectIdentity.test.ts +56 -0
  124. package/tests/readiness.test.ts +158 -0
  125. package/tests/repositoryFingerprint.test.ts +26 -0
  126. package/tsconfig.test.json +9 -0
  127. package/wasm/bash.tags.scm +1 -1
  128. package/wasm/groovy.tags.scm +1 -1
  129. package/wasm/javascript.tags.scm +2 -2
  130. package/wasm/solidity.tags.scm +1 -1
@@ -0,0 +1,199 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { domainToASCII } from 'node:url';
3
+
4
+ /** Only this hash/version pair may be sent to or stored by the server. */
5
+ export interface RepositoryFingerprint {
6
+ algorithmVersion: 1;
7
+ hash: string;
8
+ }
9
+ export type RemoteRejectionReason =
10
+ | 'invalid_remote'
11
+ | 'unsupported_transport'
12
+ | 'local_remote'
13
+ | 'scp_unreviewed_remote'
14
+ | 'https_userinfo'
15
+ | 'ssh_password'
16
+ | 'ssh_user_required'
17
+ | 'invalid_ssh_user'
18
+ | 'invalid_host'
19
+ | 'invalid_port'
20
+ | 'invalid_encoding'
21
+ | 'escaped_separator'
22
+ | 'control_character'
23
+ | 'backslash'
24
+ | 'empty_segment'
25
+ | 'dot_segment';
26
+ export type RemoteResult =
27
+ | (RepositoryFingerprint & { canonical: string })
28
+ | { status: 'rejected'; reason: RemoteRejectionReason };
29
+
30
+ interface ParsedRemote {
31
+ transport: 'https' | 'ssh';
32
+ host: string;
33
+ user: string;
34
+ port: string;
35
+ path: string;
36
+ }
37
+ export interface RepositoryProviderRule {
38
+ readonly name: string;
39
+ readonly host: string;
40
+ readonly sshUser: string;
41
+ readonly removeTerminalDotGit: boolean;
42
+ }
43
+ /** Registry changes require review and golden cases; changed v1 outputs require v2. */
44
+ export const REPOSITORY_PROVIDER_RULES: readonly RepositoryProviderRule[] = Object.freeze([
45
+ Object.freeze({ name: 'github', host: 'github.com', sshUser: 'git', removeTerminalDotGit: true }),
46
+ ]);
47
+ const controls = /\p{Cc}/u;
48
+ const reject = (reason: RemoteRejectionReason): RemoteResult => ({ status: 'rejected', reason });
49
+ const encodeSegment = (segment: string): string =>
50
+ encodeURIComponent(segment).replace(
51
+ /[!'()*]/g,
52
+ (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
53
+ );
54
+
55
+ /** Parse the raw path ourselves: WHATWG URL would silently resolve dot segments. */
56
+ export function canonicalizeRemote(
57
+ url: string,
58
+ {
59
+ providerRules = REPOSITORY_PROVIDER_RULES,
60
+ }: { providerRules?: readonly RepositoryProviderRule[] } = {}
61
+ ): RemoteResult {
62
+ if (controls.test(url)) return reject('control_character');
63
+ if (url.includes('\\')) return reject('backslash');
64
+ if (/^file:/i.test(url)) return reject('local_remote');
65
+ const input = url.split(/[?#]/, 1)[0] ?? '';
66
+ const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(input);
67
+ let transport: 'https' | 'ssh';
68
+ let authority: string;
69
+ let path: string;
70
+ if (scheme) {
71
+ const protocol = (scheme[1] ?? '').toLowerCase();
72
+ if (protocol !== 'https' && protocol !== 'ssh') return reject('unsupported_transport');
73
+ transport = protocol;
74
+ const parts = /^([^/]*)(?:\/(.*))?$/.exec(input.slice(scheme[0].length));
75
+ if (!parts) return reject('invalid_remote');
76
+ authority = parts[1] ?? '';
77
+ path = parts[2] ?? '';
78
+ } else {
79
+ const scp = /^((?:[^@/:]+@)?(?:\[[^\]]+\]|[^/:]+)):(.*)$/.exec(input);
80
+ if (!scp) return reject('invalid_remote');
81
+ transport = 'ssh';
82
+ authority = scp[1] ?? '';
83
+ path = scp[2] ?? '';
84
+ }
85
+ const at = authority.lastIndexOf('@');
86
+ let user = '';
87
+ if (at >= 0) {
88
+ if (transport === 'https') return reject('https_userinfo');
89
+ user = authority.slice(0, at);
90
+ if (user.includes(':')) return reject('ssh_password');
91
+ if (!/^[A-Za-z0-9._~!$&'()*+,;=-]+$/.test(user)) return reject('invalid_ssh_user');
92
+ authority = authority.slice(at + 1);
93
+ }
94
+ if (transport === 'ssh' && !user) return reject('ssh_user_required');
95
+ const hostPort = /^(\[[^\]]+\]|[^:[\]]+)(?::([0-9]+))?$/.exec(authority);
96
+ if (!hostPort) return reject(authority.includes(':') ? 'invalid_port' : 'invalid_host');
97
+ let host = hostPort[1] ?? '';
98
+ let port = hostPort[2] ?? '';
99
+ if (port && (Number(port) < 1 || Number(port) > 65535)) return reject('invalid_port');
100
+ if (port) port = String(Number(port));
101
+ if (port === (transport === 'https' ? '443' : '22')) port = '';
102
+ if (/[\s%/@?#]/u.test(host)) return reject('invalid_host');
103
+ try {
104
+ // Node's WHATWG host parser supplies UTS 46 ToASCII and IPv6 zero compression.
105
+ host = host.startsWith('[')
106
+ ? new URL(`https://${host}/`).hostname
107
+ : domainToASCII(host).toLowerCase();
108
+ if (!host) return reject('invalid_host');
109
+ } catch {
110
+ return reject('invalid_host');
111
+ }
112
+ const rule = providerRules.find(
113
+ (rule) => rule.host === host && !port && (transport === 'https' || user === rule.sshUser)
114
+ );
115
+ // Generic scp paths are home-relative; ssh:// paths are absolute. Only a
116
+ // reviewed provider rule can establish that both name the same repository.
117
+ if (!scheme && !rule) return reject('scp_unreviewed_remote');
118
+ if (!scheme && path.startsWith('/')) path = path.slice(1);
119
+ if (path.endsWith('/')) path = path.slice(0, -1);
120
+ const segments: string[] = [];
121
+ for (const raw of path.split('/')) {
122
+ if (/%(?:2f|5c)/i.test(raw)) return reject('escaped_separator');
123
+ let segment: string;
124
+ try {
125
+ segment = decodeURIComponent(raw).normalize('NFC');
126
+ } catch {
127
+ return reject('invalid_encoding');
128
+ }
129
+ if (controls.test(segment)) return reject('control_character');
130
+ if (segment.includes('\\')) return reject('backslash');
131
+ if (!segment) return reject('empty_segment');
132
+ if (segment === '.' || segment === '..') return reject('dot_segment');
133
+ try {
134
+ segments.push(encodeSegment(segment));
135
+ } catch {
136
+ return reject('invalid_encoding');
137
+ }
138
+ }
139
+ const parsed: ParsedRemote = { transport, host, user, port, path: segments.join('/') };
140
+ if (rule?.removeTerminalDotGit) {
141
+ parsed.path = parsed.path.replace(/\.git$/, '');
142
+ const last = parsed.path.split('/').at(-1);
143
+ if (!last) return reject('empty_segment');
144
+ if (last === '.' || last === '..') return reject('dot_segment');
145
+ }
146
+ const prefix = rule
147
+ ? `${rule.name}:${host}`
148
+ : `${transport}:${transport === 'ssh' ? `${user}@` : ''}${host}${port ? `:${port}` : ''}`;
149
+ const canonical = `repo-v1:${prefix}/${parsed.path}`;
150
+ return {
151
+ canonical,
152
+ hash: createHash('sha256').update(canonical, 'utf8').digest('hex'),
153
+ algorithmVersion: 1,
154
+ };
155
+ }
156
+
157
+ export interface RepositoryRemote {
158
+ name: string;
159
+ fetchUrls: readonly string[];
160
+ pushUrls?: readonly string[];
161
+ }
162
+ export type RemoteSelection =
163
+ | {
164
+ status: 'fingerprint';
165
+ remote: string;
166
+ fingerprint: RepositoryFingerprint & { canonical: string };
167
+ }
168
+ | { status: 'none'; reason: 'no_remote' | 'no_fetch_url' }
169
+ | { status: 'rejected'; remote: string; reason: RemoteRejectionReason }
170
+ | { status: 'choice_required'; reason: 'multiple_remotes' | 'different_urls'; remotes: string[] };
171
+
172
+ /** Choice results carry names, never credential-bearing URLs. No URL is guessed. */
173
+ export function selectRemote(remotes: readonly RepositoryRemote[]): RemoteSelection {
174
+ if (!remotes.length) return { status: 'none', reason: 'no_remote' };
175
+ const origins = remotes.filter((remote) => remote.name === 'origin');
176
+ const selected =
177
+ origins.length === 1 ? origins[0] : remotes.length === 1 ? remotes[0] : undefined;
178
+ if (!selected)
179
+ return {
180
+ status: 'choice_required',
181
+ reason: 'multiple_remotes',
182
+ remotes: remotes.map((r) => r.name),
183
+ };
184
+ if (!selected.fetchUrls.length) return { status: 'none', reason: 'no_fetch_url' };
185
+ const results = [...selected.fetchUrls, ...(selected.pushUrls ?? [])].map((url) =>
186
+ canonicalizeRemote(url)
187
+ );
188
+ const first = results[0];
189
+ if (!first) return { status: 'none', reason: 'no_fetch_url' };
190
+ if (results.length === 1 && 'reason' in first)
191
+ return { status: 'rejected', remote: selected.name, reason: first.reason };
192
+ if (
193
+ 'reason' in first ||
194
+ results.some((result) => 'reason' in result || result.canonical !== first.canonical)
195
+ ) {
196
+ return { status: 'choice_required', reason: 'different_urls', remotes: [selected.name] };
197
+ }
198
+ return { status: 'fingerprint', remote: selected.name, fingerprint: first };
199
+ }
@@ -0,0 +1,261 @@
1
+ import { lstat, readFile } from 'node:fs/promises';
2
+ import { execFile } from 'node:child_process';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { readIdentityFile, type ProjectIdentityFile } from './projectIdentityFile.js';
6
+
7
+ export interface RepositoryBoundary {
8
+ path: string;
9
+ kind: 'directory' | 'file';
10
+ }
11
+
12
+ export type RepositoryRootResult =
13
+ | {
14
+ kind: 'git';
15
+ root: string;
16
+ commonDir: string;
17
+ isLinkedWorktree: boolean;
18
+ nested: RepositoryBoundary[];
19
+ }
20
+ | { kind: 'plain'; root: string }
21
+ | { kind: 'git_unavailable'; detail: string };
22
+
23
+ type ResolvedRepository = Exclude<RepositoryRootResult, { kind: 'git_unavailable' }>;
24
+
25
+ type ResolvedBase = {
26
+ root: string;
27
+ repository: ResolvedRepository;
28
+ nested: RepositoryBoundary[];
29
+ };
30
+
31
+ export type ProjectIdentityResolution =
32
+ | (ResolvedBase & { kind: 'ok'; identity: ProjectIdentityFile })
33
+ | (ResolvedBase & { kind: 'absent' })
34
+ | (ResolvedBase & { kind: 'unknown_version'; version: unknown; path: string })
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
+ | { kind: 'git_unavailable'; detail: string };
43
+
44
+ const GIT_TIMEOUT_MS = 2_000;
45
+ const GIT_ENV_KEYS = [
46
+ 'PATH',
47
+ 'HOME',
48
+ 'LANG',
49
+ 'LC_ALL',
50
+ 'SYSTEMROOT',
51
+ 'TEMP',
52
+ 'TMP',
53
+ 'USERPROFILE',
54
+ ] as const;
55
+
56
+ function gitEnvironment(): NodeJS.ProcessEnv {
57
+ const env: NodeJS.ProcessEnv = {};
58
+ for (const key of GIT_ENV_KEYS) {
59
+ if (process.env[key] !== undefined) env[key] = process.env[key];
60
+ }
61
+ env.LC_ALL = 'C';
62
+ return env;
63
+ }
64
+
65
+ function runGit(cwd: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
66
+ return new Promise((resolvePromise, reject) => {
67
+ execFile(
68
+ 'git',
69
+ args,
70
+ { cwd, timeout: GIT_TIMEOUT_MS, encoding: 'utf8', env: gitEnvironment() },
71
+ (error, stdout, stderr) => {
72
+ if (error) reject(Object.assign(error, { stdout, stderr }));
73
+ else resolvePromise({ stdout, stderr });
74
+ }
75
+ );
76
+ });
77
+ }
78
+
79
+ async function boundaryAt(path: string): Promise<RepositoryBoundary | undefined> {
80
+ try {
81
+ const stat = await lstat(join(path, '.git'));
82
+ if (stat.isDirectory()) return { path, kind: 'directory' };
83
+ if (stat.isFile()) return { path, kind: 'file' };
84
+ } catch (error) {
85
+ const code = (error as NodeJS.ErrnoException).code;
86
+ if (code !== 'ENOENT' && code !== 'ENOTDIR') throw error;
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ async function confirmedBoundaryAt(path: string): Promise<RepositoryBoundary | undefined> {
92
+ const boundary = await boundaryAt(path);
93
+ if (!boundary) return undefined;
94
+ try {
95
+ const { stdout } = await runGit(path, ['rev-parse', '--git-dir']);
96
+ return stdout.trim() ? boundary : undefined;
97
+ } catch {
98
+ return undefined;
99
+ }
100
+ }
101
+
102
+ async function boundariesBetween(cwd: string, root: string): Promise<RepositoryBoundary[]> {
103
+ const boundaries: RepositoryBoundary[] = [];
104
+ let directory = cwd;
105
+ while (directory !== root) {
106
+ const boundary = await confirmedBoundaryAt(directory);
107
+ if (boundary) boundaries.push(boundary);
108
+ const parent = dirname(directory);
109
+ if (parent === directory) break;
110
+ directory = parent;
111
+ }
112
+ return boundaries;
113
+ }
114
+
115
+ async function containingRepository(root: string): Promise<RepositoryBoundary | undefined> {
116
+ let directory = dirname(root);
117
+ while (true) {
118
+ const boundary = await confirmedBoundaryAt(directory);
119
+ if (boundary) return boundary;
120
+ const parent = dirname(directory);
121
+ if (parent === directory) return undefined;
122
+ directory = parent;
123
+ }
124
+ }
125
+
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
+ }
140
+ }
141
+
142
+ export async function resolveRepositoryRoot(cwd: string): Promise<RepositoryRootResult> {
143
+ const absoluteCwd = resolve(cwd);
144
+ try {
145
+ const { stdout } = await runGit(absoluteCwd, [
146
+ 'rev-parse',
147
+ '--show-toplevel',
148
+ '--git-common-dir',
149
+ ]);
150
+ const [rootLine, commonLine, ...extra] = stdout.trim().split(/\r?\n/);
151
+ if (!rootLine || !commonLine || extra.length) {
152
+ return { kind: 'git_unavailable', detail: 'git rev-parse returned an unexpected response' };
153
+ }
154
+ const root = resolve(rootLine);
155
+ const commonDir = resolve(root, commonLine);
156
+ const isLinkedWorktree = commonDir !== join(root, '.git');
157
+ const nested = await boundariesBetween(absoluteCwd, root);
158
+ if (!isLinkedWorktree && (await containingRepository(root))) {
159
+ const boundary = await boundaryAt(root);
160
+ if (boundary) nested.push(boundary);
161
+ }
162
+ return {
163
+ kind: 'git',
164
+ root,
165
+ commonDir,
166
+ isLinkedWorktree,
167
+ nested,
168
+ };
169
+ } catch (error) {
170
+ const failure = error as Error & { code?: string | number; stderr?: string };
171
+ if (failure.code === 128 && /not a git repository/i.test(failure.stderr ?? failure.message)) {
172
+ return { kind: 'plain', root: absoluteCwd };
173
+ }
174
+ return { kind: 'git_unavailable', detail: failure.message };
175
+ }
176
+ }
177
+
178
+ async function readPlainIdentity(start: string): Promise<{
179
+ root: string;
180
+ result: Awaited<ReturnType<typeof readIdentityFile>>;
181
+ }> {
182
+ const home = resolve(homedir());
183
+ let directory = start;
184
+ while (true) {
185
+ const result = await readIdentityFile(directory);
186
+ if (result.kind !== 'absent') return { root: directory, result };
187
+ if (directory === home) break;
188
+ const parent = dirname(directory);
189
+ if (parent === directory) break;
190
+ directory = parent;
191
+ }
192
+ return { root: start, result: { kind: 'absent' } };
193
+ }
194
+
195
+ function withBase(
196
+ repository: ResolvedRepository,
197
+ root: string,
198
+ nested: RepositoryBoundary[]
199
+ ): ResolvedBase {
200
+ return { root, repository, nested };
201
+ }
202
+
203
+ export async function resolveProjectIdentity(
204
+ cwd: string,
205
+ options: { allowNestedInherit?: boolean } = {}
206
+ ): Promise<ProjectIdentityResolution> {
207
+ const repository = await resolveRepositoryRoot(cwd);
208
+ if (repository.kind === 'git_unavailable') return repository;
209
+
210
+ if (repository.kind === 'plain') {
211
+ const { root, result } = await readPlainIdentity(repository.root);
212
+ const base = withBase(repository, root, []);
213
+ if (result.kind === 'ok') return { ...base, ...result };
214
+ if (result.kind === 'unknown_version') return { ...base, ...result, path: root };
215
+ if (result.kind === 'malformed') return { ...base, ...result, path: root };
216
+ return { ...base, kind: 'absent' };
217
+ }
218
+
219
+ const ownRoot = repository.isLinkedWorktree ? dirname(repository.commonDir) : repository.root;
220
+ const outer = repository.isLinkedWorktree
221
+ ? undefined
222
+ : await containingRepository(repository.root);
223
+ const own = await readIdentityFile(ownRoot);
224
+ if (!outer) {
225
+ const base = withBase(repository, ownRoot, repository.nested);
226
+ if (own.kind === 'ok') return { ...base, ...own };
227
+ if (own.kind === 'unknown_version') return { ...base, ...own, path: ownRoot };
228
+ if (own.kind === 'malformed') return { ...base, ...own, path: ownRoot };
229
+ return { ...base, kind: 'absent' };
230
+ }
231
+
232
+ const nested = repository.nested;
233
+ const parentRoot = await canonicalBoundaryRoot(outer);
234
+ const parent = await readIdentityFile(parentRoot);
235
+ const base = withBase(repository, parentRoot, nested);
236
+
237
+ if (own.kind === 'unknown_version') return { ...base, ...own, path: ownRoot };
238
+ if (own.kind === 'malformed') return { ...base, ...own, path: ownRoot };
239
+ if (parent.kind === 'unknown_version') return { ...base, ...parent, path: parentRoot };
240
+ if (parent.kind === 'malformed') return { ...base, ...parent, path: parentRoot };
241
+ if (own.kind === 'ok' && parent.kind === 'ok') {
242
+ return {
243
+ ...base,
244
+ kind: 'conflict',
245
+ rootIdentity: parent.identity,
246
+ nestedIdentity: own.identity,
247
+ };
248
+ }
249
+ if (own.kind === 'ok') {
250
+ return { ...withBase(repository, ownRoot, nested), kind: 'ok', identity: own.identity };
251
+ }
252
+ if (!options.allowNestedInherit) {
253
+ return {
254
+ ...base,
255
+ kind: 'nested',
256
+ ...(parent.kind === 'ok' ? { parentIdentity: parent.identity } : {}),
257
+ };
258
+ }
259
+ if (parent.kind === 'ok') return { ...base, kind: 'ok', identity: parent.identity };
260
+ return { ...base, kind: 'absent' };
261
+ }