From ed4c2e6b4bd6128d09c3091fb54f5c0174ec6ca6 Mon Sep 17 00:00:00 2001
From: Shunsuke Suzuki <suzuki-shunsuke@users.noreply.github.com>
Date: Wed, 8 May 2024 16:27:38 +0900
Subject: [PATCH] fix(manager/devcontainer): parse with JSONC parser (#28914)

---
 .../manager/devcontainer/extract.spec.ts      | 36 ++++++++++
 lib/modules/manager/devcontainer/schema.ts    |  4 +-
 lib/util/schema-utils.spec.ts                 | 67 +++++++++++++++++++
 lib/util/schema-utils.ts                      | 11 +++
 package.json                                  |  1 +
 pnpm-lock.yaml                                |  3 +
 6 files changed, 120 insertions(+), 2 deletions(-)

diff --git a/lib/modules/manager/devcontainer/extract.spec.ts b/lib/modules/manager/devcontainer/extract.spec.ts
index 3cd12745ed..76b5b28993 100644
--- a/lib/modules/manager/devcontainer/extract.spec.ts
+++ b/lib/modules/manager/devcontainer/extract.spec.ts
@@ -27,6 +27,42 @@ describe('modules/manager/devcontainer/extract', () => {
       expect(result).toBeNull();
     });
 
+    it('tests if JSONC can be parsed', () => {
+      // Arrange
+      const content = codeBlock(`
+      {
+        // hello
+        "features": {
+          "devcontainer.registry.renovate.com/test/features/first:1.2.3": {}
+        }
+      }`);
+      const extractConfig = {};
+      // Act
+      const result = extractPackageFile(
+        content,
+        'devcontainer.json',
+        extractConfig,
+      );
+
+      // Assert
+      expect(result).toEqual({
+        deps: [
+          {
+            autoReplaceStringTemplate:
+              '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+            currentDigest: undefined,
+            currentValue: '1.2.3',
+            datasource: 'docker',
+            depName: 'devcontainer.registry.renovate.com/test/features/first',
+            depType: 'feature',
+            pinDigests: false,
+            replaceString:
+              'devcontainer.registry.renovate.com/test/features/first:1.2.3',
+          },
+        ],
+      });
+    });
+
     it('returns feature image deps when only the features property is defined in dev container JSON file', () => {
       // Arrange
       const content = codeBlock(`
diff --git a/lib/modules/manager/devcontainer/schema.ts b/lib/modules/manager/devcontainer/schema.ts
index b798121ed4..3849863204 100644
--- a/lib/modules/manager/devcontainer/schema.ts
+++ b/lib/modules/manager/devcontainer/schema.ts
@@ -1,7 +1,7 @@
 import { z } from 'zod';
-import { Json } from '../../../util/schema-utils';
+import { Jsonc } from '../../../util/schema-utils';
 
-export const DevContainerFile = Json.pipe(
+export const DevContainerFile = Jsonc.pipe(
   z.object({
     image: z.string().optional(),
     features: z.record(z.unknown()).optional(),
diff --git a/lib/util/schema-utils.spec.ts b/lib/util/schema-utils.spec.ts
index 390c2ed2bb..ccca2f70b9 100644
--- a/lib/util/schema-utils.spec.ts
+++ b/lib/util/schema-utils.spec.ts
@@ -3,6 +3,7 @@ import { z } from 'zod';
 import {
   Json,
   Json5,
+  Jsonc,
   LooseArray,
   LooseRecord,
   MultidocYaml,
@@ -269,6 +270,72 @@ describe('util/schema-utils', () => {
     });
   });
 
+  describe('Jsonc', () => {
+    it('parses JSONC', () => {
+      const Schema = Jsonc.pipe(z.object({ foo: z.literal('bar') }));
+
+      expect(Schema.parse('{"foo": "bar"}')).toEqual({ foo: 'bar' });
+
+      expect(Schema.safeParse(42)).toMatchObject({
+        error: {
+          issues: [
+            {
+              message: 'Expected string, received number',
+              code: 'invalid_type',
+              expected: 'string',
+              received: 'number',
+              path: [],
+            },
+          ],
+        },
+        success: false,
+      });
+
+      expect(Schema.safeParse('{"foo": "foo"}')).toMatchObject({
+        error: {
+          issues: [
+            {
+              message: 'Invalid literal value, expected "bar"',
+              code: 'invalid_literal',
+              expected: 'bar',
+              received: 'foo',
+              path: ['foo'],
+            },
+          ],
+        },
+        success: false,
+      });
+
+      expect(Schema.safeParse('["foo", "bar"]')).toMatchObject({
+        error: {
+          issues: [
+            {
+              message: 'Expected object, received array',
+              code: 'invalid_type',
+              expected: 'object',
+              received: 'array',
+              path: [],
+            },
+          ],
+        },
+        success: false,
+      });
+
+      expect(Schema.safeParse('{')).toMatchObject({
+        error: {
+          issues: [
+            {
+              message: 'Invalid JSONC',
+              code: 'custom',
+              path: [],
+            },
+          ],
+        },
+        success: false,
+      });
+    });
+  });
+
   describe('UtcDate', () => {
     it('parses date', () => {
       expect(UtcDate.parse('2020-04-04').toString()).toBe(
diff --git a/lib/util/schema-utils.ts b/lib/util/schema-utils.ts
index b0c32ad60f..1f7cb3d6da 100644
--- a/lib/util/schema-utils.ts
+++ b/lib/util/schema-utils.ts
@@ -1,4 +1,5 @@
 import JSON5 from 'json5';
+import * as JSONC from 'jsonc-parser';
 import { DateTime } from 'luxon';
 import type { JsonArray, JsonValue } from 'type-fest';
 import { z } from 'zod';
@@ -215,6 +216,16 @@ export const Json5 = z.string().transform((str, ctx): JsonValue => {
   }
 });
 
+export const Jsonc = z.string().transform((str, ctx): JsonValue => {
+  const errors: JSONC.ParseError[] = [];
+  const value = JSONC.parse(str, errors);
+  if (errors.length === 0) {
+    return value;
+  }
+  ctx.addIssue({ code: 'custom', message: 'Invalid JSONC' });
+  return z.NEVER;
+});
+
 export const UtcDate = z
   .string({ description: 'ISO 8601 string' })
   .transform((str, ctx): DateTime => {
diff --git a/package.json b/package.json
index 4e92c6b893..4d4774fc94 100644
--- a/package.json
+++ b/package.json
@@ -210,6 +210,7 @@
     "json-stringify-pretty-compact": "3.0.0",
     "json5": "2.2.3",
     "jsonata": "2.0.4",
+    "jsonc-parser": "3.2.1",
     "klona": "2.0.6",
     "lru-cache": "10.2.2",
     "luxon": "3.4.4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cdea0c1056..c49003771c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -215,6 +215,9 @@ importers:
       jsonata:
         specifier: 2.0.4
         version: 2.0.4
+      jsonc-parser:
+        specifier: 3.2.1
+        version: 3.2.1
       klona:
         specifier: 2.0.6
         version: 2.0.6
-- 
GitLab