@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.
Files changed (58) hide show
  1. package/dist/codeScanner.d.ts +46 -1
  2. package/dist/codeScanner.d.ts.map +1 -1
  3. package/dist/codeScanner.js +100 -12
  4. package/dist/codeScanner.js.map +1 -1
  5. package/dist/contextBudget.d.ts +2 -2
  6. package/dist/contextBudget.d.ts.map +1 -1
  7. package/dist/contextBudget.js +3 -4
  8. package/dist/contextBudget.js.map +1 -1
  9. package/dist/gitEditDetector.d.ts +11 -6
  10. package/dist/gitEditDetector.d.ts.map +1 -1
  11. package/dist/gitEditDetector.js +119 -9
  12. package/dist/gitEditDetector.js.map +1 -1
  13. package/dist/hookRuntime.d.ts +18 -8
  14. package/dist/hookRuntime.d.ts.map +1 -1
  15. package/dist/hookRuntime.js +31 -20
  16. package/dist/hookRuntime.js.map +1 -1
  17. package/dist/hookTrace.d.ts +149 -0
  18. package/dist/hookTrace.d.ts.map +1 -0
  19. package/dist/hookTrace.js +483 -0
  20. package/dist/hookTrace.js.map +1 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/instructions.d.ts +1 -1
  26. package/dist/instructions.d.ts.map +1 -1
  27. package/dist/instructions.js +1 -1
  28. package/dist/projectIdentityFile.d.ts +13 -0
  29. package/dist/projectIdentityFile.d.ts.map +1 -1
  30. package/dist/projectIdentityFile.js +31 -18
  31. package/dist/projectIdentityFile.js.map +1 -1
  32. package/dist/protectedPaths.d.ts +6 -0
  33. package/dist/protectedPaths.d.ts.map +1 -1
  34. package/dist/protectedPaths.js +15 -6
  35. package/dist/protectedPaths.js.map +1 -1
  36. package/dist/repositoryRoot.d.ts +20 -8
  37. package/dist/repositoryRoot.d.ts.map +1 -1
  38. package/dist/repositoryRoot.js +167 -79
  39. package/dist/repositoryRoot.js.map +1 -1
  40. package/dist/runtimeReader.d.ts +1 -1
  41. package/dist/runtimeReader.d.ts.map +1 -1
  42. package/dist/runtimeReader.js.map +1 -1
  43. package/package.json +2 -2
  44. package/src/codeScanner.ts +115 -9
  45. package/src/contextBudget.ts +3 -4
  46. package/src/gitEditDetector.ts +113 -8
  47. package/src/hookRuntime.ts +39 -27
  48. package/src/hookTrace.ts +588 -0
  49. package/src/index.ts +4 -0
  50. package/src/instructions.ts +1 -1
  51. package/src/projectIdentityFile.ts +34 -20
  52. package/src/protectedPaths.ts +18 -7
  53. package/src/repositoryRoot.ts +188 -82
  54. package/src/runtimeReader.ts +6 -1
  55. package/test-fixtures/identity/runner.d.mts +1 -1
  56. package/test-fixtures/identity/runner.mjs +299 -25
  57. package/tests/hookTrace.test.ts +350 -0
  58. package/tests/nearestProjectIdentity.test.ts +253 -0
@@ -156,7 +156,7 @@
156
156
 
157
157
  // Dynamic response orchestration remains authoritative. This universal floor stays
158
158
  // self-scoping when project context was already loaded through another channel.
159
- const INSTRUCTIONS_CONTENT = `Mnemonik provides persistent project memory: decisions, rationale, tasks, and policies across sessions. If no block begins "PROJECT_CONTEXT schemaVersion=", session_bootstrap({ cwd }) loads project context when cwd is the real absolute path of the project root; a placeholder or relative path fails. With that block, context is loaded and no session_bootstrap call is needed. Code and docs show current artifacts, git shows changes, and memory carries the why; memory_search recalls rationale or intent, then Grep or Read verifies current source.
159
+ const INSTRUCTIONS_CONTENT = `Mnemonik provides persistent project memory: decisions, rationale, tasks, and policies across sessions. If no block begins "PROJECT_CONTEXT schemaVersion=", session_bootstrap({}) loads project context; installed hooks supply the project. With that block, context is loaded and no session_bootstrap call is needed. Code and docs show current artifacts, git shows changes, and memory carries the why; memory_search recalls rationale or intent, then Grep or Read verifies current source.
160
160
 
161
161
  What I learn about you belongs in Companion; what I learn about this project belongs here.
162
162
 
@@ -86,9 +86,35 @@ export function parseIdentityFile(text: string): Exclude<IdentityFileResult, { k
86
86
  };
87
87
  }
88
88
 
89
+ /**
90
+ * A `.mnemonik.json` that holds configuration but no identity: a JSON object
91
+ * with no `projectId` that names schema 1 or no schema. Invalid identities,
92
+ * and any file naming another schema (which may carry its identity under other
93
+ * keys), are not.
94
+ */
95
+ export function isConfigurationOnly(text: string): boolean {
96
+ try {
97
+ const value: unknown = JSON.parse(text);
98
+ return (
99
+ !!value &&
100
+ typeof value === 'object' &&
101
+ !Array.isArray(value) &&
102
+ !('projectId' in value) &&
103
+ (!('schemaVersion' in value) || value.schemaVersion === 1)
104
+ );
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Read a folder's identity file. With `configurationOnlyAbsent` (and with
112
+ * `selectedRoot`, folders ticked during install), a configuration-only file is
113
+ * no identity: the folder is not a project of its own.
114
+ */
89
115
  export async function readIdentityFile(
90
116
  dir: string,
91
- options: { selectedRoot?: boolean } = {}
117
+ options: { selectedRoot?: boolean; configurationOnlyAbsent?: boolean } = {}
92
118
  ): Promise<IdentityFileResult> {
93
119
  const path = join(dir, '.mnemonik.json');
94
120
  try {
@@ -96,25 +122,13 @@ export async function readIdentityFile(
96
122
  if (stat.isSymbolicLink()) return { kind: 'malformed', detail: 'symlink' };
97
123
  if (!stat.isFile()) return { kind: 'malformed', detail: 'identity path is not a regular file' };
98
124
  const text = await readFile(path, 'utf8');
99
- if (options.selectedRoot) {
100
- // A ticked folder with configuration but no identity is a new project.
101
- // Keep invalid identities, non-regular files and any file that names a
102
- // schema other than 1 on the strict refusal path: a later schema may carry
103
- // its identity under other keys, and setup must not replace it.
104
- try {
105
- const value: unknown = JSON.parse(text);
106
- if (
107
- value &&
108
- typeof value === 'object' &&
109
- !Array.isArray(value) &&
110
- !('projectId' in value) &&
111
- (!('schemaVersion' in value) || value.schemaVersion === 1)
112
- )
113
- return { kind: 'absent' };
114
- } catch {
115
- /* The strict parser supplies the diagnostic. */
116
- }
117
- }
125
+ // A ticked folder with configuration but no identity is a new project, and
126
+ // a configuration-only file met walking up is not a project boundary. Keep
127
+ // invalid identities, non-regular files and any file that names a schema
128
+ // other than 1 on the strict refusal path: a later schema may carry its
129
+ // identity under other keys, and setup must not replace it.
130
+ if ((options.selectedRoot || options.configurationOnlyAbsent) && isConfigurationOnly(text))
131
+ return { kind: 'absent' };
118
132
  return parseIdentityFile(text);
119
133
  } catch (error) {
120
134
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'absent' };
@@ -7,6 +7,23 @@ function pathsFor(platform: Platform): PlatformPath {
7
7
  return platform === 'win32' ? win32 : posix;
8
8
  }
9
9
 
10
+ /**
11
+ * Mnemonik's local state folder: the same answer as `stateDirectory` in
12
+ * @mnemonik/local-setup, which depends on this package and so cannot be
13
+ * imported here.
14
+ */
15
+ export function mnemonikStateDirectory(
16
+ platform: Platform = process.platform,
17
+ env: NodeJS.ProcessEnv = process.env,
18
+ home: string = homedir()
19
+ ): string {
20
+ if (env.MNEMONIK_STATE_DIR) return env.MNEMONIK_STATE_DIR;
21
+ if (platform === 'win32')
22
+ return win32.join(env.LOCALAPPDATA || win32.join(home, 'AppData', 'Local'), 'Mnemonik');
23
+ if (platform === 'darwin') return posix.join(home, 'Library', 'Application Support', 'Mnemonik');
24
+ return posix.join(env.XDG_STATE_HOME || posix.join(home, '.local', 'state'), 'mnemonik');
25
+ }
26
+
10
27
  /** Local state and credential locations that Mnemonik must never scan. */
11
28
  export function protectedLocalPaths(
12
29
  platform: Platform = process.platform,
@@ -16,13 +33,7 @@ export function protectedLocalPaths(
16
33
  const path = pathsFor(platform);
17
34
  const localAppData = env.LOCALAPPDATA || win32.join(home, 'AppData', 'Local');
18
35
  const appData = env.APPDATA || win32.join(home, 'AppData', 'Roaming');
19
- const state =
20
- env.MNEMONIK_STATE_DIR ||
21
- (platform === 'win32'
22
- ? win32.join(localAppData, 'Mnemonik')
23
- : platform === 'darwin'
24
- ? posix.join(home, 'Library', 'Application Support', 'Mnemonik')
25
- : posix.join(env.XDG_STATE_HOME || posix.join(home, '.local', 'state'), 'mnemonik'));
36
+ const state = mnemonikStateDirectory(platform, env, home);
26
37
  const common = [
27
38
  state,
28
39
  path.join(home, '.mnemonik'),
@@ -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
  }
@@ -21,7 +21,12 @@ export type Reason =
21
21
  | 'unsigned'
22
22
  | 'permission'
23
23
  | 'acl_unavailable'
24
- | 'lock_held';
24
+ | 'lock_held'
25
+ // The registry answered without the package (404, 429 or 5xx): a fresh release
26
+ // still reaching its CDN, a busy registry, or a pin it does not have.
27
+ | 'registry_unavailable'
28
+ // The registry could not be reached at all: offline, DNS, refused, timed out.
29
+ | 'registry_unreachable';
25
30
  export class RuntimeError extends Error {
26
31
  constructor(public readonly reason: Reason) {
27
32
  super(reason);
@@ -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