Skip to content

Commit 7e8fb14

Browse files
committed
TS: Support tsconfig.json extending from ./node_modules
1 parent 5719b44 commit 7e8fb14

6 files changed

Lines changed: 127 additions & 41 deletions

File tree

javascript/extractor/lib/typescript/src/common.ts

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as ts from "./typescript";
22
import { TypeTable } from "./type_table";
33
import * as pathlib from "path";
4+
import { VirtualSourceRoot } from "./virtual_source_root";
45

56
/**
67
* Extracts the package name from the prefix of an import string.
@@ -12,21 +13,16 @@ export class Project {
1213
public program: ts.Program = null;
1314
private host: ts.CompilerHost;
1415
private resolutionCache: ts.ModuleResolutionCache;
15-
private sourceRoot: string;
16-
/** Directory whose folder structure mirrors the real source root, but with `node_modules` installed. */
17-
private virtualSourceRoot: string;
1816

19-
constructor(public tsConfig: string, public config: ts.ParsedCommandLine, public typeTable: TypeTable, public packageLocations: PackageLocationMap) {
17+
constructor(public tsConfig: string, public config: ts.ParsedCommandLine, public typeTable: TypeTable, public packageLocations: PackageLocationMap,
18+
public virtualSourceRoot: VirtualSourceRoot) {
2019
this.resolveModuleNames = this.resolveModuleNames.bind(this);
2120

2221
this.resolutionCache = ts.createModuleResolutionCache(pathlib.dirname(tsConfig), ts.sys.realpath, config.options);
2322
let host = ts.createCompilerHost(config.options, true);
2423
host.resolveModuleNames = this.resolveModuleNames;
2524
host.trace = undefined; // Disable tracing which would otherwise go to standard out
2625
this.host = host;
27-
28-
this.sourceRoot = process.cwd();
29-
this.virtualSourceRoot = process.env["CODEQL_EXTRACTOR_JAVASCRIPT_SCRATCH_DIR"];
3026
}
3127

3228
public unload(): void {
@@ -83,7 +79,7 @@ export class Project {
8379
if (packageEntryPoint == null) {
8480
// The package is not overridden, but we have established that it begins with a valid package name.
8581
// Do a lookup in the virtual source root (where dependencies are installed) by changing the 'containing file'.
86-
let virtualContainingFile = this.toVirtualPath(containingFile);
82+
let virtualContainingFile = this.virtualSourceRoot.toVirtualPath(containingFile);
8783
if (virtualContainingFile != null) {
8884
return ts.resolveModuleName(moduleName, virtualContainingFile, options, this.host, this.resolutionCache).resolvedModule;
8985
}
@@ -119,15 +115,6 @@ export class Project {
119115

120116
return null;
121117
}
122-
123-
/**
124-
* Maps a path under the real source root to the corresonding path in the virtual source root.
125-
*/
126-
private toVirtualPath(path: string) {
127-
let relative = pathlib.relative(this.sourceRoot, path);
128-
if (relative.startsWith('..') || pathlib.isAbsolute(relative)) return null;
129-
return pathlib.join(this.virtualSourceRoot, relative);
130-
}
131118
}
132119

133120
export type PackageLocationMap = Map<string, string>;

javascript/extractor/lib/typescript/src/main.ts

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@ import * as readline from "readline";
3737
import * as ts from "./typescript";
3838
import * as ast_extractor from "./ast_extractor";
3939

40-
import { Project } from "./common";
40+
import { Project, PackageLocationMap } from "./common";
4141
import { TypeTable } from "./type_table";
42+
import { VirtualSourceRoot } from "./virtual_source_root";
4243

4344
interface ParseCommand {
4445
command: "parse";
@@ -47,7 +48,8 @@ interface ParseCommand {
4748
interface OpenProjectCommand {
4849
command: "open-project";
4950
tsConfig: string;
50-
packageLocations: [string, string][];
51+
packageEntryPoints: [string, string][];
52+
packageJsonFiles: [string, string][];
5153
}
5254
interface CloseProjectCommand {
5355
command: "close-project";
@@ -243,20 +245,62 @@ function parseSingleFile(filename: string): {ast: ts.SourceFile, code: string} {
243245
return {ast, code};
244246
}
245247

248+
const nodeModulesRex = /[/\\]node_modules[/\\]((?:@[\w.-]+[/\\])?\w[\w.-]*)[/\\](.*)/;
249+
246250
function handleOpenProjectCommand(command: OpenProjectCommand) {
247251
Error.stackTraceLimit = Infinity;
248252
let tsConfigFilename = String(command.tsConfig);
249253
let tsConfig = ts.readConfigFile(tsConfigFilename, ts.sys.readFile);
250254
let basePath = pathlib.dirname(tsConfigFilename);
251255

256+
let packageEntryPoints = new Map(command.packageEntryPoints);
257+
let packageJsonFiles = new Map(command.packageJsonFiles);
258+
let virtualSourceRoot = new VirtualSourceRoot(process.cwd(), process.env["CODEQL_EXTRACTOR_JAVASCRIPT_SCRATCH_DIR"]);
259+
260+
/**
261+
* Rewrites path segments of form `node_modules/PACK/suffix` to be relative to
262+
* the location of package PACK in the source tree, if it exists.
263+
*/
264+
function redirectNodeModulesPath(path: string) {
265+
let nodeModulesMatch = nodeModulesRex.exec(path);
266+
if (nodeModulesMatch == null) return null;
267+
let packageName = nodeModulesMatch[1];
268+
let packageJsonFile = packageJsonFiles.get(packageName);
269+
if (packageJsonFile == null) return null;
270+
let packageDir = pathlib.dirname(packageJsonFile);
271+
let suffix = nodeModulesMatch[2];
272+
let finalPath = pathlib.join(packageDir, suffix);
273+
if (!ts.sys.fileExists(finalPath)) return null;
274+
return finalPath;
275+
}
276+
277+
/**
278+
* Create the host passed to the tsconfig.json parser.
279+
*
280+
* We override its file system access in case there is an "extends"
281+
* clause pointing into "./node_modules", which must be redirected to
282+
* the location of an installed package or a checked-in package.
283+
*/
252284
let parseConfigHost: ts.ParseConfigHost = {
253285
useCaseSensitiveFileNames: true,
254-
readDirectory: ts.sys.readDirectory,
255-
fileExists: (path: string) => fs.existsSync(path),
256-
readFile: ts.sys.readFile,
286+
readDirectory: ts.sys.readDirectory, // No need to override traversal/glob matching
287+
fileExists: (path: string) => {
288+
return ts.sys.fileExists(path)
289+
|| virtualSourceRoot.toVirtualPathIfFileExists(path) != null
290+
|| redirectNodeModulesPath(path) != null;
291+
},
292+
readFile: (path: string) => {
293+
if (!fs.existsSync(path)) {
294+
let virtualPath = virtualSourceRoot.toVirtualPathIfFileExists(path);
295+
if (virtualPath != null) return ts.sys.readFile(virtualPath);
296+
virtualPath = redirectNodeModulesPath(path);
297+
if (virtualPath != null) return ts.sys.readFile(virtualPath);
298+
}
299+
return ts.sys.readFile(path);
300+
}
257301
};
258302
let config = ts.parseJsonConfigFileContent(tsConfig.config, parseConfigHost, basePath);
259-
let project = new Project(tsConfigFilename, config, state.typeTable, new Map(command.packageLocations));
303+
let project = new Project(tsConfigFilename, config, state.typeTable, packageEntryPoints, virtualSourceRoot);
260304
project.load();
261305

262306
state.project = project;
@@ -530,7 +574,8 @@ if (process.argv.length > 2) {
530574
handleOpenProjectCommand({
531575
command: "open-project",
532576
tsConfig: argument,
533-
packageLocations: [],
577+
packageEntryPoints: [],
578+
packageJsonFiles: [],
534579
});
535580
for (let sf of state.project.program.getSourceFiles()) {
536581
if (pathlib.basename(sf.fileName) === "lib.d.ts") continue;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import * as pathlib from "path";
2+
import * as ts from "./typescript";
3+
4+
/**
5+
* Mapping from the source root to the virtual source root.
6+
*/
7+
export class VirtualSourceRoot {
8+
constructor(
9+
private sourceRoot: string,
10+
11+
/** Directory whose folder structure mirrors the real source root, but with `node_modules` installed. */
12+
private virtualSourceRoot: string,
13+
) {}
14+
15+
/**
16+
* Maps a path under the real source root to the corresonding path in the virtual source root.
17+
*/
18+
public toVirtualPath(path: string) {
19+
let relative = pathlib.relative(this.sourceRoot, path);
20+
if (relative.startsWith('..') || pathlib.isAbsolute(relative)) return null;
21+
return pathlib.join(this.virtualSourceRoot, relative);
22+
}
23+
24+
/**
25+
* Maps a path under the real source root to the corresonding path in the virtual source root.
26+
*/
27+
public toVirtualPathIfFileExists(path: string) {
28+
let virtualPath = this.toVirtualPath(path);
29+
if (virtualPath != null && ts.sys.fileExists(virtualPath)) {
30+
return virtualPath;
31+
}
32+
return null;
33+
}
34+
}

javascript/extractor/src/com/semmle/js/extractor/AutoBuild.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -782,7 +782,7 @@ protected DependencyInstallationResult installDependencies(Set<Path> filesToExtr
782782
}
783783
}
784784

785-
return new DependencyInstallationResult(packageMainFile);
785+
return new DependencyInstallationResult(packageMainFile, packagesInRepo);
786786
}
787787

788788
/**

javascript/extractor/src/com/semmle/js/extractor/DependencyInstallationResult.java

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,31 @@
66

77
/** Contains the results of installing dependencies. */
88
public class DependencyInstallationResult {
9-
private Map<String, Path> packageLocations;
9+
private Map<String, Path> packageEntryPoints;
10+
private Map<String, Path> packageJsonFiles;
1011

1112
public static final DependencyInstallationResult empty =
12-
new DependencyInstallationResult(Collections.emptyMap());
13+
new DependencyInstallationResult(Collections.emptyMap(), Collections.emptyMap());
1314

14-
public DependencyInstallationResult(Map<String, Path> localPackages) {
15-
this.packageLocations = localPackages;
15+
public DependencyInstallationResult(
16+
Map<String, Path> packageEntryPoints,
17+
Map<String, Path> packageJsonFiles) {
18+
this.packageEntryPoints = packageEntryPoints;
19+
this.packageJsonFiles = packageJsonFiles;
1620
}
1721

1822
/**
1923
* Returns the mapping from package names to the TypeScript file that should
2024
* act as its main entry point.
2125
*/
22-
public Map<String, Path> getPackageLocations() {
23-
return packageLocations;
26+
public Map<String, Path> getPackageEntryPoints() {
27+
return packageEntryPoints;
28+
}
29+
30+
/**
31+
* Returns the mapping from package name to corresponding package.json.
32+
*/
33+
public Map<String, Path> getPackageJsonFiles() {
34+
return packageJsonFiles;
2435
}
2536
}

javascript/extractor/src/com/semmle/js/parser/TypeScriptParser.java

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@
1111
import java.io.OutputStream;
1212
import java.io.OutputStreamWriter;
1313
import java.lang.ProcessBuilder.Redirect;
14+
import java.nio.file.Path;
1415
import java.util.ArrayList;
1516
import java.util.Arrays;
1617
import java.util.Collections;
1718
import java.util.List;
19+
import java.util.Map;
1820

1921
import com.google.gson.JsonArray;
2022
import com.google.gson.JsonElement;
@@ -404,6 +406,21 @@ public void prepareFiles(List<File> files) {
404406
checkResponseType(response, "ok");
405407
}
406408

409+
/**
410+
* Converts a map to an array of [key, value] pairs.
411+
*/
412+
private JsonArray mapToArray(Map<String, Path> map) {
413+
JsonArray result = new JsonArray();
414+
map.forEach(
415+
(key, path) -> {
416+
JsonArray entry = new JsonArray();
417+
entry.add(key);
418+
entry.add(path.toString());
419+
result.add(entry);
420+
});
421+
return result;
422+
}
423+
407424
/**
408425
* Opens a new project based on a tsconfig.json file. The compiler will analyze all files in the
409426
* project.
@@ -416,16 +433,8 @@ public ParsedProject openProject(File tsConfigFile, DependencyInstallationResult
416433
JsonObject request = new JsonObject();
417434
request.add("command", new JsonPrimitive("open-project"));
418435
request.add("tsConfig", new JsonPrimitive(tsConfigFile.getPath()));
419-
JsonArray packageLocations = new JsonArray();
420-
deps.getPackageLocations()
421-
.forEach(
422-
(packageName, packageDir) -> {
423-
JsonArray entry = new JsonArray();
424-
entry.add(packageName);
425-
entry.add(packageDir.toString());
426-
packageLocations.add(entry);
427-
});
428-
request.add("packageLocations", packageLocations);
436+
request.add("packageEntryPoints", mapToArray(deps.getPackageEntryPoints()));
437+
request.add("packageJsonFiles", mapToArray(deps.getPackageJsonFiles()));
429438
JsonObject response = talkToParserWrapper(request);
430439
try {
431440
checkResponseType(response, "project-opened");

0 commit comments

Comments
 (0)