Support recursive extended tsconfigs

Closes #265

PiperOrigin-RevId: 235554123
diff --git a/internal/tsc_wrapped/tsconfig.ts b/internal/tsc_wrapped/tsconfig.ts
index 512b314..f8460c3 100644
--- a/internal/tsc_wrapped/tsconfig.ts
+++ b/internal/tsc_wrapped/tsconfig.ts
@@ -278,12 +278,76 @@
   // TypeScript expects an absolute path for the tsconfig.json file
   tsconfigFile = resolveNormalizedPath(tsconfigFile);
 
-  const {config, error} = ts.readConfigFile(tsconfigFile, host.readFile);
+  const isUndefined = (value: any): value is undefined => value === undefined;
+
+  // Handle bazel specific options, but make sure not to crash when reading a
+  // vanilla tsconfig.json.
+
+  const readExtendedConfigFile =
+    (configFile: string, existingConfig?: any): {config?: any, error?: ts.Diagnostic} => {
+      const {config, error} = ts.readConfigFile(configFile, host.readFile);
+
+      if (error) {
+        return {error};
+      }
+
+      // Allow Bazel users to control some of the bazel options.
+      // Since TypeScript's "extends" mechanism applies only to "compilerOptions"
+      // we have to repeat some of their logic to get the user's bazelOptions.
+      const mergedConfig = existingConfig || config;
+
+      if (existingConfig) {
+        const existingBazelOpts: BazelOptions = existingConfig.bazelOptions || {};
+        const newBazelBazelOpts: BazelOptions = config.bazelOptions || {};
+
+        mergedConfig.bazelOptions = {
+          ...existingBazelOpts,
+
+          disableStrictDeps: isUndefined(existingBazelOpts.disableStrictDeps)
+            ? newBazelBazelOpts.disableStrictDeps
+            : existingBazelOpts.disableStrictDeps,
+
+          suppressTsconfigOverrideWarnings: isUndefined(existingBazelOpts.suppressTsconfigOverrideWarnings)
+            ? newBazelBazelOpts.suppressTsconfigOverrideWarnings
+            : existingBazelOpts.suppressTsconfigOverrideWarnings,
+
+          tsickle: isUndefined(existingBazelOpts.tsickle)
+            ? newBazelBazelOpts.tsickle
+            : existingBazelOpts.tsickle,
+
+          googmodule: isUndefined(existingBazelOpts.googmodule)
+            ? newBazelBazelOpts.googmodule
+            : existingBazelOpts.googmodule,
+
+          devmodeTargetOverride: isUndefined(existingBazelOpts.devmodeTargetOverride)
+            ? newBazelBazelOpts.devmodeTargetOverride
+            : existingBazelOpts.devmodeTargetOverride,
+        }
+
+        if (!mergedConfig.bazelOptions.suppressTsconfigOverrideWarnings) {
+          warnOnOverriddenOptions(config);
+        }
+      }
+
+      if (config.extends) {
+        let extendedConfigPath = resolveNormalizedPath(path.dirname(configFile), config.extends);
+        if (!extendedConfigPath.endsWith('.json')) extendedConfigPath += '.json';
+
+        return readExtendedConfigFile(extendedConfigPath, mergedConfig);
+      }
+
+      return {config: mergedConfig};
+    };
+
+  const {config, error} = readExtendedConfigFile(tsconfigFile);
   if (error) {
     // target is in the config file we failed to load...
     return [null, [error], {target: ''}];
   }
 
+  const {options, errors, fileNames} =
+    ts.parseJsonConfigFileContent(config, host, path.dirname(tsconfigFile));
+
   // Handle bazel specific options, but make sure not to crash when reading a
   // vanilla tsconfig.json.
   const bazelOpts: BazelOptions = config.bazelOptions || {};
@@ -292,37 +356,7 @@
   bazelOpts.typeBlackListPaths = bazelOpts.typeBlackListPaths || [];
   bazelOpts.compilationTargetSrc = bazelOpts.compilationTargetSrc || [];
 
-  // Allow Bazel users to control some of the bazel options.
-  // Since TypeScript's "extends" mechanism applies only to "compilerOptions"
-  // we have to repeat some of their logic to get the user's bazelOptions.
-  if (config.extends) {
-    let userConfigFile =
-        resolveNormalizedPath(path.dirname(tsconfigFile), config.extends);
-    if (!userConfigFile.endsWith('.json')) userConfigFile += '.json';
-    const {config: userConfig, error} =
-        ts.readConfigFile(userConfigFile, host.readFile);
-    if (error) {
-      return [null, [error], {target}];
-    }
-    if (userConfig.bazelOptions) {
-      bazelOpts.disableStrictDeps = bazelOpts.disableStrictDeps ||
-          userConfig.bazelOptions.disableStrictDeps;
-      bazelOpts.suppressTsconfigOverrideWarnings =
-          bazelOpts.suppressTsconfigOverrideWarnings ||
-          userConfig.bazelOptions.suppressTsconfigOverrideWarnings;
-      bazelOpts.tsickle = bazelOpts.tsickle || userConfig.bazelOptions.tsickle;
-      bazelOpts.googmodule =
-          bazelOpts.googmodule || userConfig.bazelOptions.googmodule;
-      bazelOpts.devmodeTargetOverride = bazelOpts.devmodeTargetOverride ||
-          userConfig.bazelOptions.devmodeTargetOverride;
-    }
-    if (!bazelOpts.suppressTsconfigOverrideWarnings) {
-      warnOnOverriddenOptions(userConfig);
-    }
-  }
 
-  const {options, errors, fileNames} =
-      ts.parseJsonConfigFileContent(config, host, path.dirname(tsconfigFile));
   if (errors && errors.length) {
     return [null, errors, {target}];
   }
diff --git a/internal/tsc_wrapped/tsconfig_test.ts b/internal/tsc_wrapped/tsconfig_test.ts
index 63d3e7b..eac3ffe 100644
--- a/internal/tsc_wrapped/tsconfig_test.ts
+++ b/internal/tsc_wrapped/tsconfig_test.ts
@@ -57,4 +57,60 @@
       expect(bazelOpts.disableStrictDeps).toBeTruthy();
     }
   });
+
+  it('honors bazelOptions in recursive extends', ()=> {
+    const tsconfigOne = {
+      extends: './tsconfig-level-b.json',
+      files: ['a.ts'],
+      bazelOptions: {
+        disableStrictDeps: false
+      }
+    };
+
+    const tsconfigTwo = {
+      extends: './tsconfig-level-c.json',
+      bazelOptions: {
+        suppressTsconfigOverrideWarnings: true
+      }
+    };
+
+    const tsconfigThree = {
+      bazelOptions: {
+        tsickle: true,
+        suppressTsconfigOverrideWarnings: false,
+        disableStrictDeps: true
+      }
+    };
+  
+    const files = {
+      [resolveNormalizedPath('path/to/tsconfig-level-a.json')]: JSON.stringify(tsconfigOne),
+      [resolveNormalizedPath('path/to/tsconfig-level-b.json')]: JSON.stringify(tsconfigTwo),
+      [resolveNormalizedPath('path/to/tsconfig-level-c.json')]: JSON.stringify(tsconfigThree),
+    };
+
+    const host: ts.ParseConfigHost = {
+      useCaseSensitiveFileNames: true,
+      fileExists: (path: string) => !!files[path],
+      readFile: (path: string) => files[path],
+      readDirectory(
+          rootDir: string, extensions: ReadonlyArray<string>,
+          excludes: ReadonlyArray<string>, includes: ReadonlyArray<string>,
+          depth: number): string[] {
+            return [];
+      },
+    };
+
+    const [parsed, diagnostics] =
+        parseTsconfig('path/to/tsconfig-level-a.json', host);
+    expect(diagnostics).toBeNull();
+
+    if (!parsed) {
+      fail('Expected parsed');
+    } else {
+      const {bazelOpts} = parsed;
+      expect(bazelOpts.tsickle).toBeTruthy();
+      expect(bazelOpts.suppressTsconfigOverrideWarnings).toBeTruthy();
+      expect(bazelOpts.disableStrictDeps).toBeFalsy();
+    }
+  })
 });