carrick 0.3.98 → 0.3.100

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carrick",
3
- "version": "0.3.98",
3
+ "version": "0.3.100",
4
4
  "description": "Maps your entire TypeScript codebase across services and repositories, giving AI agents full context on existing types, routes, and function behaviours over MCP before they write duplicate or breaking code.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -58,11 +58,11 @@
58
58
  "zod": "^3.23.0"
59
59
  },
60
60
  "optionalDependencies": {
61
- "@carrick-tools/cli-darwin-arm64": "0.3.98",
62
- "@carrick-tools/cli-darwin-x64": "0.3.98",
63
- "@carrick-tools/cli-linux-arm64": "0.3.98",
64
- "@carrick-tools/cli-linux-x64": "0.3.98",
65
- "@carrick-tools/cli-win32-x64": "0.3.98"
61
+ "@carrick-tools/cli-darwin-arm64": "0.3.100",
62
+ "@carrick-tools/cli-darwin-x64": "0.3.100",
63
+ "@carrick-tools/cli-linux-arm64": "0.3.100",
64
+ "@carrick-tools/cli-linux-x64": "0.3.100",
65
+ "@carrick-tools/cli-win32-x64": "0.3.100"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@types/node": "^24.13.3",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "carrick",
3
3
  "description": "Claude Code plugin for Carrick, which indexes TypeScript codebases across service and repository boundaries. After each edit it adds the file's routes, calls, and cross-service type mismatches to the session, and it registers Carrick's language server. It pairs with the Carrick MCP server, which lets agents search functions by intent rather than name.",
4
- "version": "0.3.98",
4
+ "version": "0.3.100",
5
5
  "author": {
6
6
  "name": "Carrick",
7
7
  "email": "hello@carrick.tools"
@@ -122,6 +122,8 @@ export declare class TypeBundler {
122
122
  private dedupeSymbols;
123
123
  private validateSymbols;
124
124
  private validateSymbol;
125
+ /** Where a requested symbol is declared, or undefined when it cannot be found. */
126
+ private siteOf;
125
127
  /**
126
128
  * Structural body (`{ ... }`) for an interface/object type, with every nested
127
129
  * named member inlined recursively. Returns null when the expansion is not a
@@ -11,7 +11,7 @@
11
11
  * - Supports scoped packages and subpaths
12
12
  * - Includes a safety pass for any compiler-introduced raw specifiers
13
13
  */
14
- import { Node, } from 'ts-morph';
14
+ import { Node, ts, } from 'ts-morph';
15
15
  import * as path from 'node:path';
16
16
  import * as fs from 'node:fs';
17
17
  import { expandTypeStructural, } from './type-structural-expander.js';
@@ -25,6 +25,97 @@ import { expandTypeStructural, } from './type-structural-expander.js';
25
25
  * as any other symbol the bundler can't extract a type for.
26
26
  */
27
27
  const MAX_ARRAY_DEPTH = 10;
28
+ /** Whether `sourceFile` itself declares `name` in a form the bundle reads. */
29
+ function declaresName(sourceFile, name) {
30
+ return Boolean(sourceFile.getInterface(name) ??
31
+ sourceFile.getTypeAlias(name) ??
32
+ sourceFile.getClass(name) ??
33
+ sourceFile.getEnum(name) ??
34
+ sourceFile.getVariableDeclaration(name) ??
35
+ sourceFile.getFunction(name));
36
+ }
37
+ /**
38
+ * The declarations `name` resolves to when imported from `sourceFile`
39
+ * (carrick#1605), or `ambiguous`.
40
+ *
41
+ * A name the module exports itself (declared there, `export { X }`, `export {
42
+ * X } from`, `export { X as Y } from`) wins over `export *`, and the checker
43
+ * follows it. Otherwise each `export *` is followed, at any depth. Two of them
44
+ * providing different declarations make the name ambiguous: TypeScript
45
+ * reports TS2308 and an ES module exports neither, while the checker's export
46
+ * table silently keeps the first, so the stars are walked here rather than
47
+ * read from it.
48
+ */
49
+ function exportedDeclarations(sourceFile, name, seen) {
50
+ const key = sourceFile.getFilePath();
51
+ if (seen.has(key))
52
+ return [];
53
+ seen.add(key);
54
+ const ownExports = sourceFile.getSymbol()?.compilerSymbol.exports;
55
+ if (ownExports?.has(ts.escapeLeadingUnderscores(name))) {
56
+ return sourceFile.getExportedDeclarations().get(name) ?? [];
57
+ }
58
+ const found = [];
59
+ for (const declaration of sourceFile.getExportDeclarations()) {
60
+ // Only `export * from`: named and namespace re-exports are own exports.
61
+ if (declaration.hasNamedExports() || declaration.getNamespaceExport())
62
+ continue;
63
+ const target = declaration.getModuleSpecifierSourceFile();
64
+ if (!target)
65
+ continue;
66
+ const declarations = exportedDeclarations(target, name, seen);
67
+ if (declarations === 'ambiguous')
68
+ return declarations;
69
+ if (declarations.length > 0)
70
+ found.push(declarations);
71
+ }
72
+ const distinct = new Set(found.map(([first]) => `${first.getSourceFile().getFilePath()}:${first.getStart()}`));
73
+ if (distinct.size > 1)
74
+ return 'ambiguous';
75
+ return found[0] ?? [];
76
+ }
77
+ /** The name a re-exported declaration carries where it is declared. */
78
+ function declaredName(declaration) {
79
+ if (Node.isInterfaceDeclaration(declaration) ||
80
+ Node.isTypeAliasDeclaration(declaration) ||
81
+ Node.isClassDeclaration(declaration) ||
82
+ Node.isEnumDeclaration(declaration) ||
83
+ Node.isVariableDeclaration(declaration) ||
84
+ Node.isFunctionDeclaration(declaration)) {
85
+ return declaration.getName();
86
+ }
87
+ return undefined;
88
+ }
89
+ /**
90
+ * Where `name`, as `sourceFile` exports it, is declared (carrick#1605). A name
91
+ * the file declares is its own. Otherwise it is followed the way an import of
92
+ * it is: a model names a type where the consumer imports it from, usually a
93
+ * barrel that only has `export *`.
94
+ */
95
+ function declarationSite(sourceFile, name, label) {
96
+ if (declaresName(sourceFile, name))
97
+ return { sourceFile, name };
98
+ const declarations = exportedDeclarations(sourceFile, name, new Set());
99
+ if (declarations === 'ambiguous') {
100
+ return {
101
+ reason: `Symbol '${name}' is exported by more than one \`export *\` in ${label}`,
102
+ };
103
+ }
104
+ for (const declaration of declarations) {
105
+ const file = declaration.getSourceFile();
106
+ // An installed package's declaration names types from the package's own
107
+ // files, which the bundle does not carry, so its text would dangle. The
108
+ // failure is what it was before the walk, and inference keeps the row.
109
+ if (file.isInNodeModules() || file.isFromExternalLibrary()) {
110
+ return { reason: `Symbol '${name}' is re-exported from an installed package in ${label}` };
111
+ }
112
+ const declared = declaredName(declaration);
113
+ if (declared && declaresName(declaration.getSourceFile(), declared)) {
114
+ return { sourceFile: declaration.getSourceFile(), name: declared };
115
+ }
116
+ }
117
+ return { reason: `Symbol '${name}' not found in ${label}` };
118
+ }
28
119
  /**
29
120
  * SurfaceEmitter - Generates .d.ts surface files with rewritten imports
30
121
  *
@@ -428,31 +519,19 @@ export class TypeBundler {
428
519
  reason: `Failed to load source file: ${err instanceof Error ? err.message : String(err)}`,
429
520
  };
430
521
  }
431
- // Look for the symbol in the file
432
- const symbolName = symbol.symbol_name;
433
- // Check interfaces
434
- if (sourceFile.getInterface(symbolName))
435
- return { valid: true };
436
- // Check type aliases
437
- if (sourceFile.getTypeAlias(symbolName))
438
- return { valid: true };
439
- // Check classes
440
- if (sourceFile.getClass(symbolName))
441
- return { valid: true };
442
- // Check enums
443
- if (sourceFile.getEnum(symbolName))
444
- return { valid: true };
445
- // Check exported variables
446
- if (sourceFile.getVariableDeclaration(symbolName))
447
- return { valid: true };
448
- // Check functions
449
- if (sourceFile.getFunction(symbolName))
450
- return { valid: true };
451
- // Symbol not found
452
- return {
453
- valid: false,
454
- reason: `Symbol '${symbolName}' not found in ${symbol.source_file}`,
455
- };
522
+ const site = declarationSite(sourceFile, symbol.symbol_name, symbol.source_file);
523
+ return 'reason' in site ? { valid: false, reason: site.reason } : { valid: true };
524
+ }
525
+ /** Where a requested symbol is declared, or undefined when it cannot be found. */
526
+ siteOf(symbol) {
527
+ const absolutePath = path.isAbsolute(symbol.source_file)
528
+ ? symbol.source_file
529
+ : path.resolve(this.repoRoot, symbol.source_file);
530
+ const sourceFile = this.project.getSourceFile(absolutePath);
531
+ if (!sourceFile)
532
+ return undefined;
533
+ const site = declarationSite(sourceFile, symbol.symbol_name, symbol.source_file);
534
+ return 'reason' in site ? undefined : site;
456
535
  }
457
536
  /**
458
537
  * Structural body (`{ ... }`) for an interface/object type, with every nested
@@ -525,13 +604,10 @@ export class TypeBundler {
525
604
  /// union/intersection/function that would misparse; those are re-resolved
526
605
  /// from the source Type here (this runs only on the rare array_depth path).
527
606
  elementNeedsArrayParens(symbol) {
528
- const absolutePath = path.isAbsolute(symbol.source_file)
529
- ? symbol.source_file
530
- : path.resolve(this.repoRoot, symbol.source_file);
531
- const sourceFile = this.project.getSourceFile(absolutePath);
532
- if (!sourceFile)
607
+ const site = this.siteOf(symbol);
608
+ if (!site)
533
609
  return false;
534
- const name = symbol.symbol_name;
610
+ const { sourceFile, name } = site;
535
611
  const decl = sourceFile.getTypeAlias(name) ?? sourceFile.getVariableDeclaration(name);
536
612
  if (!decl)
537
613
  return false;
@@ -546,15 +622,15 @@ export class TypeBundler {
546
622
  // verbatim declaration text from a fallback branch. Only expressions can be
547
623
  // array-wrapped by `extractTypeDefinition`.
548
624
  extractTypeDefinitionBase(symbol) {
549
- const absolutePath = path.isAbsolute(symbol.source_file)
550
- ? symbol.source_file
551
- : path.resolve(this.repoRoot, symbol.source_file);
552
- const sourceFile = this.project.getSourceFile(absolutePath);
553
- if (!sourceFile) {
625
+ const site = this.siteOf(symbol);
626
+ if (!site) {
554
627
  return null;
555
628
  }
556
- const symbolName = symbol.symbol_name;
557
- const alias = symbol.alias || symbolName;
629
+ // `symbolName` is the declaration's own name, which a renamed re-export
630
+ // (`export { Account as AccountView }`) does not share with the request;
631
+ // the bundle declares the name the symbol was requested under.
632
+ const { sourceFile, name: symbolName } = site;
633
+ const alias = symbol.alias || symbol.symbol_name;
558
634
  // Try interface
559
635
  const iface = sourceFile.getInterface(symbolName);
560
636
  if (iface) {
@@ -30,6 +30,7 @@
30
30
  import type { CaptureStubOptions, CaptureStubResult } from './api.js';
31
31
  export type { CaptureStubOptions, CaptureStubResult } from './api.js';
32
32
  export { DenoProject, findDenoConfig } from './deno-project.js';
33
+ export { serviceConfigPath } from './project-references.js';
33
34
  export { runCheck } from './check.js';
34
35
  export type { CheckProgress } from './check.js';
35
36
  export { jsonWireDeclarations } from './check-probe.js';
@@ -39,7 +39,9 @@ import { typesPackageOf, withInstalledPackages } from './installed-package.js';
39
39
  import { selfCheckStub } from './self-check.js';
40
40
  import { collectSpecifiers, isRelative, packageNameOf } from './specifiers.js';
41
41
  import { DenoProject, findDenoConfig } from './deno-project.js';
42
+ import { emitsAlike, ProjectGraph } from './project-references.js';
42
43
  export { DenoProject, findDenoConfig } from './deno-project.js';
44
+ export { serviceConfigPath } from './project-references.js';
43
45
  // v2 check core ("tsc as the judge"). Same bundle, same seam: the sidecar
44
46
  // reaches it only through this door (index.js).
45
47
  export { runCheck } from './check.js';
@@ -128,6 +130,13 @@ export function captureStub(opts) {
128
130
  ? path.resolve(repoRoot, opts.tsconfigPath)
129
131
  : path.join(repoRoot, 'tsconfig.json');
130
132
  let parsed;
133
+ // The config the emit's options came from: the named one, or the project
134
+ // that owns the most anchors' files (carrick#1604).
135
+ let projectConfigPath = configPath;
136
+ // Anchor indexes by the project that owns their file, when the named config
137
+ // references others and the anchors' files do not all belong to one project.
138
+ let ownerGroups;
139
+ let emitProject;
131
140
  let deno;
132
141
  try {
133
142
  const config = findDenoConfig(repoRoot, opts.tsconfigPath);
@@ -164,18 +173,44 @@ export function captureStub(opts) {
164
173
  }, ts.sys, repoRoot);
165
174
  }
166
175
  else {
167
- const configHost = {
168
- ...ts.sys,
169
- onUnRecoverableConfigFileDiagnostic: (d) => {
170
- throw new Error(ts.flattenDiagnosticMessageText(d.messageText, '\n'));
171
- },
172
- };
176
+ // Each anchor is typed under the project that owns its file: the named
177
+ // config when it lists the file, else the first project it references
178
+ // (depth-first, in declared order) that does, else the named config.
179
+ let graph;
173
180
  try {
174
- parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, configHost) ?? undefined;
181
+ graph = new ProjectGraph(configPath);
175
182
  }
176
183
  catch (err) {
177
184
  return fail(stubDir, packageName, [err instanceof Error ? err.message : String(err)]);
178
185
  }
186
+ const groups = new Map();
187
+ const unfiled = [];
188
+ opts.anchors.forEach((anchor, index) => {
189
+ const file = anchor.source_file ? path.resolve(repoRoot, anchor.source_file) : undefined;
190
+ if (!file || !fs.existsSync(file)) {
191
+ unfiled.push(index);
192
+ return;
193
+ }
194
+ const owner = graph.ownerOf(file);
195
+ groups.set(owner, [...(groups.get(owner) ?? []), index]);
196
+ });
197
+ // One emit: under the owner of the most anchors, ties to search order.
198
+ let emit = graph.named;
199
+ let most = 0;
200
+ for (const [owner, indexes] of groups) {
201
+ if (indexes.length > most || (indexes.length === most && graph.rank(owner) < graph.rank(emit))) {
202
+ emit = owner;
203
+ most = indexes.length;
204
+ }
205
+ }
206
+ if (unfiled.length > 0)
207
+ groups.set(emit, [...(groups.get(emit) ?? []), ...unfiled]);
208
+ errors.push(...graph.diagnostics);
209
+ parsed = emit.parsed;
210
+ projectConfigPath = emit.configPath;
211
+ emitProject = emit;
212
+ if (groups.size > 1)
213
+ ownerGroups = groups;
179
214
  }
180
215
  if (!parsed) {
181
216
  return fail(stubDir, packageName, [`failed to parse ${configPath}`]);
@@ -183,7 +218,7 @@ export function captureStub(opts) {
183
218
  // The surface entry must live inside the effective rootDir (design doc
184
219
  // Capture step 1: an entry at repo root with rootDir "src" fails TS6059).
185
220
  const entryDir = parsed.options.rootDir
186
- ? path.resolve(path.dirname(configPath), parsed.options.rootDir)
221
+ ? path.resolve(path.dirname(projectConfigPath), parsed.options.rootDir)
187
222
  : repoRoot;
188
223
  const surfaceEntry = surfaceEntryFileName();
189
224
  const surfaceDeclaration = `${surfaceEntry}.d.ts`;
@@ -193,8 +228,11 @@ export function captureStub(opts) {
193
228
  fs.mkdirSync(path.dirname(entryPath), { recursive: true });
194
229
  // ---- Phase A: analysis program over placeholder entry + anchor sources ----
195
230
  let resolved;
231
+ const analysisCtx = { repoRoot, entryDir: path.dirname(entryPath), entryPath };
196
232
  try {
197
- resolved = resolveAnchors(opts, parsed, { repoRoot, entryDir: path.dirname(entryPath), entryPath }, deno);
233
+ resolved = ownerGroups && emitProject
234
+ ? resolveAnchorsByOwner(opts, ownerGroups, emitProject, analysisCtx, errors)
235
+ : resolveAnchors(opts, parsed, analysisCtx, deno);
198
236
  }
199
237
  catch (err) {
200
238
  return fail(stubDir, packageName, [err instanceof Error ? err.message : String(err)]);
@@ -343,7 +381,7 @@ export function captureStub(opts) {
343
381
  typesDir,
344
382
  files: emittedFiles,
345
383
  options: parsed.options,
346
- configPath,
384
+ configPath: projectConfigPath,
347
385
  entryDir,
348
386
  });
349
387
  const specifierRewrites = denoRewrites + rewritten.rewrites;
@@ -513,6 +551,49 @@ function rewriteSurfaceAliasesToUnknown(text, demoted) {
513
551
  }
514
552
  return out;
515
553
  }
554
+ /**
555
+ * Phase A when the anchors' files belong to different projects (carrick#1604):
556
+ * each owner's anchors are resolved in a program built from that owner's
557
+ * options, so a file is never typed under a project that does not own it.
558
+ *
559
+ * The surface is emitted once, under `emit`'s options. An anchor from another
560
+ * project keeps its text when the text stands alone, or when the two projects
561
+ * would emit a declaration the same way. Otherwise its text names a module the
562
+ * emit would declare under the wrong options (a symbol or handler anchor, or
563
+ * a print that imports a module), so it is demoted with the reason, and cannot
564
+ * self-check clean.
565
+ */
566
+ function resolveAnchorsByOwner(opts, groups, emit, ctx, errors) {
567
+ const resolved = new Array(opts.anchors.length);
568
+ for (const [owner, indexes] of groups) {
569
+ const anchors = indexes.map((index) => opts.anchors[index]);
570
+ const group = resolveAnchors({ ...opts, anchors }, owner.parsed, ctx);
571
+ let demoted = 0;
572
+ group.forEach((anchor, position) => {
573
+ const index = indexes[position];
574
+ const namesModule = anchor.request.kind === 'symbol' ||
575
+ anchor.request.kind === 'handler_return' ||
576
+ collectSpecifiers(anchor.aliasText).size > 0;
577
+ if (owner === emit || anchor.failureReason !== undefined || !namesModule || emitsAlike(owner, emit)) {
578
+ resolved[index] = anchor;
579
+ return;
580
+ }
581
+ demoted += 1;
582
+ resolved[index] = {
583
+ request: anchor.request,
584
+ aliasText: 'unknown',
585
+ serialization: 'structural_fallback',
586
+ failureReason: `typed under ${owner.configPath}, the project that owns its file, whose options differ ` +
587
+ `from ${emit.configPath}, which emits the surface; the module it names would be declared under the wrong options`,
588
+ };
589
+ });
590
+ if (owner !== emit) {
591
+ errors.push(`${indexes.length} anchor(s) typed under ${owner.configPath}, the project that owns their files` +
592
+ (demoted > 0 ? `; ${demoted} demoted because the surface is emitted under ${emit.configPath}` : ''));
593
+ }
594
+ }
595
+ return resolved;
596
+ }
516
597
  /** Phase A: build the placeholder entry, then resolve every anchor. */
517
598
  function resolveAnchors(opts, parsed, ctx, deno) {
518
599
  const placeholderLines = ['// Carrick capture v2 analysis placeholder.'];
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Which project types a file of a service whose tsconfig references others
3
+ * (carrick#1604).
4
+ *
5
+ * A "solution" tsconfig (`"files": []` plus `references`) carries no compiler
6
+ * options: they live in the projects it references. Parsing the named file
7
+ * alone typed every file with none, so `customConditions`, `paths` and the
8
+ * module resolution mode never applied and workspace imports fell through to
9
+ * unbuilt output.
10
+ *
11
+ * TypeScript's editor answers "which project types this file" by searching
12
+ * the named config, then its references depth-first in declared order, for
13
+ * the first project whose file list includes the file. That project is the
14
+ * file's owner here too, and a file is only ever typed under its owner's
15
+ * options: the loader builds one program per owner it is asked about, and
16
+ * the capture types each anchor in its owner's program. A file no reachable
17
+ * project includes is owned by the named config, which is how it was typed
18
+ * before references were read.
19
+ */
20
+ import ts from 'typescript';
21
+ /** One parsed tsconfig. */
22
+ export interface ServiceProject {
23
+ /** Absolute path of the config file the options came from. */
24
+ configPath: string;
25
+ parsed: ts.ParsedCommandLine;
26
+ }
27
+ /**
28
+ * The projects a named config reaches and the owner of each file among them.
29
+ * References are read only when a file the named config does not include is
30
+ * asked about, so a service whose files the named config includes costs one
31
+ * parse, as before.
32
+ */
33
+ export declare class ProjectGraph {
34
+ readonly named: ServiceProject;
35
+ /** References that could not be read, one line each. */
36
+ readonly diagnostics: string[];
37
+ private readonly extendedConfigCache;
38
+ private walked;
39
+ private readonly namedFiles;
40
+ /** Throws when the named config cannot be read, as a bare parse would. */
41
+ constructor(configPath: string);
42
+ /** Whether the named config references other projects at all. */
43
+ get hasReferences(): boolean;
44
+ /**
45
+ * The project that owns `file`: the first in search order whose file list
46
+ * includes it (the named config, then its references depth-first in
47
+ * declared order; a config reached twice is visited once). The named config
48
+ * when none does.
49
+ */
50
+ ownerOf(file: string): ServiceProject;
51
+ /** Position in search order, 0 for the named config. */
52
+ rank(project: ServiceProject): number;
53
+ private projects;
54
+ }
55
+ /**
56
+ * For the loader: the config each file of the service is typed under. The
57
+ * returned function gives the owner's config path for a file, and the named
58
+ * config for no file; a reference that cannot be read goes to `report`. A named config that references nothing is not parsed
59
+ * here (`references` is never inherited through `extends`), so the function
60
+ * answers the named path for every file and the common case costs nothing.
61
+ */
62
+ export declare function serviceConfigPath(configPath: string, report?: (diagnostic: string) => void): (file?: string) => string;
63
+ /**
64
+ * Whether two projects' options would emit a declaration the same way, so a
65
+ * type one of them names can be emitted by the other.
66
+ */
67
+ export declare function emitsAlike(a: ServiceProject, b: ServiceProject): boolean;
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Which project types a file of a service whose tsconfig references others
3
+ * (carrick#1604).
4
+ *
5
+ * A "solution" tsconfig (`"files": []` plus `references`) carries no compiler
6
+ * options: they live in the projects it references. Parsing the named file
7
+ * alone typed every file with none, so `customConditions`, `paths` and the
8
+ * module resolution mode never applied and workspace imports fell through to
9
+ * unbuilt output.
10
+ *
11
+ * TypeScript's editor answers "which project types this file" by searching
12
+ * the named config, then its references depth-first in declared order, for
13
+ * the first project whose file list includes the file. That project is the
14
+ * file's owner here too, and a file is only ever typed under its owner's
15
+ * options: the loader builds one program per owner it is asked about, and
16
+ * the capture types each anchor in its owner's program. A file no reachable
17
+ * project includes is owned by the named config, which is how it was typed
18
+ * before references were read.
19
+ */
20
+ import * as path from 'node:path';
21
+ import ts from 'typescript';
22
+ /** Parse one config; throws on a file that cannot be read or parsed. */
23
+ function parseConfig(configPath, extendedConfigCache) {
24
+ const host = {
25
+ ...ts.sys,
26
+ onUnRecoverableConfigFileDiagnostic: (d) => {
27
+ throw new Error(ts.flattenDiagnosticMessageText(d.messageText, '\n'));
28
+ },
29
+ };
30
+ const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, host, extendedConfigCache);
31
+ if (!parsed)
32
+ throw new Error(`failed to parse ${configPath}`);
33
+ return { configPath: path.resolve(configPath), parsed };
34
+ }
35
+ const fileKey = (file) => path.resolve(file);
36
+ /**
37
+ * The projects a named config reaches and the owner of each file among them.
38
+ * References are read only when a file the named config does not include is
39
+ * asked about, so a service whose files the named config includes costs one
40
+ * parse, as before.
41
+ */
42
+ export class ProjectGraph {
43
+ named;
44
+ /** References that could not be read, one line each. */
45
+ diagnostics = [];
46
+ extendedConfigCache = new Map();
47
+ walked;
48
+ namedFiles;
49
+ /** Throws when the named config cannot be read, as a bare parse would. */
50
+ constructor(configPath) {
51
+ this.named = parseConfig(configPath, this.extendedConfigCache);
52
+ this.namedFiles = new Set(this.named.parsed.fileNames.map(fileKey));
53
+ }
54
+ /** Whether the named config references other projects at all. */
55
+ get hasReferences() {
56
+ return (this.named.parsed.projectReferences?.length ?? 0) > 0;
57
+ }
58
+ /**
59
+ * The project that owns `file`: the first in search order whose file list
60
+ * includes it (the named config, then its references depth-first in
61
+ * declared order; a config reached twice is visited once). The named config
62
+ * when none does.
63
+ */
64
+ ownerOf(file) {
65
+ const key = fileKey(file);
66
+ if (this.namedFiles.has(key) || !this.hasReferences)
67
+ return this.named;
68
+ return this.projects().find((entry) => entry.files.has(key))?.project ?? this.named;
69
+ }
70
+ /** Position in search order, 0 for the named config. */
71
+ rank(project) {
72
+ if (project === this.named)
73
+ return 0;
74
+ const index = this.projects().findIndex((entry) => entry.project === project);
75
+ return index === -1 ? Number.MAX_SAFE_INTEGER : index;
76
+ }
77
+ projects() {
78
+ if (this.walked)
79
+ return this.walked;
80
+ const visited = new Set([this.named.configPath]);
81
+ const order = [];
82
+ const visit = (project, files) => {
83
+ order.push({ project, files });
84
+ for (const reference of project.parsed.projectReferences ?? []) {
85
+ const referencePath = path.resolve(ts.resolveProjectReferencePath(reference));
86
+ if (visited.has(referencePath))
87
+ continue;
88
+ visited.add(referencePath);
89
+ let child;
90
+ try {
91
+ child = parseConfig(referencePath, this.extendedConfigCache);
92
+ }
93
+ catch (err) {
94
+ this.diagnostics.push(`referenced tsconfig ${referencePath} (from ${project.configPath}) could not be read and was skipped: ` +
95
+ (err instanceof Error ? err.message : String(err)));
96
+ continue;
97
+ }
98
+ visit(child, new Set(child.parsed.fileNames.map(fileKey)));
99
+ }
100
+ };
101
+ visit(this.named, this.namedFiles);
102
+ this.walked = order;
103
+ return order;
104
+ }
105
+ }
106
+ /**
107
+ * For the loader: the config each file of the service is typed under. The
108
+ * returned function gives the owner's config path for a file, and the named
109
+ * config for no file; a reference that cannot be read goes to `report`. A named config that references nothing is not parsed
110
+ * here (`references` is never inherited through `extends`), so the function
111
+ * answers the named path for every file and the common case costs nothing.
112
+ */
113
+ export function serviceConfigPath(configPath, report = () => { }) {
114
+ const raw = ts.readConfigFile(configPath, ts.sys.readFile).config;
115
+ if (!Array.isArray(raw?.references) || raw.references.length === 0) {
116
+ return () => configPath;
117
+ }
118
+ let graph;
119
+ return (file) => {
120
+ if (file === undefined)
121
+ return configPath;
122
+ graph ??= new ProjectGraph(configPath);
123
+ const reported = graph.diagnostics.length;
124
+ const owner = graph.ownerOf(file);
125
+ graph.diagnostics.slice(reported).forEach(report);
126
+ return owner === graph.named ? configPath : owner.configPath;
127
+ };
128
+ }
129
+ /** Compiler options that only place output, so two projects differing only in these emit the same declarations. */
130
+ const OUTPUT_ONLY_OPTIONS = new Set([
131
+ 'rootDir',
132
+ 'outDir',
133
+ 'declarationDir',
134
+ 'outFile',
135
+ 'composite',
136
+ 'declaration',
137
+ 'declarationMap',
138
+ 'emitDeclarationOnly',
139
+ 'incremental',
140
+ 'tsBuildInfoFile',
141
+ 'configFilePath',
142
+ 'noEmit',
143
+ 'sourceMap',
144
+ 'inlineSourceMap',
145
+ 'inlineSources',
146
+ ]);
147
+ /**
148
+ * Whether two projects' options would emit a declaration the same way, so a
149
+ * type one of them names can be emitted by the other.
150
+ */
151
+ export function emitsAlike(a, b) {
152
+ const relevant = (options) => JSON.stringify(Object.keys(options)
153
+ .filter((key) => !OUTPUT_ONLY_OPTIONS.has(key))
154
+ .sort()
155
+ .map((key) => [key, options[key]]));
156
+ return relevant(a.parsed.options) === relevant(b.parsed.options);
157
+ }
@@ -28,23 +28,29 @@ import { ClientSemanticsVerifier } from './client-semantics.js';
28
28
  let projectLoader = null;
29
29
  let monorepoBuilder = null;
30
30
  let initTimeMs = null;
31
- let components = null;
31
+ /**
32
+ * Components by project key (`ProjectLoader.projectKeyFor`): `''` is the
33
+ * default project, any other key the program of a project that owns some of
34
+ * the service's files (carrick#1604).
35
+ */
36
+ let components = new Map();
32
37
  /**
33
38
  * Get the project-backed components, building the project if this is the
34
39
  * first request that needs it.
35
40
  *
36
41
  * @throws if init has not run, or if the project cannot be built
37
42
  */
38
- function projectComponents() {
43
+ function projectComponents(key = '') {
39
44
  if (!projectLoader?.isInitialized()) {
40
45
  throw new Error('Sidecar not initialized. Call init first.');
41
46
  }
42
- if (!components) {
47
+ let built = components.get(key);
48
+ if (!built) {
43
49
  // Bound here rather than read from the module slot inside the components:
44
50
  // a re-init drops `components` and points the slot at another service, and
45
51
  // nothing built over this project may follow it there.
46
52
  const loader = projectLoader;
47
- const project = loader.getProject();
53
+ const project = loader.getProjectFor(key);
48
54
  const repoRoot = loader.getRepoRoot();
49
55
  // The module graph, where the project resolved through one, is the only
50
56
  // thing that can name the package a file belongs to: a Deno service
@@ -54,7 +60,7 @@ function projectComponents() {
54
60
  repoRoot,
55
61
  packageOf: (filePath) => loader.packageOf(filePath),
56
62
  });
57
- components = {
63
+ built = {
58
64
  typeBundler: new TypeBundler({ project, repoRoot }),
59
65
  surfaceEmitter: new SurfaceEmitter({ project, repoRoot }),
60
66
  typeInferrer,
@@ -70,8 +76,41 @@ function projectComponents() {
70
76
  retyper: new Retyper(project, typeInferrer, jsonWireDeclarations, findDisqualifyingTopTypes),
71
77
  semanticsVerifier: new ClientSemanticsVerifier(project),
72
78
  };
79
+ components.set(key, built);
73
80
  }
74
- return components;
81
+ return built;
82
+ }
83
+ /** One bundle answer from the answers of several programs. */
84
+ function mergeBundles(results) {
85
+ const symbol_failures = results.flatMap((r) => r.symbol_failures ?? []);
86
+ const answered = results.filter((r) => r.success);
87
+ if (answered.length === 0) {
88
+ return {
89
+ success: false,
90
+ symbol_failures,
91
+ errors: [...new Set(results.flatMap((r) => r.errors ?? []))],
92
+ };
93
+ }
94
+ return {
95
+ success: true,
96
+ dts_content: answered.map((r) => r.dts_content ?? '').join('\n'),
97
+ manifest: answered.flatMap((r) => r.manifest ?? []),
98
+ symbol_failures: symbol_failures.length > 0 ? symbol_failures : undefined,
99
+ };
100
+ }
101
+ /**
102
+ * Split a request's items by the project that types their file, keeping each
103
+ * group in request order. One group, the default project's, for a service
104
+ * whose tsconfig references nothing.
105
+ */
106
+ function byProject(items, fileOf) {
107
+ // An empty request is still answered by the default project, as before.
108
+ const groups = new Map(items.length === 0 ? [['', []]] : []);
109
+ for (const item of items) {
110
+ const key = projectLoader?.projectKeyFor(fileOf(item)) ?? '';
111
+ groups.set(key, [...(groups.get(key) ?? []), item]);
112
+ }
113
+ return groups;
75
114
  }
76
115
  // ===========================================================================
77
116
  // Request Handlers
@@ -85,7 +124,7 @@ function handleInit(request) {
85
124
  log(`Initializing with repo_root: ${request.repo_root}`);
86
125
  // Re-init re-scopes the sidecar to another root: drop everything built
87
126
  // over the previous project before resolving the new one.
88
- components = null;
127
+ components = new Map();
89
128
  projectLoader = new ProjectLoader({
90
129
  repoRoot: request.repo_root,
91
130
  tsconfigPath: request.tsconfig_path,
@@ -131,7 +170,10 @@ function handleInit(request) {
131
170
  function handleBundle(request) {
132
171
  try {
133
172
  log(`Bundling ${request.symbols.length} symbol(s)`);
134
- const result = projectComponents().typeBundler.bundle(request.symbols);
173
+ // A symbol named by a path is bundled from the program of the project
174
+ // that owns that file (carrick#1604).
175
+ const results = [...byProject(request.symbols, (symbol) => symbol.source_file)].map(([key, symbols]) => projectComponents(key).typeBundler.bundle(symbols));
176
+ const result = results.length === 1 ? results[0] : mergeBundles(results);
135
177
  if (!result.success) {
136
178
  return {
137
179
  request_id: request.request_id,
@@ -277,7 +319,18 @@ async function handleCheckV2Async(request) {
277
319
  function handleInfer(request) {
278
320
  try {
279
321
  log(`Inferring ${request.requests.length} type(s)`);
280
- const result = projectComponents().typeInferrer.infer(request.requests, request.extraction_config);
322
+ const results = [...byProject(request.requests, (item) => item.file_path)].map(([key, items]) => projectComponents(key).typeInferrer.infer(items, request.extraction_config));
323
+ const result = results.length === 1
324
+ ? results[0]
325
+ : (() => {
326
+ const inferred_types = results.flatMap((r) => r.inferred_types ?? []);
327
+ const errors = results.flatMap((r) => r.errors ?? []);
328
+ return {
329
+ success: errors.length === 0 || inferred_types.length > 0,
330
+ inferred_types,
331
+ errors: errors.length > 0 ? errors : undefined,
332
+ };
333
+ })();
281
334
  return {
282
335
  request_id: request.request_id,
283
336
  status: result.success ? 'success' : 'error',
@@ -307,7 +360,19 @@ const RETYPE_BUDGET_MS = 600_000;
307
360
  function handleRetypeCheck(request) {
308
361
  try {
309
362
  log(`Retyping ${request.items.length} consumer call(s)`);
310
- const outcomes = projectComponents().retyper.run(request.items, request.budget_ms ?? RETYPE_BUDGET_MS);
363
+ const budget = request.budget_ms ?? RETYPE_BUDGET_MS;
364
+ const groups = byProject(request.items.map((item, index) => ({ item, index })), ({ item }) => item.file_path);
365
+ const deadline = performance.now() + budget;
366
+ const outcomes = new Array(request.items.length);
367
+ for (const [key, entries] of groups) {
368
+ // One budget for the request, whichever programs it spans.
369
+ const remaining = groups.size === 1 ? budget : Math.max(0, deadline - performance.now());
370
+ projectComponents(key)
371
+ .retyper.run(entries.map(({ item }) => item), remaining)
372
+ .forEach((outcome, position) => {
373
+ outcomes[entries[position].index] = outcome;
374
+ });
375
+ }
311
376
  return { request_id: request.request_id, status: 'success', outcomes };
312
377
  }
313
378
  catch (err) {
@@ -0,0 +1,32 @@
1
+ /**
2
+ * How the init'd project resolves an import: in the mode the compiler picks
3
+ * for the file that imports it (carrick#1619).
4
+ *
5
+ * Under `module` `node16`..`nodenext` the compiler reads each file's own
6
+ * format (its nearest `package.json` `"type"`, or its extension) to decide
7
+ * whether an import resolves with the `import` or the `require` export
8
+ * conditions. The program asks its host to create each file with that format,
9
+ * but ts-morph's host creates every file with the script target only, so no
10
+ * file had a format and every import resolved as a `require`. A package whose
11
+ * `exports` entry only has an `import` branch did not resolve at all, while
12
+ * the capture, which uses the compiler directly, resolved the same import.
13
+ *
14
+ * This resolution host gives the importing file its format before its imports
15
+ * are resolved, then resolves each import as the compiler's default does:
16
+ * `ts.resolveModuleName` in the mode `ts.getModeForUsageLocation` reads off
17
+ * that import. The format is only set under `node16`..`nodenext`. Under other
18
+ * `module` settings the compiler still takes an import's mode from its syntax
19
+ * (`import`, `require`, a `resolution-mode` attribute) and reads a file's
20
+ * format only in narrow cases; setting it there made answers worse, because
21
+ * ts-morph's TypeScript copy shares one resolution-cache entry across modes
22
+ * under Bundler (carrick#1632), so those settings keep their old modes.
23
+ *
24
+ * The hook receives one entry per import, by module name, so one name
25
+ * imported twice in different modes (a `require` beside an `import`) needs
26
+ * each entry matched to its own import. It is, by position, when every import
27
+ * of the name is being resolved; when only some of them are (the compiler
28
+ * reusing earlier answers) and their modes differ, the entry cannot be placed
29
+ * and is left unresolved rather than given another import's mode.
30
+ */
31
+ import { type ResolutionHostFactory } from 'ts-morph';
32
+ export declare const moduleFormatResolutionHost: ResolutionHostFactory;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * How the init'd project resolves an import: in the mode the compiler picks
3
+ * for the file that imports it (carrick#1619).
4
+ *
5
+ * Under `module` `node16`..`nodenext` the compiler reads each file's own
6
+ * format (its nearest `package.json` `"type"`, or its extension) to decide
7
+ * whether an import resolves with the `import` or the `require` export
8
+ * conditions. The program asks its host to create each file with that format,
9
+ * but ts-morph's host creates every file with the script target only, so no
10
+ * file had a format and every import resolved as a `require`. A package whose
11
+ * `exports` entry only has an `import` branch did not resolve at all, while
12
+ * the capture, which uses the compiler directly, resolved the same import.
13
+ *
14
+ * This resolution host gives the importing file its format before its imports
15
+ * are resolved, then resolves each import as the compiler's default does:
16
+ * `ts.resolveModuleName` in the mode `ts.getModeForUsageLocation` reads off
17
+ * that import. The format is only set under `node16`..`nodenext`. Under other
18
+ * `module` settings the compiler still takes an import's mode from its syntax
19
+ * (`import`, `require`, a `resolution-mode` attribute) and reads a file's
20
+ * format only in narrow cases; setting it there made answers worse, because
21
+ * ts-morph's TypeScript copy shares one resolution-cache entry across modes
22
+ * under Bundler (carrick#1632), so those settings keep their old modes.
23
+ *
24
+ * The hook receives one entry per import, by module name, so one name
25
+ * imported twice in different modes (a `require` beside an `import`) needs
26
+ * each entry matched to its own import. It is, by position, when every import
27
+ * of the name is being resolved; when only some of them are (the compiler
28
+ * reusing earlier answers) and their modes differ, the entry cannot be placed
29
+ * and is left unresolved rather than given another import's mode.
30
+ */
31
+ import { ts } from 'ts-morph';
32
+ /** Whether the compiler decides an import's mode from the importing file's format. */
33
+ function formatDecidesMode(options) {
34
+ const kind = options.module;
35
+ return kind !== undefined && kind >= ts.ModuleKind.Node16 && kind <= ts.ModuleKind.NodeNext;
36
+ }
37
+ /**
38
+ * The literals in `file` the program resolves, by module name and in source
39
+ * order: its imports, then its module augmentations. Neither list is in the
40
+ * public declaration file.
41
+ */
42
+ function usagesOf(file) {
43
+ const internal = file;
44
+ const usages = new Map();
45
+ const add = (literal) => {
46
+ usages.set(literal.text, [...(usages.get(literal.text) ?? []), literal]);
47
+ };
48
+ for (const literal of internal.imports ?? [])
49
+ add(literal);
50
+ for (const literal of internal.moduleAugmentations ?? []) {
51
+ if (ts.isStringLiteral(literal))
52
+ add(literal);
53
+ }
54
+ return usages;
55
+ }
56
+ /** Why a module name cannot be given one mode: see the header. */
57
+ const UNPLACED = Symbol('unplaced');
58
+ export const moduleFormatResolutionHost = (host, getOptions) => {
59
+ let cache;
60
+ let cacheOptions;
61
+ const cacheFor = (options) => {
62
+ if (!cache || cacheOptions !== options) {
63
+ cache = ts.createModuleResolutionCache(process.cwd(), (fileName) => fileName, options);
64
+ cacheOptions = options;
65
+ }
66
+ return cache;
67
+ };
68
+ return {
69
+ resolveModuleNames: (moduleNames, containingFile, _reusedNames, redirectedReference, options, containingSourceFile) => {
70
+ const compilerOptions = options ?? getOptions();
71
+ const resolutionCache = cacheFor(compilerOptions);
72
+ if (containingSourceFile &&
73
+ containingSourceFile.impliedNodeFormat === undefined &&
74
+ formatDecidesMode(compilerOptions)) {
75
+ containingSourceFile.impliedNodeFormat =
76
+ ts.getImpliedNodeFormatForFile(containingFile, resolutionCache.getPackageJsonInfoCache(), host, compilerOptions);
77
+ }
78
+ const usages = containingSourceFile ? usagesOf(containingSourceFile) : undefined;
79
+ const asked = new Map();
80
+ for (const name of moduleNames)
81
+ asked.set(name, (asked.get(name) ?? 0) + 1);
82
+ const seen = new Map();
83
+ const modeOf = (name) => {
84
+ const occurrence = seen.get(name) ?? 0;
85
+ seen.set(name, occurrence + 1);
86
+ const literals = usages?.get(name) ?? [];
87
+ if (!containingSourceFile || literals.length === 0)
88
+ return undefined;
89
+ const modes = literals.map((literal) => ts.getModeForUsageLocation(containingSourceFile, literal, compilerOptions));
90
+ if (new Set(modes).size === 1)
91
+ return modes[0];
92
+ return asked.get(name) === literals.length ? modes[occurrence] : UNPLACED;
93
+ };
94
+ return moduleNames.map((name) => {
95
+ const mode = modeOf(name);
96
+ if (mode === UNPLACED)
97
+ return undefined;
98
+ return ts.resolveModuleName(name, containingFile, compilerOptions, host, resolutionCache, redirectedReference, mode).resolvedModule;
99
+ });
100
+ },
101
+ };
102
+ };
@@ -61,6 +61,12 @@ export declare class ProjectLoader {
61
61
  * build, so it is there from the first `getProject()` onwards.
62
62
  */
63
63
  private denoProject;
64
+ /** The tsconfig the service names, when the project is built from one. */
65
+ private namedTsconfigPath;
66
+ /** Each file's owning config (carrick#1604), resolved on first use. */
67
+ private configPathFor;
68
+ /** Programs built for owning projects other than the named tsconfig. */
69
+ private readonly ownerProjects;
64
70
  private readonly repoRoot;
65
71
  private readonly tsconfigPath;
66
72
  private readonly tsconfigSnapshot;
@@ -82,6 +88,24 @@ export declare class ProjectLoader {
82
88
  * Convert a TsconfigSnapshot to ts-morph CompilerOptions
83
89
  */
84
90
  private snapshotToCompilerOptions;
91
+ /** A ts-morph project built from one tsconfig and the files it lists. */
92
+ private projectFromConfig;
93
+ /**
94
+ * Which project types `file` (carrick#1604): the key of its owning
95
+ * project's program, or `''` for the default one. A tsconfig that
96
+ * references other projects owns only the files it lists itself; any other
97
+ * file belongs to the first project it references (depth-first, in
98
+ * declared order) whose file list includes it, and is typed under that
99
+ * project's options. A file no project includes, and every request that
100
+ * names no file, uses the default project, built from the named tsconfig
101
+ * as before.
102
+ */
103
+ projectKeyFor(file?: string): string;
104
+ /**
105
+ * The project behind a key from `projectKeyFor`, built on first use and
106
+ * kept: a service whose files all belong to one project builds one.
107
+ */
108
+ getProjectFor(key: string): Project;
85
109
  /**
86
110
  * Get the ts-morph Project, building it on first call.
87
111
  *
@@ -12,7 +12,8 @@
12
12
  import { Project } from 'ts-morph';
13
13
  import * as path from 'node:path';
14
14
  import * as fs from 'node:fs';
15
- import { DenoProject, findDenoConfig } from './capture/index.js';
15
+ import { DenoProject, findDenoConfig, serviceConfigPath } from './capture/index.js';
16
+ import { moduleFormatResolutionHost } from './module-format.js';
16
17
  /**
17
18
  * Source-file patterns used when the repo declares no tsconfig, relative to
18
19
  * the repo root. `node_modules` is excluded explicitly: a glob that matches
@@ -115,6 +116,12 @@ export class ProjectLoader {
115
116
  * build, so it is there from the first `getProject()` onwards.
116
117
  */
117
118
  denoProject;
119
+ /** The tsconfig the service names, when the project is built from one. */
120
+ namedTsconfigPath;
121
+ /** Each file's owning config (carrick#1604), resolved on first use. */
122
+ configPathFor;
123
+ /** Programs built for owning projects other than the named tsconfig. */
124
+ ownerProjects = new Map();
118
125
  repoRoot;
119
126
  tsconfigPath;
120
127
  tsconfigSnapshot;
@@ -200,10 +207,8 @@ export class ProjectLoader {
200
207
  }
201
208
  else if (tsconfigPath) {
202
209
  this.log(`Project will load with tsconfig: ${tsconfigPath}`);
203
- this.buildProject = () => new Project({
204
- tsConfigFilePath: tsconfigPath,
205
- skipAddingFilesFromTsConfig: false,
206
- });
210
+ this.namedTsconfigPath = tsconfigPath;
211
+ this.buildProject = () => this.projectFromConfig(tsconfigPath);
207
212
  }
208
213
  else {
209
214
  this.log('No tsconfig.json found, using default compiler options');
@@ -306,6 +311,49 @@ export class ProjectLoader {
306
311
  }
307
312
  return result;
308
313
  }
314
+ /** A ts-morph project built from one tsconfig and the files it lists. */
315
+ projectFromConfig(configPath) {
316
+ return new Project({
317
+ tsConfigFilePath: configPath,
318
+ skipAddingFilesFromTsConfig: false,
319
+ // Each import resolves in its file's own module format (carrick#1619).
320
+ resolutionHost: moduleFormatResolutionHost,
321
+ });
322
+ }
323
+ /**
324
+ * Which project types `file` (carrick#1604): the key of its owning
325
+ * project's program, or `''` for the default one. A tsconfig that
326
+ * references other projects owns only the files it lists itself; any other
327
+ * file belongs to the first project it references (depth-first, in
328
+ * declared order) whose file list includes it, and is typed under that
329
+ * project's options. A file no project includes, and every request that
330
+ * names no file, uses the default project, built from the named tsconfig
331
+ * as before.
332
+ */
333
+ projectKeyFor(file) {
334
+ if (!this.namedTsconfigPath || file === undefined)
335
+ return '';
336
+ this.configPathFor ??= serviceConfigPath(this.namedTsconfigPath, (d) => this.logError(d));
337
+ const config = this.configPathFor(path.resolve(this.repoRoot, file));
338
+ return config === this.namedTsconfigPath ? '' : config;
339
+ }
340
+ /**
341
+ * The project behind a key from `projectKeyFor`, built on first use and
342
+ * kept: a service whose files all belong to one project builds one.
343
+ */
344
+ getProjectFor(key) {
345
+ if (key === '')
346
+ return this.getProject();
347
+ let project = this.ownerProjects.get(key);
348
+ if (!project) {
349
+ const startTime = performance.now();
350
+ project = this.projectFromConfig(key);
351
+ this.ownerProjects.set(key, project);
352
+ this.log(`Project for ${key}, the project that owns the requested files, built in ` +
353
+ `${Math.round(performance.now() - startTime)}ms (${project.getSourceFiles().length} source files)`);
354
+ }
355
+ return project;
356
+ }
309
357
  /**
310
358
  * Get the ts-morph Project, building it on first call.
311
359
  *
@@ -685,8 +685,9 @@ export interface InferredType {
685
685
  * anchor symbol has a resolvable source declaration. Lets the scanner's
686
686
  * pub/sub two-anchor arbitration (carrick#413) re-aim a demoted explicit
687
687
  * `SymbolRequest` at the tsc-witnessed payload type: the bundler requires
688
- * the symbol to be DECLARED in the request's `source_file`, and the
689
- * inference is the only party that knows where that is. Reported only by
688
+ * the symbol to be declared in, or re-exported by, the request's
689
+ * `source_file`, and the inference is the only party that knows where that
690
+ * is. Reported only by
690
691
  * the pub/sub infer kinds (`function_param`, `expression`); other kinds
691
692
  * omit it.
692
693
  */