Repository: oclif/core (ts)

Issue:
loadTSConfig assume valid JSON
**Describe the bug**

The `loadTSConfig` function assumes `tsconfig.json` is JSON, however, `tsconfig.json` officially is a JSONC file, which allows for comments.

From: https://www.typescriptlang.org/docs/handbook/declaration-files/dts-from-js.html#tsconfig

> The TSConfig is a jsonc file ...

https://github.com/oclif/core/blob/7aec22efe81948780e78486080f494c9e108f74c/src/config/ts-node.ts#L18-L45

Fails with:

> Warning: Could not parse tsconfig.json for .... Falling back to compiled source.

**To Reproduce**

- Add a comment to `tsconfig.json`
- Run a command

**Expected behavior**

Allow comments.

**Screenshots**

N/A


Pull request:
fix: support jsonc tsconfig
Always use `tsconfck` again for parse tsconfig.json

Fixes #849 

Patch:
diff --git a/src/config/ts-node.ts b/src/config/ts-node.ts
index 28d2ed7..15c5267 100644
--- a/src/config/ts-node.ts
+++ b/src/config/ts-node.ts
@@ -4,43 +4,32 @@ import * as TSNode from 'ts-node'
 import {memoizedWarn} from '../errors'
 import {Plugin, TSConfig} from '../interfaces'
 import {settings} from '../settings'
-import {existsSync, readJson} from '../util/fs'
+import {existsSync, readTSConfig} from '../util/fs'
 import {isProd} from '../util/util'
 import Cache from './cache'
 import {Debug} from './util'
-
 // eslint-disable-next-line new-cap
 const debug = Debug('ts-node')
 
 export const TS_CONFIGS: Record<string, TSConfig> = {}
 const REGISTERED = new Set<string>()
 
+function isErrno(error: any): error is NodeJS.ErrnoException {
+  return 'code' in error && error.code === 'ENOENT'
+}
+
 async function loadTSConfig(root: string): Promise<TSConfig | undefined> {
   try {
     if (TS_CONFIGS[root]) return TS_CONFIGS[root]
-    const tsconfigPath = join(root, 'tsconfig.json')
-    const tsconfig = await readJson<TSConfig>(tsconfigPath)
-
-    if (!tsconfig || Object.keys(tsconfig.compilerOptions).length === 0) return
-
-    TS_CONFIGS[root] = tsconfig
 
-    if (tsconfig.extends) {
-      const {parse} = await import('tsconfck')
-      const result = await parse(tsconfigPath)
-      const tsNodeOpts = Object.fromEntries(
-        (result.extended ?? []).flatMap((e) => Object.entries(e.tsconfig['ts-node'] ?? {})).reverse(),
-      )
-
-      TS_CONFIGS[root] = {...result.tsconfig, 'ts-node': tsNodeOpts}
-    }
+    TS_CONFIGS[root] = await readTSConfig(join(root, 'tsconfig.json'))
 
     return TS_CONFIGS[root]
   } catch (error) {
-    if (error instanceof SyntaxError) {
-      debug(`Could not parse tsconfig.json. Skipping ts-node registration for ${root}.`)
-      memoizedWarn(`Could not parse tsconfig.json for ${root}. Falling back to compiled source.`)
-    }
+    if (isErrno(error)) return
+
+    debug(`Could not parse tsconfig.json. Skipping ts-node registration for ${root}.`)
+    memoizedWarn(`Could not parse tsconfig.json for ${root}. Falling back to compiled source.`)
   }
 }
 
diff --git a/src/util/fs.ts b/src/util/fs.ts
index 50e04a9..2afc495 100644
--- a/src/util/fs.ts
+++ b/src/util/fs.ts
@@ -69,3 +69,12 @@ export async function safeReadJson<T>(path: string): Promise<T | undefined> {
 export function existsSync(path: string): boolean {
   return fsExistsSync(path)
 }
+
+export async function readTSConfig(path: string) {
+  const {parse} = await import('tsconfck')
+  const result = await parse(path)
+  const tsNodeOpts = Object.fromEntries(
+    (result.extended ?? []).flatMap((e) => Object.entries(e.tsconfig['ts-node'] ?? {})).reverse(),
+  )
+  return {...result.tsconfig, 'ts-node': tsNodeOpts}
+}


Test patch:
diff --git a/test/config/ts-node.test.ts b/test/config/ts-node.test.ts
index 83ba986..e8470b1 100644
--- a/test/config/ts-node.test.ts
+++ b/test/config/ts-node.test.ts
@@ -1,10 +1,8 @@
 import {expect} from 'chai'
 import {join, resolve} from 'node:path'
 import {SinonSandbox, createSandbox} from 'sinon'
-import stripAnsi from 'strip-ansi'
 import * as tsNode from 'ts-node'
 
-import write from '../../src/cli-ux/write'
 import * as configTsNode from '../../src/config/ts-node'
 import {Interfaces, settings} from '../../src/index'
 import * as util from '../../src/util/fs'
@@ -44,25 +42,25 @@ describe('tsPath', () => {
   })
 
   it('should resolve a .js file to ts src', async () => {
-    sandbox.stub(util, 'readJson').resolves(DEFAULT_TS_CONFIG)
+    sandbox.stub(util, 'readTSConfig').resolves(DEFAULT_TS_CONFIG)
     const result = await configTsNode.tsPath(root, jsCompiled)
     expect(result).to.equal(join(root, tsModule))
   })
 
   it('should resolve a module file to ts src', async () => {
-    sandbox.stub(util, 'readJson').resolves(DEFAULT_TS_CONFIG)
+    sandbox.stub(util, 'readTSConfig').resolves(DEFAULT_TS_CONFIG)
     const result = await configTsNode.tsPath(root, jsCompiledModule)
     expect(result).to.equal(join(root, tsModule))
   })
 
   it('should resolve a .ts file', async () => {
-    sandbox.stub(util, 'readJson').resolves(DEFAULT_TS_CONFIG)
+    sandbox.stub(util, 'readTSConfig').resolves(DEFAULT_TS_CONFIG)
     const result = await configTsNode.tsPath(root, tsSource)
     expect(result).to.equal(join(root, tsSource))
   })
 
   it('should resolve a .ts file using baseUrl', async () => {
-    sandbox.stub(util, 'readJson').resolves({
+    sandbox.stub(util, 'readTSConfig').resolves({
       compilerOptions: {
         baseUrl: '.src/',
         outDir: 'lib',
@@ -73,19 +71,19 @@ describe('tsPath', () => {
   })
 
   it('should resolve .ts with no outDir', async () => {
-    sandbox.stub(util, 'readJson').resolves({compilerOptions: {rootDir: 'src'}})
+    sandbox.stub(util, 'readTSConfig').resolves({compilerOptions: {rootDir: 'src'}})
     const result = await configTsNode.tsPath(root, tsSource)
     expect(result).to.equal(join(root, tsSource))
   })
 
   it('should resolve .js with no rootDir and outDir', async () => {
-    sandbox.stub(util, 'readJson').resolves({compilerOptions: {strict: true}})
+    sandbox.stub(util, 'readTSConfig').resolves({compilerOptions: {strict: true}})
     const result = await configTsNode.tsPath(root, jsCompiled)
     expect(result).to.equal(join(root, jsCompiled))
   })
 
   it('should resolve to .ts file if enabled and prod', async () => {
-    sandbox.stub(util, 'readJson').resolves(DEFAULT_TS_CONFIG)
+    sandbox.stub(util, 'readTSConfig').resolves(DEFAULT_TS_CONFIG)
     settings.tsnodeEnabled = true
     const originalNodeEnv = process.env.NODE_ENV
     delete process.env.NODE_ENV
@@ -98,21 +96,11 @@ describe('tsPath', () => {
   })
 
   it('should resolve to js if disabled', async () => {
-    sandbox.stub(util, 'readJson').resolves(DEFAULT_TS_CONFIG)
+    sandbox.stub(util, 'readTSConfig').resolves(DEFAULT_TS_CONFIG)
     settings.tsnodeEnabled = false
     const result = await configTsNode.tsPath(root, jsCompiled)
     expect(result).to.equal(join(root, jsCompiled))
 
     delete settings.tsnodeEnabled
   })
-
-  it('should handle SyntaxError', async () => {
-    sandbox.stub(util, 'readJson').throws(new SyntaxError('Unexpected token } in JSON at position 0'))
-    const stderrStub = sandbox.stub(write, 'stderr')
-    const result = await configTsNode.tsPath(root, tsSource)
-    expect(result).to.equal(join(root, tsSource))
-    expect(stripAnsi(stderrStub.firstCall.firstArg).split('\n').join(' ')).to.include(
-      'Warning: Could not parse tsconfig.json',
-    )
-  })
 })