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 +6 -6
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/sidecar/dist/src/bundler.d.ts +2 -0
- package/sidecar/dist/src/bundler.js +115 -39
- package/sidecar/dist/src/capture/index.d.ts +1 -0
- package/sidecar/dist/src/capture/index.js +91 -10
- package/sidecar/dist/src/capture/project-references.d.ts +67 -0
- package/sidecar/dist/src/capture/project-references.js +157 -0
- package/sidecar/dist/src/index.js +75 -10
- package/sidecar/dist/src/module-format.d.ts +32 -0
- package/sidecar/dist/src/module-format.js +102 -0
- package/sidecar/dist/src/project-loader.d.ts +24 -0
- package/sidecar/dist/src/project-loader.js +53 -5
- package/sidecar/dist/src/types.d.ts +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "carrick",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
62
|
-
"@carrick-tools/cli-darwin-x64": "0.3.
|
|
63
|
-
"@carrick-tools/cli-linux-arm64": "0.3.
|
|
64
|
-
"@carrick-tools/cli-linux-x64": "0.3.
|
|
65
|
-
"@carrick-tools/cli-win32-x64": "0.3.
|
|
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.
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
if (sourceFile
|
|
441
|
-
return
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
529
|
-
|
|
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 =
|
|
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
|
|
550
|
-
|
|
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
|
-
|
|
557
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
204
|
-
|
|
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
|
|
689
|
-
* inference is the only party that knows where that
|
|
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
|
*/
|