From d3e81debb42ecf64f9f6797569a026b1524260ea Mon Sep 17 00:00:00 2001 From: Simon Heather <32168619+X-Guardian@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:59:52 +0100 Subject: [PATCH 01/54] fix(completion): a !reference tag anywhere in the file breaks all input completion (#263) * fix(completion): a !reference tag anywhere in the file suppresses all input completion * Fix referenceTag --------- Co-authored-by: Simon Heather --- package-lock.json | 8 --- package.json | 1 - src/providers/localComponentResolver.ts | 4 +- src/utils/yamlParser.ts | 24 ++++++- .../suite/referenceTagCompletion.test.ts | 68 +++++++++++++++++++ .../reference-tag-completion/.gitlab-ci.yml | 14 ++++ .../templates/deploy.yml | 16 +++++ tests/unit/completionInputContext.test.ts | 19 ++++++ tests/unit/yamlParser.test.ts | 25 +++++++ 9 files changed, 166 insertions(+), 13 deletions(-) create mode 100644 tests/extension-host/suite/referenceTagCompletion.test.ts create mode 100644 tests/fixtures/reference-tag-completion/.gitlab-ci.yml create mode 100644 tests/fixtures/reference-tag-completion/templates/deploy.yml diff --git a/package-lock.json b/package-lock.json index e8fbf063..b87f928b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,6 @@ "@octokit/plugin-paginate-rest": "^15.0.0", "@release-it/conventional-changelog": "^12.0.0", "@types/glob": "^9.0.0", - "@types/js-yaml": "^4.0.9", "@types/mocha": "^10.0.10", "@types/node": "^26.2.0", "@types/vscode": "^1.120.0", @@ -1954,13 +1953,6 @@ "glob": "*" } }, - "node_modules/@types/js-yaml": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", - "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", diff --git a/package.json b/package.json index 874904a9..01231fec 100644 --- a/package.json +++ b/package.json @@ -312,7 +312,6 @@ "@octokit/plugin-paginate-rest": "^15.0.0", "@release-it/conventional-changelog": "^12.0.0", "@types/glob": "^9.0.0", - "@types/js-yaml": "^4.0.9", "@types/mocha": "^10.0.10", "@types/node": "^26.2.0", "@types/vscode": "^1.120.0", diff --git a/src/providers/localComponentResolver.ts b/src/providers/localComponentResolver.ts index d9795bc3..7f916b60 100644 --- a/src/providers/localComponentResolver.ts +++ b/src/providers/localComponentResolver.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import * as yaml from 'js-yaml'; import { Component, ComponentParameter } from './componentDetector'; import { Logger } from '../utils/logger'; -import { isYamlNode } from '../utils/yamlParser'; +import { isYamlNode, GITLAB_CI_SCHEMA } from '../utils/yamlParser'; import type { ParameterDefault } from '../types/git-component'; // Pure parser helpers live in their own module so the unit suite can exercise them under plain Node. Re-exported @@ -229,7 +229,7 @@ export async function resolveLocalIncludeOutcome( } let docs: unknown[]; try { - docs = yaml.loadAll(text); + docs = yaml.loadAll(text, { schema: GITLAB_CI_SCHEMA }); } catch (err) { logger.debug(`[LocalComponentResolver] Failed to parse ${uri.fsPath}: ${err}`, 'LocalComponentResolver'); // The file exists and was read; it just isn't valid YAML. That's a plain include we can't extract inputs from, diff --git a/src/utils/yamlParser.ts b/src/utils/yamlParser.ts index 98b374c4..2af42402 100755 --- a/src/utils/yamlParser.ts +++ b/src/utils/yamlParser.ts @@ -3,6 +3,26 @@ import * as yaml from 'js-yaml'; /** Loose object shape returned by `js-yaml`. Callers narrow via property checks before reading fields. */ export type YamlNode = Record; +/** + * `!reference [.job, script]` — GitLab's own tag for splicing another job's key into this one. It belongs to no YAML + * schema, so a stock parser throws on it and the *whole file* — `include:` block included — yields nothing, killing + * completion/hover/validation for any pipeline that uses one. Resolving a reference needs the merged pipeline, which + * this extension never builds, so the tag constructs to its path sequence: enough for the surrounding document to + * parse, and a caller that reads one sees the target it points at rather than `undefined`. + */ +const referenceTag = yaml.defineSequenceTag('!reference', { + create: () => [], + addItem: (carrier, item) => { + carrier.push(item); + }, + // Load-only. `identify` selects the tag when *dumping*; returning false stops it claiming the plain arrays this + // constructs, which would re-emit unrelated sequences as `!reference`. + identify: () => false, +}); + +/** The core schema plus GitLab's CI-only tags, so a `.gitlab-ci.yml` using them still parses structurally. */ +export const GITLAB_CI_SCHEMA = yaml.CORE_SCHEMA.withTags(referenceTag); + /** Type-guard: a parsed YAML value is a non-null object (i.e. a mapping). Use to narrow `unknown` results. */ export function isYamlNode(value: unknown): value is YamlNode { return typeof value === 'object' && value !== null && !Array.isArray(value); @@ -34,7 +54,7 @@ export function parseYaml(text: string, silent = false): unknown { } // Parse and cache - const parsed = yaml.load(text); + const parsed = yaml.load(text, { schema: GITLAB_CI_SCHEMA }); parseCache.set(contentHash, { content: text, parsed, timestamp: now }); // Clean old cache entries periodically @@ -68,7 +88,7 @@ export function parseYaml(text: string, silent = false): unknown { */ export function parseYamlDocuments(text: string, silent = false): YamlNode[] { try { - const docs = yaml.loadAll(text); + const docs = yaml.loadAll(text, { schema: GITLAB_CI_SCHEMA }); return docs.filter(isYamlNode); } catch (e) { if (!silent) { diff --git a/tests/extension-host/suite/referenceTagCompletion.test.ts b/tests/extension-host/suite/referenceTagCompletion.test.ts new file mode 100644 index 00000000..1ef913f2 --- /dev/null +++ b/tests/extension-host/suite/referenceTagCompletion.test.ts @@ -0,0 +1,68 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +const EXTENSION_ID = 'eFAILution.gitlab-component-helper'; + +// __dirname is /out-test/suite at runtime; fixtures live under tests/fixtures. +const FIXTURE_DIR = path.resolve(__dirname, '..', '..', 'tests', 'fixtures', 'reference-tag-completion'); +const FIXTURE = path.join(FIXTURE_DIR, '.gitlab-ci.yml'); + +async function ensureActive(): Promise { + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(ext); + if (!ext.isActive) await ext.activate(); +} + +/** 0-indexed line whose trimmed text starts with `:`. */ +function lineStartingWith(doc: vscode.TextDocument, name: string): number { + const lines = doc.getText().split('\n'); + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim().startsWith(`${name}:`)) return i; + } + throw new Error(`fixture missing a line starting with ${name}:`); +} + +function labels(list: vscode.CompletionList | undefined): string[] { + return (list?.items ?? []).map((i) => (typeof i.label === 'string' ? i.label : i.label.label)); +} + +async function completionsAt(doc: vscode.TextDocument, position: vscode.Position): Promise { + const list = await vscode.commands.executeCommand( + 'vscode.executeCompletionItemProvider', + doc.uri, + position + ); + assert.ok(list, 'executeCompletionItemProvider returned no completion list'); + return list; +} + +// Regression for GitLab's `!reference [.job, key]` tag. It belongs to no YAML schema, so a stock parser threw on it +// and took the entire file down — `include:` block included — leaving the parse null and suppressing every input +// completion, even though the tag sits in an unrelated job further down the file. Drives VS Code's own completion +// engine end-to-end to confirm the name slot still resolves with a `!reference` present. +suite('Input completion in a file using a !reference tag', () => { + suiteSetup(ensureActive); + + test('name slot under the include offers the include\'s not-yet-set inputs', async () => { + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(FIXTURE)); + const editor = await vscode.window.showTextDocument(doc); + + // The include's inputs already set `job_name`; the remaining inputs are `environment` and `region`. Type a + // fresh name slot on a new line under `job_name:` at that key's indent, then ask for completions at the caret. + const jobNameLine = lineStartingWith(doc, 'job_name'); + const keyIndent = doc.lineAt(jobNameLine).firstNonWhitespaceCharacterIndex; + const insertAt = new vscode.Position(jobNameLine, doc.lineAt(jobNameLine).text.length); + await editor.edit((b) => b.insert(insertAt, `\n${' '.repeat(keyIndent)}`)); + + const slotLine = jobNameLine + 1; + const position = new vscode.Position(slotLine, keyIndent); + const list = await completionsAt(doc, position); + + const got = labels(list); + assert.ok(got.includes('environment'), `expected an 'environment' completion. Got: ${JSON.stringify(got)}`); + assert.ok(got.includes('region'), `expected a 'region' completion. Got: ${JSON.stringify(got)}`); + // The already-set input must not be re-offered. + assert.ok(!got.includes('job_name'), `'job_name' is already set and must not be offered. Got: ${JSON.stringify(got)}`); + }); +}); diff --git a/tests/fixtures/reference-tag-completion/.gitlab-ci.yml b/tests/fixtures/reference-tag-completion/.gitlab-ci.yml new file mode 100644 index 00000000..a07d62db --- /dev/null +++ b/tests/fixtures/reference-tag-completion/.gitlab-ci.yml @@ -0,0 +1,14 @@ +include: + - local: "reference-tag-completion/templates/deploy.yml" + inputs: + job_name: deploy + +.pnpm-setup: + script: + - corepack enable + - pnpm install --frozen-lockfile + +test: + script: + - !reference [.pnpm-setup, script] + - pnpm test diff --git a/tests/fixtures/reference-tag-completion/templates/deploy.yml b/tests/fixtures/reference-tag-completion/templates/deploy.yml new file mode 100644 index 00000000..514092da --- /dev/null +++ b/tests/fixtures/reference-tag-completion/templates/deploy.yml @@ -0,0 +1,16 @@ +spec: + inputs: + environment: + description: Target environment + type: string + default: staging + region: + description: Target region + type: string + job_name: + description: The name of the CI job + type: string +--- +$[[ inputs.job_name ]]: + script: + - echo "deploy $[[ inputs.job_name ]] to $[[ inputs.environment ]]/$[[ inputs.region ]]" diff --git a/tests/unit/completionInputContext.test.ts b/tests/unit/completionInputContext.test.ts index e6b3d4f8..d1ca979a 100644 --- a/tests/unit/completionInputContext.test.ts +++ b/tests/unit/completionInputContext.test.ts @@ -383,6 +383,25 @@ include: existingInputNames: [], }); }); + + // A `!reference` anywhere in the file used to fail the parse outright, so an empty inputs slot offered nothing. + test('resolves the inputs slot when a later job uses a !reference tag', () => { + // The slot line carries the indentation the user has typed into it, hence the explicit spaces. + const text = `include: + - component: ${FULL_PIPELINE_URL} + inputs: +${' '} +test: + script: + - !reference [.pnpm-setup, script]`; + const ctx = findCompletionInputContextAtLine(text, 3, 6); + assert.deepStrictEqual(ctx, { + componentUrl: FULL_PIPELINE_URL, + includeKind: 'component', + slot: 'name', + existingInputNames: [], + }); + }); }); suite('buildInputInsertValue', () => { diff --git a/tests/unit/yamlParser.test.ts b/tests/unit/yamlParser.test.ts index 0314e74d..d6f040fe 100644 --- a/tests/unit/yamlParser.test.ts +++ b/tests/unit/yamlParser.test.ts @@ -41,6 +41,31 @@ include: test('returns [] on unparseable input', () => { assert.deepStrictEqual(parseYamlDocuments('key: "unterminated', true), []); }); + + // A stock schema throws on GitLab's `!reference`, taking the whole document — `include:` and all — down with it. + test('parses a document using GitLab\'s !reference tag', () => { + const text = `include: + - component: https://gitlab.com/c/x@1.0.0 + inputs: + stage: build + +test: + script: + - !reference [.pnpm-setup, script] +`; + const docs = parseYamlDocuments(text, true); + assert.strictEqual(docs.length, 1); + const doc = findDocumentWith(docs, 'include'); + assert.ok(doc, 'the include-bearing document should survive the !reference tag'); + assert.deepStrictEqual(doc.include, [ + { component: 'https://gitlab.com/c/x@1.0.0', inputs: { stage: 'build' } }, + ]); + }); + + test('constructs !reference as the path sequence it points at', () => { + const docs = parseYamlDocuments('test:\n script:\n - !reference [.setup, script]\n', true); + assert.deepStrictEqual(docs[0].test, { script: [['.setup', 'script']] }); + }); }); suite('findDocumentWith', () => { From 9538d21381d440bc14af109eae4f87a3fe8ba955 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:01:14 -0400 Subject: [PATCH 02/54] chore(deps): bump js-yaml from 4.2.0 to 4.3.2 (#264) Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.2.0 to 4.3.2. - [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.2/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...4.3.2) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 4.3.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index b87f928b..0ec6bab2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3415,9 +3415,9 @@ } }, "node_modules/cosmiconfig/node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -6127,9 +6127,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.3.0.tgz", - "integrity": "sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.1.tgz", + "integrity": "sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==", "dev": true, "funding": [ { @@ -6774,9 +6774,9 @@ } }, "node_modules/mocha/node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { From 099932484ccf6154d4e90d61855619c08d648f58 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:01:25 -0400 Subject: [PATCH 03/54] chore(deps-dev): bump fast-uri from 3.1.5 to 3.1.7 (#265) Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.5 to 3.1.7. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.5...v3.1.7) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.7 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0ec6bab2..70e7ecf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4455,9 +4455,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "dev": true, "funding": [ { From ecd1aebe79f23a7888b203b9e360c772bcde8b33 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:01:38 -0400 Subject: [PATCH 04/54] chore(deps-dev): bump the dev-dependencies group with 9 updates (#266) Bumps the dev-dependencies group with 9 updates: | Package | From | To | | --- | --- | --- | | [@octokit/core](https://github.com/octokit/core.js) | `7.0.7` | `7.0.8` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.2.0` | `26.4.1` | | [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.67.0` | `8.69.0` | | [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.67.0` | `8.69.0` | | [eslint](https://github.com/eslint/eslint) | `10.8.1` | `10.9.1` | | [globals](https://github.com/sindresorhus/globals) | `17.11.0` | `17.12.0` | | [js-yaml](https://github.com/nodeca/js-yaml) | `5.3.0` | `5.4.1` | | [tsx](https://github.com/privatenumber/tsx) | `4.23.12` | `4.23.13` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.67.0` | `8.69.0` | Updates `@octokit/core` from 7.0.7 to 7.0.8 - [Release notes](https://github.com/octokit/core.js/releases) - [Commits](https://github.com/octokit/core.js/compare/v7.0.7...v7.0.8) Updates `@types/node` from 26.2.0 to 26.4.1 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `@typescript-eslint/eslint-plugin` from 8.67.0 to 8.69.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.69.0/packages/eslint-plugin) Updates `@typescript-eslint/parser` from 8.67.0 to 8.69.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.69.0/packages/parser) Updates `eslint` from 10.8.1 to 10.9.1 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.8.1...v10.9.1) Updates `globals` from 17.11.0 to 17.12.0 - [Release notes](https://github.com/sindresorhus/globals/releases) - [Commits](https://github.com/sindresorhus/globals/compare/v17.11.0...v17.12.0) Updates `js-yaml` from 5.3.0 to 5.4.1 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/5.3.0...5.4.1) Updates `tsx` from 4.23.12 to 4.23.13 - [Release notes](https://github.com/privatenumber/tsx/releases) - [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs) - [Commits](https://github.com/privatenumber/tsx/compare/v4.23.12...v4.23.13) Updates `typescript-eslint` from 8.67.0 to 8.69.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.69.0/packages/typescript-eslint) --- updated-dependencies: - dependency-name: "@octokit/core" dependency-version: 7.0.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-dependencies - dependency-name: "@types/node" dependency-version: 26.4.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-dependencies - dependency-name: "@typescript-eslint/eslint-plugin" dependency-version: 8.69.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-dependencies - dependency-name: "@typescript-eslint/parser" dependency-version: 8.69.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-dependencies - dependency-name: eslint dependency-version: 10.9.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-dependencies - dependency-name: globals dependency-version: 17.12.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-dependencies - dependency-name: js-yaml dependency-version: 5.4.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-dependencies - dependency-name: tsx dependency-version: 4.23.13 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-dependencies - dependency-name: typescript-eslint dependency-version: 8.69.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 304 +++++++++++++++++++++++----------------------- package.json | 18 +-- 2 files changed, 161 insertions(+), 161 deletions(-) diff --git a/package-lock.json b/package-lock.json index 70e7ecf7..8587ff24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,15 +13,15 @@ "@commitlint/config-conventional": "^21.0.2", "@digitalroute/cz-conventional-changelog-for-jira": "^8.0.1", "@eslint/js": "^10.0.1", - "@octokit/core": "^7.0.7", + "@octokit/core": "^7.0.8", "@octokit/plugin-paginate-rest": "^15.0.0", "@release-it/conventional-changelog": "^12.0.0", "@types/glob": "^9.0.0", "@types/mocha": "^10.0.10", - "@types/node": "^26.2.0", + "@types/node": "^26.4.1", "@types/vscode": "^1.120.0", - "@typescript-eslint/eslint-plugin": "^8.67.0", - "@typescript-eslint/parser": "^8.67.0", + "@typescript-eslint/eslint-plugin": "^8.69.0", + "@typescript-eslint/parser": "^8.69.0", "@vscode/test-electron": "^3.1.0", "commitizen": "^4.3.2", "conventional-changelog-conventionalcommits": "^9.3.1", @@ -30,19 +30,19 @@ "dateformat": "^5.0.3", "dotenv": "^17.4.2", "esbuild": "^0.28.2", - "eslint": "^10.8.1", - "globals": "^17.11.0", + "eslint": "^10.9.1", + "globals": "^17.12.0", "husky": "^9.1.6", "is-ci": "^4.0.0", - "js-yaml": "^5.3.0", + "js-yaml": "^5.4.1", "minimatch": "^10.2.6", "mocha": "^11.8.0", "npm-run-all": "^4.1.5", "release-it": "^21.0.2", "semver": "^7.8.5", - "tsx": "^4.23.12", + "tsx": "^4.23.13", "typescript": "^6.0.3", - "typescript-eslint": "^8.67.0" + "typescript-eslint": "^8.69.0" }, "engines": { "node": ">=22.0.0", @@ -1535,17 +1535,17 @@ } }, "node_modules/@octokit/core": { - "version": "7.0.7", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.7.tgz", - "integrity": "sha512-DcB0M3KFgr9ECI328lhBMVsyFT2DnmNucSBTqEN3exyNKUzkkpUSCHmTRcunF41Eou2TIQKW4seewri8ON9bSA==", + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.8.tgz", + "integrity": "sha512-L7y8eYc+AwxGr2PWI4WFt1VG4TiJ66c26BD16mXpYIlXxG0SMigM1+m4aTSlYyBr5BlQsGAlz8uDCoZN4SEMcg==", "dev": true, "license": "MIT", "dependencies": { "@octokit/auth-token": "^6.0.0", - "@octokit/graphql": "^9.0.4", - "@octokit/request": "^10.0.13", - "@octokit/request-error": "^7.1.1", - "@octokit/types": "^17.0.0", + "@octokit/graphql": "^9.0.5", + "@octokit/request": "^10.0.16", + "@octokit/request-error": "^7.1.2", + "@octokit/types": "^18.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" }, @@ -1554,30 +1554,30 @@ } }, "node_modules/@octokit/core/node_modules/@octokit/openapi-types": { - "version": "28.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-28.0.0.tgz", - "integrity": "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==", + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-29.0.1.tgz", + "integrity": "sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg==", "dev": true, "license": "MIT" }, "node_modules/@octokit/core/node_modules/@octokit/types": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-17.0.0.tgz", - "integrity": "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-18.0.0.tgz", + "integrity": "sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/openapi-types": "^28.0.0" + "@octokit/openapi-types": "^29.0.1" } }, "node_modules/@octokit/endpoint": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.4.tgz", - "integrity": "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA==", + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.5.tgz", + "integrity": "sha512-iXa654H3yFafF/ieHkukfbgWo2rmXD2ceD0ZOtrPhw1bc3FDch1d9N/TNs0FQ1/cIbwb7kspUX8jzIs8nzb9DQ==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/types": "^17.0.0", + "@octokit/types": "^18.0.0", "universal-user-agent": "^7.0.2" }, "engines": { @@ -1585,31 +1585,31 @@ } }, "node_modules/@octokit/endpoint/node_modules/@octokit/openapi-types": { - "version": "28.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-28.0.0.tgz", - "integrity": "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==", + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-29.0.1.tgz", + "integrity": "sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg==", "dev": true, "license": "MIT" }, "node_modules/@octokit/endpoint/node_modules/@octokit/types": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-17.0.0.tgz", - "integrity": "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-18.0.0.tgz", + "integrity": "sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/openapi-types": "^28.0.0" + "@octokit/openapi-types": "^29.0.1" } }, "node_modules/@octokit/graphql": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.4.tgz", - "integrity": "sha512-5s15CCiY8XXQ+FG+b1YQcl6Z2FA++nwAz/tg2VUrTmnMncP+2nnGUEYANImdnxsA2Fnq+Mbl7hDjUTw7cFAwcg==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.5.tgz", + "integrity": "sha512-bt/hm03LeU6Vy7FwTrkkC9p3XGT/lBwClglMqxBSe5/q0E5CdJTXeAqEI0vlw89/LF/G6tryTIH8HirZ3prMVg==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/request": "^10.0.13", - "@octokit/types": "^17.0.0", + "@octokit/request": "^10.0.16", + "@octokit/types": "^18.0.0", "universal-user-agent": "^7.0.0" }, "engines": { @@ -1617,20 +1617,20 @@ } }, "node_modules/@octokit/graphql/node_modules/@octokit/openapi-types": { - "version": "28.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-28.0.0.tgz", - "integrity": "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==", + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-29.0.1.tgz", + "integrity": "sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg==", "dev": true, "license": "MIT" }, "node_modules/@octokit/graphql/node_modules/@octokit/types": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-17.0.0.tgz", - "integrity": "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-18.0.0.tgz", + "integrity": "sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/openapi-types": "^28.0.0" + "@octokit/openapi-types": "^29.0.1" } }, "node_modules/@octokit/openapi-types": { @@ -1703,17 +1703,17 @@ } }, "node_modules/@octokit/request": { - "version": "10.0.13", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.13.tgz", - "integrity": "sha512-v2269YxL9Yf+x3d+gRI63FP0vFQEiWgLyBzxe/Y+0yFDg2B/Tzf5dhh9VNfccVAQnfcfwQWyk/y6Bn7rUXXs7A==", + "version": "10.0.16", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.16.tgz", + "integrity": "sha512-A0zWGjHzISIb+9ccG8s0dq7LKO5zVpJLRICjgUb+sJxEWqn8RUHB1rD3AE51+PECvXHIxqZ1VVvs4fHTSD9nUQ==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/endpoint": "^11.0.3", - "@octokit/request-error": "^7.1.1", - "@octokit/types": "^17.0.0", - "content-type": "^2.0.0", - "json-with-bigint": "^3.5.3", + "@octokit/endpoint": "^11.0.5", + "@octokit/request-error": "^7.1.2", + "@octokit/types": "^18.0.0", + "content-type": "^3.0.0", + "json-with-bigint": "^3.5.12", "universal-user-agent": "^7.0.2" }, "engines": { @@ -1721,50 +1721,50 @@ } }, "node_modules/@octokit/request-error": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.1.tgz", - "integrity": "sha512-+eaY7G2VVpSf2pc5Gn1+mph837V/d/TYTJAgWL9Tb0ogGYcpN3IlAVFgjL+Vv93F/sevrxkvsYCedtpLdcFLzA==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.2.tgz", + "integrity": "sha512-XZRuT3xZ84D3gYErI1DZvhJ33dCWVV6uzBtWkaBB4TvA/L6eOeTZodxLFVB44bBEEo3vEx7y00UfX1tBLrtLRg==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/types": "^17.0.0" + "@octokit/types": "^18.0.0" }, "engines": { "node": ">= 20" } }, "node_modules/@octokit/request-error/node_modules/@octokit/openapi-types": { - "version": "28.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-28.0.0.tgz", - "integrity": "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==", + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-29.0.1.tgz", + "integrity": "sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg==", "dev": true, "license": "MIT" }, "node_modules/@octokit/request-error/node_modules/@octokit/types": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-17.0.0.tgz", - "integrity": "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-18.0.0.tgz", + "integrity": "sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/openapi-types": "^28.0.0" + "@octokit/openapi-types": "^29.0.1" } }, "node_modules/@octokit/request/node_modules/@octokit/openapi-types": { - "version": "28.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-28.0.0.tgz", - "integrity": "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==", + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-29.0.1.tgz", + "integrity": "sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg==", "dev": true, "license": "MIT" }, "node_modules/@octokit/request/node_modules/@octokit/types": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-17.0.0.tgz", - "integrity": "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-18.0.0.tgz", + "integrity": "sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/openapi-types": "^28.0.0" + "@octokit/openapi-types": "^29.0.1" } }, "node_modules/@octokit/rest": { @@ -1968,9 +1968,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", - "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "version": "26.4.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", + "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", "dev": true, "license": "MIT", "dependencies": { @@ -1992,17 +1992,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", - "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz", + "integrity": "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/type-utils": "8.67.0", - "@typescript-eslint/utils": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/type-utils": "8.69.0", + "@typescript-eslint/utils": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2015,22 +2015,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.67.0", + "@typescript-eslint/parser": "^8.69.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", - "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.69.0.tgz", + "integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", "debug": "^4.4.3" }, "engines": { @@ -2046,14 +2046,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", - "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.69.0.tgz", + "integrity": "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.67.0", - "@typescript-eslint/types": "^8.67.0", + "@typescript-eslint/tsconfig-utils": "^8.69.0", + "@typescript-eslint/types": "^8.69.0", "debug": "^4.4.3" }, "engines": { @@ -2068,14 +2068,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", - "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz", + "integrity": "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0" + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2086,9 +2086,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", - "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz", + "integrity": "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==", "dev": true, "license": "MIT", "engines": { @@ -2103,15 +2103,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", - "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz", + "integrity": "sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/utils": "8.69.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2128,9 +2128,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", - "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz", + "integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==", "dev": true, "license": "MIT", "engines": { @@ -2142,16 +2142,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", - "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz", + "integrity": "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.67.0", - "@typescript-eslint/tsconfig-utils": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/project-service": "8.69.0", + "@typescript-eslint/tsconfig-utils": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2170,16 +2170,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", - "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.69.0.tgz", + "integrity": "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0" + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2194,13 +2194,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", - "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz", + "integrity": "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/types": "8.69.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3216,13 +3216,13 @@ "license": "MIT" }, "node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-3.0.0.tgz", + "integrity": "sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { "type": "opencollective", @@ -4142,9 +4142,9 @@ } }, "node_modules/eslint": { - "version": "10.8.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", - "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz", + "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==", "dev": true, "license": "MIT", "workspaces": [ @@ -4976,9 +4976,9 @@ } }, "node_modules/globals": { - "version": "17.11.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz", - "integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==", + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", "dev": true, "license": "MIT", "engines": { @@ -6185,9 +6185,9 @@ "license": "MIT" }, "node_modules/json-with-bigint": { - "version": "3.5.10", - "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.10.tgz", - "integrity": "sha512-Vcx+JVNEBts/xfcoCS69sKrOhOk/3TVlvlT+XzUOefVKnnrbYSCKpDCm10pohsJFtsJVYnwa/cXRZ4eElzaM6w==", + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.12.tgz", + "integrity": "sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w==", "dev": true, "license": "MIT" }, @@ -9125,9 +9125,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.23.12", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", - "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", "dev": true, "license": "MIT", "dependencies": { @@ -9269,16 +9269,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", - "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.69.0.tgz", + "integrity": "sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.67.0", - "@typescript-eslint/parser": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/utils": "8.67.0" + "@typescript-eslint/eslint-plugin": "8.69.0", + "@typescript-eslint/parser": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/utils": "8.69.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/package.json b/package.json index 01231fec..1afee95b 100644 --- a/package.json +++ b/package.json @@ -308,15 +308,15 @@ "@commitlint/config-conventional": "^21.0.2", "@digitalroute/cz-conventional-changelog-for-jira": "^8.0.1", "@eslint/js": "^10.0.1", - "@octokit/core": "^7.0.7", + "@octokit/core": "^7.0.8", "@octokit/plugin-paginate-rest": "^15.0.0", "@release-it/conventional-changelog": "^12.0.0", "@types/glob": "^9.0.0", "@types/mocha": "^10.0.10", - "@types/node": "^26.2.0", + "@types/node": "^26.4.1", "@types/vscode": "^1.120.0", - "@typescript-eslint/eslint-plugin": "^8.67.0", - "@typescript-eslint/parser": "^8.67.0", + "@typescript-eslint/eslint-plugin": "^8.69.0", + "@typescript-eslint/parser": "^8.69.0", "@vscode/test-electron": "^3.1.0", "commitizen": "^4.3.2", "conventional-changelog-conventionalcommits": "^9.3.1", @@ -325,19 +325,19 @@ "dateformat": "^5.0.3", "dotenv": "^17.4.2", "esbuild": "^0.28.2", - "eslint": "^10.8.1", - "globals": "^17.11.0", + "eslint": "^10.9.1", + "globals": "^17.12.0", "husky": "^9.1.6", "is-ci": "^4.0.0", - "js-yaml": "^5.3.0", + "js-yaml": "^5.4.1", "minimatch": "^10.2.6", "mocha": "^11.8.0", "npm-run-all": "^4.1.5", "release-it": "^21.0.2", "semver": "^7.8.5", - "tsx": "^4.23.12", + "tsx": "^4.23.13", "typescript": "^6.0.3", - "typescript-eslint": "^8.67.0" + "typescript-eslint": "^8.69.0" }, "keywords": [], "author": "eFAILution", From 574d38d41ba69d57ea73347db2f1bf5f38907611 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 8 Sep 2026 12:04:38 +0000 Subject: [PATCH 05/54] chore(release): 0.17.0 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8587ff24..8ba36338 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.16.12", + "version": "0.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.16.12", + "version": "0.17.0", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.2.2", diff --git a/package.json b/package.json index 1afee95b..4f9bb4af 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gitlab-component-helper", "displayName": "GitLab Component Helper", "description": "Provides intellisense for GitLab CI components", - "version": "0.16.12", + "version": "0.17.0", "icon": "images/icon.png", "engines": { "node": ">=22.0.0", From cabacbc79ad42430264c80b131cc07c1c6c4a10c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:06:57 -0400 Subject: [PATCH 06/54] chore(deps-dev): bump mocha from 11.8.0 to 12.0.0 (#267) Bumps [mocha](https://github.com/mochajs/mocha) from 11.8.0 to 12.0.0. - [Release notes](https://github.com/mochajs/mocha/releases) - [Changelog](https://github.com/mochajs/mocha/blob/main/CHANGELOG.md) - [Commits](https://github.com/mochajs/mocha/compare/v11.8.0...v12.0.0) --- updated-dependencies: - dependency-name: mocha dependency-version: 12.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: eFAILution --- package-lock.json | 600 ++++------------------------------------------ package.json | 2 +- 2 files changed, 41 insertions(+), 561 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8ba36338..0452346e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,7 @@ "is-ci": "^4.0.0", "js-yaml": "^5.4.1", "minimatch": "^10.2.6", - "mocha": "^11.8.0", + "mocha": "^12.0.0", "npm-run-all": "^4.1.5", "release-it": "^21.0.2", "semver": "^7.8.5", @@ -1421,109 +1421,6 @@ } } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@octokit/auth-token": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", @@ -1823,17 +1720,6 @@ "url": "https://github.com/phun-ky/typeof?sponsor=1" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@release-it/conventional-changelog": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/@release-it/conventional-changelog/-/conventional-changelog-12.0.0.tgz", @@ -2747,36 +2633,6 @@ } } }, - "node_modules/c12/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/c12/node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/cachedir": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", @@ -2883,16 +2739,16 @@ "license": "MIT" }, "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "dev": true, "license": "MIT", "dependencies": { - "readdirp": "^4.0.1" + "readdirp": "^5.0.0" }, "engines": { - "node": ">= 14.16.0" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -3653,19 +3509,6 @@ } } }, - "node_modules/decamelize": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", - "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -3825,9 +3668,9 @@ } }, "node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -3875,13 +3718,6 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -4584,16 +4420,6 @@ "node": ">= 8" } }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, - "license": "BSD-3-Clause", - "bin": { - "flat": "cli.js" - } - }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -4631,36 +4457,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -5119,16 +4915,6 @@ "node": ">= 0.4" } }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, "node_modules/homedir-polyfill": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", @@ -6093,22 +5879,6 @@ "node": "^18.17 || >=20.6.1" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -6513,13 +6283,6 @@ "node": ">=0.10.0" } }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, "node_modules/macos-release": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-3.4.0.tgz", @@ -6660,107 +6423,34 @@ } }, "node_modules/mocha": { - "version": "11.8.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.8.0.tgz", - "integrity": "sha512-VyCeUdGN3A9lmCTTgG4yuvY9ixxaDk+xt2R/7/+1AP6EqNG+G9OKkzBwhVtVYoNX8YsxNSgAl8mOv3IAeOpFbw==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-12.0.0.tgz", + "integrity": "sha512-NYNh5IFt6WYqm9bi4601m7vix8MZdXC0DwS4gY6WhXO2RgJWhivhISVmq1oklCif18OSI9l+vx5Mdm5oh1XGiQ==", "dev": true, "license": "MIT", "dependencies": { "browser-stdout": "^1.3.1", - "chokidar": "^4.0.1", + "chokidar": "^5.0.0", "debug": "^4.3.5", - "diff": "^7.0.0", - "escape-string-regexp": "^4.0.0", + "diff": "^9.0.0", "find-up": "^5.0.0", - "glob": "^10.4.5", - "he": "^1.2.0", + "glob": "^13.0.0", "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "log-symbols": "^4.1.0", - "minimatch": "^9.0.5", + "is-unicode-supported": "^0.1.0", + "js-yaml": "^5.0.0", + "minimatch": "^10.2.2", "ms": "^2.1.3", "picocolors": "^1.1.1", - "serialize-javascript": "^6.0.2", - "strip-json-comments": "^3.1.1", + "serialize-javascript": "^7.0.2", + "strip-json-comments": "^5.0.3", "supports-color": "^8.1.1", - "workerpool": "^9.2.0", - "yargs": "^17.7.2", - "yargs-parser": "^21.1.1", - "yargs-unparser": "^2.0.0" + "workerpool": "^10.0.0" }, "bin": { - "_mocha": "bin/_mocha", "mocha": "bin/mocha.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/mocha/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/mocha/node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/mocha/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mocha/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/mocha/node_modules/has-flag": { @@ -6773,60 +6463,17 @@ "node": ">=8" } }, - "node_modules/mocha/node_modules/js-yaml": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "node_modules/mocha/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/mocha/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/mocha/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" + "node": ">=14.16" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/mocha/node_modules/supports-color": { @@ -6845,25 +6492,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/mocha/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -7517,13 +7145,6 @@ "quickjs-wasi": "^2.2.0" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -7905,16 +7526,6 @@ "dev": true, "license": "MIT" }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/rc9": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", @@ -7987,13 +7598,13 @@ } }, "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 14.18.0" + "node": ">= 20.19.0" }, "funding": { "type": "individual", @@ -8291,16 +7902,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -8522,13 +8123,13 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.1.tgz", + "integrity": "sha512-k3CMsaIvvdSwm8oLB4MXSl0wH2/cwlH7xGcnRd2DaeRmBkbzYmyT8j0tsX60DwD1eRwHTpNpH8ljKu9oUT1MeQ==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" + "engines": { + "node": ">=20.0.0" } }, "node_modules/set-function-length": { @@ -8856,22 +8457,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string.prototype.padend": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", @@ -8963,20 +8548,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -9568,9 +9139,9 @@ } }, "node_modules/workerpool": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", - "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-10.0.3.tgz", + "integrity": "sha512-6z2Iis68Wqth93/G/wJP9u+R3O+d2XTlgWChGCwuT1qLbBsOYueGRZuJ++v3mtDP5KjYdy+WzvWC+VWETSVXJA==", "dev": true, "license": "Apache-2.0" }, @@ -9592,61 +9163,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -9735,42 +9251,6 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-unparser": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", - "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "camelcase": "^6.0.0", - "decamelize": "^4.0.0", - "flat": "^5.0.2", - "is-plain-obj": "^2.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-unparser/node_modules/is-plain-obj": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/yargs/node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", diff --git a/package.json b/package.json index 4f9bb4af..42f41d65 100644 --- a/package.json +++ b/package.json @@ -331,7 +331,7 @@ "is-ci": "^4.0.0", "js-yaml": "^5.4.1", "minimatch": "^10.2.6", - "mocha": "^11.8.0", + "mocha": "^12.0.0", "npm-run-all": "^4.1.5", "release-it": "^21.0.2", "semver": "^7.8.5", From ea9b26ad168d6d8d2813c103432dc46e12a2fefb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 8 Sep 2026 12:09:18 +0000 Subject: [PATCH 07/54] chore(release): 0.17.1 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0452346e..4fe9c6d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.17.0", + "version": "0.17.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.17.0", + "version": "0.17.1", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.2.2", diff --git a/package.json b/package.json index 42f41d65..4a642fd7 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gitlab-component-helper", "displayName": "GitLab Component Helper", "description": "Provides intellisense for GitLab CI components", - "version": "0.17.0", + "version": "0.17.1", "icon": "images/icon.png", "engines": { "node": ">=22.0.0", From 42d310c33770e3d4dff30001367da62fc9e53d1c Mon Sep 17 00:00:00 2001 From: Simon Heather <32168619+X-Guardian@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:52:20 +0100 Subject: [PATCH 08/54] fix(parser): tolerate any local YAML tag, not just sequence !reference (#273) Co-authored-by: Simon Heather --- src/utils/yamlParser.ts | 48 +++++++++++---- .../templates/deploy.yml | 7 +++ tests/unit/yamlParser.test.ts | 61 ++++++++++++++++++- 3 files changed, 104 insertions(+), 12 deletions(-) diff --git a/src/utils/yamlParser.ts b/src/utils/yamlParser.ts index 2af42402..bdc66e8b 100755 --- a/src/utils/yamlParser.ts +++ b/src/utils/yamlParser.ts @@ -4,24 +4,50 @@ import * as yaml from 'js-yaml'; export type YamlNode = Record; /** - * `!reference [.job, script]` — GitLab's own tag for splicing another job's key into this one. It belongs to no YAML - * schema, so a stock parser throws on it and the *whole file* — `include:` block included — yields nothing, killing - * completion/hover/validation for any pipeline that uses one. Resolving a reference needs the merged pipeline, which - * this extension never builds, so the tag constructs to its path sequence: enough for the surrounding document to - * parse, and a caller that reads one sees the target it points at rather than `undefined`. + * Shared options for the local-tag catch-alls below. A local tag (`!reference`, `!custom`, …) belongs to no schema, + * so a stock parse throws and the whole file — `include:` and all — yields nothing. Matching by prefix on `!` + * degrades any such tag to the value it wraps: this extension reads only `include:` and `spec.inputs`, never the + * tagged values. `identify` is dump-side only; false keeps these load-only. */ -const referenceTag = yaml.defineSequenceTag('!reference', { +const loadOnly = { matchByTagPrefix: true, identify: () => false } as const; + +/** One catch-all per node kind, since a tag is selected by the shape of the node it decorates. */ +const anyLocalScalarTag = yaml.defineScalarTag('!', { ...loadOnly, resolve: (source) => source }); + +const anyLocalSequenceTag = yaml.defineSequenceTag('!', { + ...loadOnly, create: () => [], addItem: (carrier, item) => { carrier.push(item); }, - // Load-only. `identify` selects the tag when *dumping*; returning false stops it claiming the plain arrays this - // constructs, which would re-emit unrelated sequences as `!reference`. - identify: () => false, }); -/** The core schema plus GitLab's CI-only tags, so a `.gitlab-ci.yml` using them still parses structurally. */ -export const GITLAB_CI_SCHEMA = yaml.CORE_SCHEMA.withTags(referenceTag); +const anyLocalMappingTag = yaml.defineMappingTag, YamlNode>('!', { + ...loadOnly, + // Map carrier so non-string keys survive; finalized to a plain object, which is what every reader here expects. + create: () => new Map(), + addPair: (carrier, key, value) => { + carrier.set(key, value); + return ''; // Success; a non-empty string is an error message. + }, + has: (carrier, key) => carrier.has(key), + keys: (result) => Object.keys(result), + get: (result, key) => result[String(key)], + finalize: (carrier) => { + const result: YamlNode = {}; + for (const [key, value] of carrier) { + result[String(key)] = value; + } + return result; + }, +}); + +/** The core schema plus tolerated local tags, so a `.gitlab-ci.yml` using them still parses structurally. */ +export const GITLAB_CI_SCHEMA = yaml.CORE_SCHEMA.withTags( + anyLocalScalarTag, + anyLocalSequenceTag, + anyLocalMappingTag +); /** Type-guard: a parsed YAML value is a non-null object (i.e. a mapping). Use to narrow `unknown` results. */ export function isYamlNode(value: unknown): value is YamlNode { diff --git a/tests/fixtures/reference-tag-completion/templates/deploy.yml b/tests/fixtures/reference-tag-completion/templates/deploy.yml index 514092da..1817f69e 100644 --- a/tests/fixtures/reference-tag-completion/templates/deploy.yml +++ b/tests/fixtures/reference-tag-completion/templates/deploy.yml @@ -11,6 +11,13 @@ spec: description: The name of the CI job type: string --- +# The `!reference` here is load-bearing: it exercises the local include's own parse in localComponentResolver, which +# reads this file's `spec:` block. Without it, that parse path is never given a tag to choke on. +.deploy-setup: + script: + - echo "authenticating against $[[ inputs.region ]]" + $[[ inputs.job_name ]]: script: + - !reference [.deploy-setup, script] - echo "deploy $[[ inputs.job_name ]] to $[[ inputs.environment ]]/$[[ inputs.region ]]" diff --git a/tests/unit/yamlParser.test.ts b/tests/unit/yamlParser.test.ts index d6f040fe..e74eaadd 100644 --- a/tests/unit/yamlParser.test.ts +++ b/tests/unit/yamlParser.test.ts @@ -9,7 +9,7 @@ */ import * as assert from 'node:assert/strict'; -import { parseYamlDocuments, findDocumentWith } from '../../src/utils/yamlParser'; +import { parseYaml, parseYamlDocuments, findDocumentWith } from '../../src/utils/yamlParser'; suite('parseYamlDocuments', () => { test('returns every mapping document of a multi-document stream', () => { @@ -66,6 +66,65 @@ test: const docs = parseYamlDocuments('test:\n script:\n - !reference [.setup, script]\n', true); assert.deepStrictEqual(docs[0].test, { script: [['.setup', 'script']] }); }); + + // Any local tag is fatal to a stock parse, not just a sequence-position `!reference`. Each of these forms took the + // whole document down while only the sequence form was handled, so the tags match by prefix on `!` instead. + test('tolerates a local tag in every node position', () => { + const cases: [string, string, unknown][] = [ + ['scalar', 'key: !reference foo', { key: 'foo' }], + ['sequence', 'key: !reference [.setup, script]', { key: ['.setup', 'script'] }], + ['mapping', 'key: !reference\n nested: value', { key: { nested: 'value' } }], + ]; + for (const [position, text, expected] of cases) { + assert.deepStrictEqual(parseYamlDocuments(text, true)[0], expected, `${position} position`); + } + }); + + // The shape a user is mid-way through typing: `!reference` with no argument yet. Losing the parse here blanks + // completion at exactly the moment it is wanted. + test('tolerates a half-typed tag with no value yet', () => { + const text = `include: + - component: https://gitlab.com/c/x@1.0.0 + inputs: + stage: build + +test: + script: + - !reference +`; + const doc = findDocumentWith(parseYamlDocuments(text, true), 'include'); + assert.ok(doc, 'the include must still resolve while a tag is half-typed'); + }); + + test('tolerates an unknown tag that is not !reference', () => { + assert.deepStrictEqual(parseYamlDocuments('a: !custom [1, 2]', true)[0], { a: [1, 2] }); + }); + + // The tolerated tags must not disturb ordinary YAML: core scalars keep their types rather than becoming strings. + test('leaves untagged YAML and its scalar types alone', () => { + const text = 'num: 1\nbool: true\nnul: null\nstr: plain\nlist:\n - a\n'; + assert.deepStrictEqual(parseYamlDocuments(text, true)[0], { + num: 1, + bool: true, + nul: null, + str: 'plain', + list: ['a'], + }); + }); +}); + +// `parseYaml` is the single-document path (the completion round-trip probe, the component browser's wrapped-include +// parse). It takes the same schema, but the tests above all go through `parseYamlDocuments`. +suite('parseYaml', () => { + test('tolerates a local tag in every node position', () => { + assert.deepStrictEqual(parseYaml('key: !reference foo', true), { key: 'foo' }); + assert.deepStrictEqual(parseYaml('key: !reference [.setup, script]', true), { key: ['.setup', 'script'] }); + assert.deepStrictEqual(parseYaml('key: !reference\n nested: value', true), { key: { nested: 'value' } }); + }); + + test('still returns null on genuinely malformed YAML', () => { + assert.strictEqual(parseYaml('key: "unterminated', true), null); + }); }); suite('findDocumentWith', () => { From aaf85b3d234129e19cfe6a98005ab7f8751e4d6e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 8 Sep 2026 23:54:54 +0000 Subject: [PATCH 09/54] chore(release): 0.17.2 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4fe9c6d4..edb1b29c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.17.1", + "version": "0.17.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.17.1", + "version": "0.17.2", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.2.2", diff --git a/package.json b/package.json index 4a642fd7..f5265290 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gitlab-component-helper", "displayName": "GitLab Component Helper", "description": "Provides intellisense for GitLab CI components", - "version": "0.17.1", + "version": "0.17.2", "icon": "images/icon.png", "engines": { "node": ">=22.0.0", From 8bd3ed0e6ca8f8d91ed9c04ed9608c0f8cb129a9 Mon Sep 17 00:00:00 2001 From: Simon Heather <32168619+X-Guardian@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:56:43 +0100 Subject: [PATCH 10/54] fix(parser): merge YAML merge keys (`<<:`) as GitLab does (#274) Co-authored-by: Simon Heather Co-authored-by: eFAILution --- src/utils/yamlParser.ts | 8 +- tests/extension-host/suite/mergeKey.test.ts | 75 +++++++++++++++++++ tests/fixtures/merge-key/.gitlab-ci.yml | 7 ++ tests/fixtures/merge-key/templates/deploy.yml | 23 ++++++ tests/unit/yamlParser.test.ts | 47 +++++++++++- 5 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 tests/extension-host/suite/mergeKey.test.ts create mode 100644 tests/fixtures/merge-key/.gitlab-ci.yml create mode 100644 tests/fixtures/merge-key/templates/deploy.yml diff --git a/src/utils/yamlParser.ts b/src/utils/yamlParser.ts index bdc66e8b..2dcee1f4 100755 --- a/src/utils/yamlParser.ts +++ b/src/utils/yamlParser.ts @@ -42,8 +42,14 @@ const anyLocalMappingTag = yaml.defineMappingTag, YamlNode }, }); -/** The core schema plus tolerated local tags, so a `.gitlab-ci.yml` using them still parses structurally. */ +/** + * The core schema plus tolerated local tags, so a `.gitlab-ci.yml` using them still parses structurally. + * + * `mergeTag` gives `<<: *anchor` its YAML 1.1 meaning — merge the anchored mapping in — which is how GitLab's own + * parser (Ruby's Psych) reads it, and how anchors are shared between jobs in practice. + */ export const GITLAB_CI_SCHEMA = yaml.CORE_SCHEMA.withTags( + yaml.mergeTag, anyLocalScalarTag, anyLocalSequenceTag, anyLocalMappingTag diff --git a/tests/extension-host/suite/mergeKey.test.ts b/tests/extension-host/suite/mergeKey.test.ts new file mode 100644 index 00000000..1c35a411 --- /dev/null +++ b/tests/extension-host/suite/mergeKey.test.ts @@ -0,0 +1,75 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +const EXTENSION_ID = 'eFAILution.gitlab-component-helper'; + +// __dirname is /out-test/suite at runtime; fixtures live under tests/fixtures. +const FIXTURE_DIR = path.resolve(__dirname, '..', '..', 'tests', 'fixtures', 'merge-key'); +const FIXTURE = path.join(FIXTURE_DIR, '.gitlab-ci.yml'); + +async function ensureActive(): Promise { + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(ext); + if (!ext.isActive) await ext.activate(); +} + +/** Wait until our own validation has run — the fixture's `bogus_input` guarantees one diagnostic to key off. */ +async function waitForOurDiagnostics(uri: vscode.Uri, timeoutMs = 5000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const ours = vscode.languages.getDiagnostics(uri).filter((d) => d.source === 'gitlab-component-helper'); + if (ours.some((d) => d.code === 'unknown-input')) return ours; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return vscode.languages.getDiagnostics(uri).filter((d) => d.source === 'gitlab-component-helper'); +} + +function labels(list: vscode.CompletionList | undefined): string[] { + return (list?.items ?? []).map((i) => (typeof i.label === 'string' ? i.label : i.label.label)); +} + +// Regression for the YAML merge key. GitLab parses with Psych, where `<<: *anchor` merges the anchored mapping in; +// without `mergeTag` the parser leaves a literal `<<` key, so a `spec.inputs` entry inheriting its `default` through +// the merge reads as having no default — reported missing — and `<<` itself surfaces as an input. Drives VS Code's +// diagnostics and completion engine end-to-end, which is where the user actually sees the bug. +suite('Merge-key inputs in a local include', () => { + suiteSetup(ensureActive); + + test('inputs inheriting a default through `<<:` are not reported missing', async () => { + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(FIXTURE)); + await vscode.window.showTextDocument(doc); + + const ours = await waitForOurDiagnostics(doc.uri); + const missing = ours.filter((d) => d.code === 'missing-required-input'); + assert.deepStrictEqual( + missing.map((d) => d.message), + [], + `stage and region both carry a default through the merge key, so neither is required. Got: ${JSON.stringify(ours.map((d) => ({ code: d.code, msg: d.message })))}` + ); + }); + + test('the merge key itself is not offered as an input', async () => { + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(FIXTURE)); + const editor = await vscode.window.showTextDocument(doc); + + // Open a fresh name slot under `job_name:` at that key's indent, then ask for completions at the caret. + const lines = doc.getText().split('\n'); + const jobNameLine = lines.findIndex((l) => l.trim().startsWith('job_name:')); + assert.ok(jobNameLine !== -1, 'fixture missing a job_name input line'); + const keyIndent = doc.lineAt(jobNameLine).firstNonWhitespaceCharacterIndex; + const insertAt = new vscode.Position(jobNameLine, doc.lineAt(jobNameLine).text.length); + await editor.edit((b) => b.insert(insertAt, `\n${' '.repeat(keyIndent)}`)); + + const list = await vscode.commands.executeCommand( + 'vscode.executeCompletionItemProvider', + doc.uri, + new vscode.Position(jobNameLine + 1, keyIndent) + ); + const got = labels(list); + + assert.ok(!got.includes('<<'), `'<<' is a merge key, not an input. Got: ${JSON.stringify(got)}`); + assert.ok(got.includes('stage'), `expected the merged-in 'stage' input. Got: ${JSON.stringify(got)}`); + assert.ok(got.includes('region'), `expected the merged-in 'region' input. Got: ${JSON.stringify(got)}`); + }); +}); diff --git a/tests/fixtures/merge-key/.gitlab-ci.yml b/tests/fixtures/merge-key/.gitlab-ci.yml new file mode 100644 index 00000000..1adc123a --- /dev/null +++ b/tests/fixtures/merge-key/.gitlab-ci.yml @@ -0,0 +1,7 @@ +include: + - local: "merge-key/templates/deploy.yml" + inputs: + job_name: deploy-prod + # Deliberately unknown: gives the test a diagnostic to wait for, so asserting the *absence* of a + # missing-required-input for the merge-inherited inputs cannot pass before validation has run. + bogus_input: oops diff --git a/tests/fixtures/merge-key/templates/deploy.yml b/tests/fixtures/merge-key/templates/deploy.yml new file mode 100644 index 00000000..7e4472d4 --- /dev/null +++ b/tests/fixtures/merge-key/templates/deploy.yml @@ -0,0 +1,23 @@ +.common-inputs: &common + stage: + description: The stage the job runs in + type: string + default: deploy + region: + description: Target region + type: string + default: eu-west-1 + +spec: + inputs: + # `stage` and `region` arrive through the merge key. Both carry a default, so neither is required — a parser + # that leaves `<<` literal instead reports them missing and offers an input named `<<`. + <<: *common + job_name: + description: The name of the CI job + type: string +--- +$[[ inputs.job_name ]]: + stage: $[[ inputs.stage ]] + script: + - echo "deploy to $[[ inputs.region ]]" diff --git a/tests/unit/yamlParser.test.ts b/tests/unit/yamlParser.test.ts index e74eaadd..dcde39ad 100644 --- a/tests/unit/yamlParser.test.ts +++ b/tests/unit/yamlParser.test.ts @@ -9,7 +9,7 @@ */ import * as assert from 'node:assert/strict'; -import { parseYaml, parseYamlDocuments, findDocumentWith } from '../../src/utils/yamlParser'; +import { parseYaml, parseYamlDocuments, findDocumentWith, isYamlNode } from '../../src/utils/yamlParser'; suite('parseYamlDocuments', () => { test('returns every mapping document of a multi-document stream', () => { @@ -67,6 +67,51 @@ test: assert.deepStrictEqual(docs[0].test, { script: [['.setup', 'script']] }); }); + // GitLab parses with Psych, where `<<: *anchor` merges. Left unmerged, an input inheriting its `default` through + // an anchor reads as required, and a merged `spec.inputs` offers an input named `<<`. + test('merges `<<:` into the surrounding mapping', () => { + const text = `.defaults: &defaults + stage: + type: string + default: build +spec: + inputs: + <<: *defaults + extra: + type: string +`; + const docs = parseYamlDocuments(text, true); + assert.deepStrictEqual(findDocumentWith(docs, 'spec')?.spec, { + inputs: { + stage: { type: 'string', default: 'build' }, + extra: { type: 'string' }, + }, + }); + }); + + test('merges a sequence of anchors, earlier entries winning', () => { + const text = `.a: &a + x: 1 + y: one +.b: &b + y: two + z: 3 +job: + <<: [*a, *b] +`; + const docs = parseYamlDocuments(text, true); + assert.deepStrictEqual(docs[0].job, { x: 1, y: 'one', z: 3 }); + }); + + // YAML 1.1 scalar resolution would make these booleans; all are plausible job or input names. + test('keeps `y`, `n`, `yes`, `no`, `on`, `off` as string keys', () => { + const text = 'spec:\n inputs:\n y: 1\n n: 2\n yes: 3\n no: 4\n on: 5\n off: 6\n'; + const spec = findDocumentWith(parseYamlDocuments(text, true), 'spec')?.spec; + assert.ok(isYamlNode(spec)); + assert.ok(isYamlNode(spec.inputs)); + assert.deepStrictEqual(Object.keys(spec.inputs), ['y', 'n', 'yes', 'no', 'on', 'off']); + }); + // Any local tag is fatal to a stock parse, not just a sequence-position `!reference`. Each of these forms took the // whole document down while only the sequence form was handled, so the tags match by prefix on `!` instead. test('tolerates a local tag in every node position', () => { From 48e72c10bb9060926c0a12318e7a175c0c7d6921 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 8 Sep 2026 23:59:37 +0000 Subject: [PATCH 11/54] chore(release): 0.17.3 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index edb1b29c..5b8f4265 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.17.2", + "version": "0.17.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.17.2", + "version": "0.17.3", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.2.2", diff --git a/package.json b/package.json index f5265290..c36d2f2f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gitlab-component-helper", "displayName": "GitLab Component Helper", "description": "Provides intellisense for GitLab CI components", - "version": "0.17.2", + "version": "0.17.3", "icon": "images/icon.png", "engines": { "node": ">=22.0.0", From dfed5024f9655d5cd272b122face22414811034a Mon Sep 17 00:00:00 2001 From: Cid-oe Date: Wed, 9 Sep 2026 17:01:48 +0530 Subject: [PATCH 12/54] fix(providers): match cached component templatePath regardless of ref (#276) (#278) --- src/providers/documentLinkProvider.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/providers/documentLinkProvider.ts b/src/providers/documentLinkProvider.ts index 498ac7c9..04e21579 100644 --- a/src/providers/documentLinkProvider.ts +++ b/src/providers/documentLinkProvider.ts @@ -88,8 +88,7 @@ export class ComponentDocumentLinkProvider implements vscode.DocumentLinkProvide (c) => c.gitlabInstance === parsed.gitlabInstance && c.sourcePath === parsed.path && - c.name === parsed.name && - (!parsed.version || c.version === parsed.version) + c.name === parsed.name ); // Only produce a link when the cache has a resolved templatePath for the component. Uncached components are From a83832c177e9acbeee274274fa4e208fb4fb9935 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 9 Sep 2026 11:34:29 +0000 Subject: [PATCH 13/54] chore(release): 0.17.4 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5b8f4265..e1837c99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.17.3", + "version": "0.17.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.17.3", + "version": "0.17.4", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.2.2", diff --git a/package.json b/package.json index c36d2f2f..25b46cbf 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gitlab-component-helper", "displayName": "GitLab Component Helper", "description": "Provides intellisense for GitLab CI components", - "version": "0.17.3", + "version": "0.17.4", "icon": "images/icon.png", "engines": { "node": ">=22.0.0", From e3cac4f9628788e3cb3364a1455d1d263dbfcbdc Mon Sep 17 00:00:00 2001 From: Simon Heather <32168619+X-Guardian@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:45:11 +0100 Subject: [PATCH 14/54] fix(details): make Refresh Versions work in the browser-opened details panel (#280) * fix(details): make Refresh Versions work in the browser-opened details panel * Fix test * fix(browser): label monorepo versions and surface version-change failures Ports the two gaps #281 caught into this branch. - The browser details panel's fetchVersions never sent versionLabels, so a refresh reverted the dropdown from '1.0.0' back to 'deploy-1.0.0' on a tag-per-component source. It now sends them like the detached panel does. - versionChangeError still hid the spinner and said nothing, the same silent failure this branch fixes one case block over. It now reports in the same slot, and both entry points clear a stale error before retrying. Rather than copy the label loop a third time, it moves to buildVersionLabels in tagScoping (pure, unit-tested) and the two existing copies collapse onto it. That also compiles the tag template once per list instead of once per tag. Co-authored-by: Cid-oe --------- Co-authored-by: Simon Heather Co-authored-by: eFAILution Co-authored-by: Cid-oe --- src/extension.ts | 41 ++++------- src/providers/componentBrowserProvider.ts | 73 ++++++++++---------- src/services/cache/componentCacheManager.ts | 9 ++- src/services/cache/versionCache.ts | 4 +- src/services/component/tagScoping.ts | 26 +++++++ src/services/component/versionLookupShape.ts | 29 ++++++++ src/types/cache.ts | 11 +++ tests/unit/tagScoping.test.ts | 32 +++++++-- tests/unit/versionLookupShape.test.ts | 56 +++++++++++++++ 9 files changed, 206 insertions(+), 75 deletions(-) create mode 100644 src/services/component/versionLookupShape.ts create mode 100644 tests/unit/versionLookupShape.test.ts diff --git a/src/extension.ts b/src/extension.ts index 876bfcc7..b4c54387 100755 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,33 +5,18 @@ import { CompletionProvider } from './providers/completionProvider'; import { ComponentDocumentLinkProvider } from './providers/documentLinkProvider'; import { ComponentBrowserProvider } from './providers/componentBrowserProvider'; import { detectIncludeComponent, Component } from './providers/componentDetector'; -import { stripTagPrefix } from './services/component/tagScoping'; +import { buildVersionLabels } from './services/component/tagScoping'; import { getComponentCacheManager, ComponentCacheManager } from './services/cache/componentCacheManager'; import { Logger } from './utils/logger'; import { ValidationProvider } from './providers/validationProvider'; import type { CachedComponent } from './types/cache'; +import { isVersionLookupShape } from './services/component/versionLookupShape'; import type { GitLabYamlFragment } from './types/gitlab-catalog'; import type { HoverContext } from './providers/hoverContentBuilder'; /** Component payload passed to the `detachHover` command. Adds the hover-builder's location context. */ type DetachableComponent = Component & { _hoverContext?: HoverContext }; -/** - * Type-guard narrowing a `Component`-shaped value to one that also satisfies `CachedComponent`. - * - * @param component A `Component` (typically `activeComponent` in the detach-hover panel) that may - * or may not have been enriched with cache details. - * @returns `true` if all `CachedComponent` required fields are present and string-typed, - * narrowing `component` to `Component & CachedComponent` in the truthy branch. - * `false` if any field is missing. - */ -function isCachedComponentShape(component: Component): component is Component & CachedComponent { - return typeof component.source === 'string' - && typeof component.sourcePath === 'string' - && typeof component.gitlabInstance === 'string' - && typeof component.version === 'string' - && typeof component.url === 'string'; -} import { getPerformanceMonitor } from './utils/performanceMonitor'; import { isGitLabCIFile, invalidateFileGlobsCache } from './utils/gitlabCiFileMatcher'; @@ -509,29 +494,27 @@ export function activate(context: vscode.ExtensionContext) { break; case 'fetchVersions': try { - if (!isCachedComponentShape(activeComponent)) { - throw new Error('Component is missing required fields (source, sourcePath, gitlabInstance, version) for version lookup.'); + if (!isVersionLookupShape(activeComponent)) { + throw new Error(`Cannot look up versions for ${activeComponent.name}: missing source path or GitLab instance.`); } - const versions = await cacheManager.fetchComponentVersions(activeComponent); + // Bind the narrowed value: `activeComponent` is reassignable, so TS widens it back across the await. + const lookupTarget = activeComponent; + const versions = await cacheManager.fetchComponentVersions(lookupTarget); // For a monorepo source, map each full tag to its stripped {version} so the dropdown shows short // labels while keeping the full tag as the option value (the inserted ref). - let versionLabels: Record | undefined; - if (activeComponent.tagPattern) { - versionLabels = {}; - for (const v of versions) { - versionLabels[v] = stripTagPrefix(v, activeComponent.name, activeComponent.tagPattern); - } - } + const versionLabels = buildVersionLabels(versions, lookupTarget.name, lookupTarget.tagPattern); panel.webview.postMessage({ command: 'versionsLoaded', versions: versions, versionLabels, - currentVersion: activeComponent.version + currentVersion: lookupTarget.version }); } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + logger.error(`[Extension] Error fetching versions for detached details panel: ${reason}`, 'Extension'); panel.webview.postMessage({ command: 'versionsError', - error: error instanceof Error ? error.message : String(error) + error: reason }); } break; diff --git a/src/providers/componentBrowserProvider.ts b/src/providers/componentBrowserProvider.ts index 4d93a058..428222ed 100644 --- a/src/providers/componentBrowserProvider.ts +++ b/src/providers/componentBrowserProvider.ts @@ -3,7 +3,7 @@ import { getComponentService } from '../services/component'; import { ComponentCacheManager } from '../services/cache/componentCacheManager'; import { GitLabCatalogComponent, GitLabCatalogVariable } from '../types/gitlab-catalog'; import type { ComponentParameter, Component } from './componentDetector'; -import type { CachedComponent } from '../types/cache'; +import { isVersionLookupShape } from '../services/component/versionLookupShape'; import type { SourceGroup, ComponentGroup, ComponentVersion } from './componentBrowserTypes'; import type { HoverContext } from './hoverContentBuilder'; import { containsGitLabVariables } from '../utils/gitlabVariables'; @@ -14,7 +14,7 @@ import { serializeForScript } from '../webview/scriptData'; import { generateComponentText } from './componentBrowserGenerate'; import { findComponentLineRange, parseExistingComponentText } from './componentBrowserEdit'; import { transformCachedComponentsToGroups } from './componentBrowserTransform'; -import { compileTagTemplate, stripTagPrefix } from '../services/component/tagScoping'; +import { buildVersionLabels, compileTagTemplate, stripTagPrefix } from '../services/component/tagScoping'; /** * Component shape carried through the detach-hover webview's "Open in Detailed View" round trip. @@ -24,19 +24,6 @@ import { compileTagTemplate, stripTagPrefix } from '../services/component/tagSco */ type DetachableComponent = Component & { _hoverContext?: HoverContext }; -/** - * Type-guard narrowing a `Component`-shaped value to one that also satisfies `CachedComponent`. - * `Component` carries `source`/`sourcePath`/`gitlabInstance`/`version`/`url` as optional; cache - * methods like `fetchComponentVersions` require them. The guard checks all five before the call so - * we don't pass a half-populated `Component` into a function expecting the full cache shape. - */ -function isCachedComponentShape(component: Component): component is Component & CachedComponent { - return typeof component.source === 'string' - && typeof component.sourcePath === 'string' - && typeof component.gitlabInstance === 'string' - && typeof component.version === 'string' - && typeof component.url === 'string'; -} /** * Pre-existing component shape parsed out of a `.gitlab-ci.yml` include line by @@ -553,19 +540,25 @@ export class ComponentBrowserProvider { } else if (message.command === 'fetchVersions') { // Fetch available versions for the component try { - if (!isCachedComponentShape(component)) { - throw new Error('Component is missing required fields (source, sourcePath, gitlabInstance, version) for version lookup.'); + if (!isVersionLookupShape(component)) { + throw new Error(`Cannot look up versions for ${component.name}: missing source path or GitLab instance.`); } const versions = await this.cacheManager.fetchComponentVersions(component); + // Same monorepo labelling as the detached panel: the webview can't reach the template matcher, so the + // full tag → stripped {version} map is built here. Without it a refresh reverts the dropdown to full tags. + const versionLabels = buildVersionLabels(versions, component.name, component.tagPattern); detailsPanel.webview.postMessage({ command: 'versionsLoaded', versions: versions, + versionLabels, currentVersion: component.version }); } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + this.logger.error(`[ComponentBrowser] Error fetching versions: ${reason}`, 'ComponentBrowser'); detailsPanel.webview.postMessage({ command: 'versionsError', - error: error instanceof Error ? error.message : String(error) + error: reason }); } } else if (message.command === 'versionChanged') { @@ -1633,6 +1626,9 @@ export class ComponentBrowserProvider { font-size: 0.9em; color: var(--vscode-disabledForeground); } + .version-loading.version-error { + color: var(--vscode-errorForeground); + } .parameters { border: 1px solid var(--vscode-panel-border); border-radius: 5px; @@ -1913,7 +1909,9 @@ export class ComponentBrowserProvider { console.log('Version changed to:', selectedVersion); - // Show loading state + // Show loading state, clearing any error left by a previous attempt. + loading.textContent = 'Loading version details...'; + loading.classList.remove('version-error'); loading.style.display = 'inline'; // Send message to fetch details for this version @@ -1927,6 +1925,9 @@ export class ComponentBrowserProvider { const loading = document.getElementById('versionLoading'); const select = document.getElementById('versionSelect'); + // Clear any error left by a previous attempt before starting a new one. + loading.textContent = 'Loading version details...'; + loading.classList.remove('version-error'); loading.style.display = 'inline'; select.disabled = true; @@ -2103,19 +2104,29 @@ export class ComponentBrowserProvider { case 'versionsLoaded': updateVersionDropdown(message.versions, message.currentVersion, message.versionLabels); break; - case 'versionsError': - document.getElementById('versionLoading').style.display = 'none'; + case 'versionsError': { + // Reuse the loading slot to report the failure: a refresh that silently does nothing reads as an + // inert button, so the user is told rather than left guessing. + const versionStatus = document.getElementById('versionLoading'); + versionStatus.textContent = 'Could not load versions: ' + (message.error || 'unknown error'); + versionStatus.classList.add('version-error'); + versionStatus.style.display = 'inline'; document.getElementById('versionSelect').disabled = false; - // Could show error message here break; + } case 'componentDetailsUpdated': updateComponentDetails(message.component); break; - case 'versionChangeError': - document.getElementById('versionLoading').style.display = 'none'; - // Could show error message here + case 'versionChangeError': { + // Same treatment as versionsError: a failed version switch used to hide the spinner and say nothing, + // which reads as the dropdown simply not working. + const changeStatus = document.getElementById('versionLoading'); + changeStatus.textContent = 'Could not load that version: ' + (message.error || 'unknown error'); + changeStatus.classList.add('version-error'); + changeStatus.style.display = 'inline'; console.error('Version change error:', message.error); break; + } } }); @@ -2140,6 +2151,7 @@ export class ComponentBrowserProvider { }); loading.style.display = 'none'; + loading.classList.remove('version-error'); select.disabled = false; currentVersions = versions; versionsLoaded = true; @@ -2800,16 +2812,7 @@ ${sourceErrors.size > 0 ? '\nErrors:\n' + Array.from(sourceErrors.entries()).map // For a monorepo source, precompute display labels (full tag → stripped {version}) server-side, since the // webview can't reach the template matcher. Non-monorepo sources send no labels (value == label). - let versionLabels: Record | undefined; - if (updatedComponent.tagPattern) { - const matcher = compileTagTemplate(updatedComponent.tagPattern, componentName); - if (matcher) { - versionLabels = {}; - for (const v of availableVersions) { - versionLabels[v] = matcher.extractVersion(v) ?? v; - } - } - } + const versionLabels = buildVersionLabels(availableVersions, componentName, updatedComponent.tagPattern); // Send versions to webview if (this.panel) { diff --git a/src/services/cache/componentCacheManager.ts b/src/services/cache/componentCacheManager.ts index 2a76ae13..411361ed 100644 --- a/src/services/cache/componentCacheManager.ts +++ b/src/services/cache/componentCacheManager.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import { getComponentService } from '../component'; import { Logger } from '../../utils/logger'; import { getPerformanceMonitor } from '../../utils/performanceMonitor'; -import { CachedComponent, PersistentCacheData } from '../../types/cache'; +import { CachedComponent, PersistentCacheData, VersionLookupComponent } from '../../types/cache'; import { ComponentSource } from '../../types/api'; import { reconcileComponentSource } from './sourceReconciliation'; import { ProjectCache } from './projectCache'; @@ -424,9 +424,12 @@ export class ComponentCacheManager implements vscode.Disposable { } /** - * Fetch and cache all available versions for a specific component + * Fetch and cache all available versions for a specific component. + * + * Takes the narrow {@link VersionLookupComponent} rather than a full cache entry: the lookup is driven entirely by + * the project coordinates, so callers holding a partial component (the details panel) can use it too. */ - public async fetchComponentVersions(component: CachedComponent): Promise { + public async fetchComponentVersions(component: VersionLookupComponent): Promise { try { const sortedVersions = await this.versionCache.fetchComponentVersions(component); diff --git a/src/services/cache/versionCache.ts b/src/services/cache/versionCache.ts index 9e7dfaed..75e9a46a 100644 --- a/src/services/cache/versionCache.ts +++ b/src/services/cache/versionCache.ts @@ -1,7 +1,7 @@ import { getComponentService } from '../component'; import { compileTagTemplate, scopeTagsToComponent } from '../component/tagScoping'; import { Logger } from '../../utils/logger'; -import { CachedComponent, VersionCacheSnapshot } from '../../types/cache'; +import { VersionCacheSnapshot, VersionLookupComponent } from '../../types/cache'; /** * VersionCache - Handles version fetching and caching for components @@ -30,7 +30,7 @@ export class VersionCache { * @param component Component to fetch versions for * @returns Array of sorted version strings (full prefixed tags for monorepo sources, plus `main`/`master`) */ - async fetchComponentVersions(component: CachedComponent): Promise { + async fetchComponentVersions(component: VersionLookupComponent): Promise { try { const cacheKey = `${component.gitlabInstance}|${component.sourcePath}`; let projectTags: string[] | undefined = this.projectTagsCache.get(cacheKey); diff --git a/src/services/component/tagScoping.ts b/src/services/component/tagScoping.ts index a8eb1d32..57a12963 100644 --- a/src/services/component/tagScoping.ts +++ b/src/services/component/tagScoping.ts @@ -132,3 +132,29 @@ export function stripTagPrefix(tag: string, componentName: string, template?: st const matcher = compileTagTemplate(template, componentName); return matcher?.extractVersion(tag) ?? tag; } + +/** + * Build the display-label map the version dropdown uses: full tag → stripped `{version}`. + * + * Returns `undefined` when the source is not a tag-per-component monorepo (no template, or one that doesn't + * compile), which is the "no labels — value is the label" signal the webview already falls back on. The template is + * compiled once for the whole list rather than per tag. + * + * @param versions The full tags to label. + * @param componentName The component name substituted for `{name}`. + * @param template The per-source tag-version template. Absent for ordinary sources. + */ +export function buildVersionLabels( + versions: string[], + componentName: string, + template?: string +): Record | undefined { + if (!template) { + return undefined; + } + const matcher = compileTagTemplate(template, componentName); + if (!matcher) { + return undefined; + } + return Object.fromEntries(versions.map((tag) => [tag, matcher.extractVersion(tag) ?? tag])); +} diff --git a/src/services/component/versionLookupShape.ts b/src/services/component/versionLookupShape.ts new file mode 100644 index 00000000..bab8a247 --- /dev/null +++ b/src/services/component/versionLookupShape.ts @@ -0,0 +1,29 @@ +/** + * Shared type-guard for "can this component be used to look up versions?". + * + * `vscode`-free and pure so the unit suite can drive it directly. Both details-panel entry points check the same + * thing, so they share one definition rather than a copy each. + */ + +import type { Component } from '../../providers/componentDetector'; +import type { VersionLookupComponent } from '../../types/cache'; + +/** + * Narrow a `Component` to the fields a version lookup needs: the project coordinates to query and the ref to fall + * back on. `Component` carries all three as optional, so they are checked before calling the cache. + * + * Deliberately does **not** require `url` or `source`. The details panel's component is rebuilt in the webview from + * a `ComponentVersion`, which has no `url`, and nothing in the lookup path reads one — `fetchComponentVersions` + * queries by `gitlabInstance` + `sourcePath` and scopes by `tagPattern`. Demanding `url` here only rejects + * components the cache could have served. + * + * @param component The component to check. + * @returns `true` when `sourcePath`, `gitlabInstance` and `version` are all present and string-typed. + */ +export function isVersionLookupShape( + component: Component +): component is Component & VersionLookupComponent { + return typeof component.sourcePath === 'string' + && typeof component.gitlabInstance === 'string' + && typeof component.version === 'string'; +} diff --git a/src/types/cache.ts b/src/types/cache.ts index 535b8d5f..6fd173e8 100644 --- a/src/types/cache.ts +++ b/src/types/cache.ts @@ -110,6 +110,17 @@ export interface CachedComponent { tagPattern?: string; } +/** + * The subset of {@link CachedComponent} a version lookup actually needs: the project coordinates to query, the + * current ref to fall back on, and the monorepo template used to scope the returned tags. + * + * Version fetching is reachable from surfaces that hold less than a full cache entry — the details panel receives a + * `ComponentVersion` built in the webview, which carries no `url`. Naming the real requirement lets those callers + * through instead of failing a `CachedComponent` check on fields the lookup never reads. + */ +export type VersionLookupComponent = Pick + & Pick; + /** * Serialized form of the per-project version caches, persisted in global state. * diff --git a/tests/unit/tagScoping.test.ts b/tests/unit/tagScoping.test.ts index b31eac90..c463e9b9 100644 --- a/tests/unit/tagScoping.test.ts +++ b/tests/unit/tagScoping.test.ts @@ -9,12 +9,7 @@ */ import * as assert from 'node:assert/strict'; -import { - compileTagTemplate, - scopeTagsToComponent, - stripTagPrefix, - DEFAULT_TAG_PATTERN, -} from '../../src/services/component/tagScoping'; +import { DEFAULT_TAG_PATTERN, buildVersionLabels, compileTagTemplate, scopeTagsToComponent, stripTagPrefix } from '../../src/services/component/tagScoping'; import { selectDefaultVersion } from '../../src/providers/componentBrowserTransform'; // A realistic mixed tag list for a tag-per-component monorepo using the default `{name}-{version}` convention. @@ -136,3 +131,28 @@ suite('selectDefaultVersion — monorepo', () => { assert.strictEqual(chosen, scoped[0]); }); }); + +suite('buildVersionLabels', () => { + const versions = ['deploy-1.0.0', 'deploy-1.1.0', 'main']; + + test('maps each tag to its stripped {version} for a monorepo source', () => { + assert.deepStrictEqual(buildVersionLabels(versions, 'deploy', '{name}-{version}'), { + 'deploy-1.0.0': '1.0.0', + 'deploy-1.1.0': '1.1.0', + // A tag that doesn't match the template (a branch name) keeps its full form. + main: 'main', + }); + }); + + test('returns undefined with no template, so the webview falls back to the full tag', () => { + assert.strictEqual(buildVersionLabels(versions, 'deploy', undefined), undefined); + }); + + test('returns undefined when the template does not compile', () => { + assert.strictEqual(buildVersionLabels(versions, 'deploy', 'no-tokens-here'), undefined); + }); + + test('returns an empty map rather than undefined for an empty version list', () => { + assert.deepStrictEqual(buildVersionLabels([], 'deploy', '{name}-{version}'), {}); + }); +}); diff --git a/tests/unit/versionLookupShape.test.ts b/tests/unit/versionLookupShape.test.ts new file mode 100644 index 00000000..a878fc27 --- /dev/null +++ b/tests/unit/versionLookupShape.test.ts @@ -0,0 +1,56 @@ +// @mocha +/** + * Tests src/services/component/versionLookupShape.ts — the guard deciding whether a component can be used for a + * version lookup. It previously required `url`, which the details panel's webview-rebuilt component never carries, + * so Refresh Versions failed there for every component. + */ + +import * as assert from 'node:assert/strict'; +import { isVersionLookupShape } from '../../src/services/component/versionLookupShape'; +import type { Component } from '../../src/providers/componentDetector'; + +/** The shape the details panel receives: a `ComponentVersion` plus name/version, with no `url`. */ +const detailsPanelComponent: Component = { + name: 'deploy', + description: 'Deploy the thing', + parameters: [], + source: 'Test Source', + sourcePath: 'group/monorepo', + gitlabInstance: 'gitlab.com', + version: 'deploy-1.0.0', +}; + +/** The fixture minus one field, for the "what happens when this is missing" cases. */ +function without(field: keyof Component): Component { + const component = { ...detailsPanelComponent }; + delete component[field]; + return component; +} + +suite('isVersionLookupShape', () => { + test('accepts the details panel component, which has no url', () => { + assert.strictEqual('url' in detailsPanelComponent, false, 'fixture should model the missing url'); + assert.strictEqual(isVersionLookupShape(detailsPanelComponent), true); + }); + + test('accepts a component with no source, which the lookup never reads', () => { + assert.strictEqual(isVersionLookupShape(without('source')), true); + }); + + test('still accepts a fully populated cache entry', () => { + const cached = { ...detailsPanelComponent, url: 'https://gitlab.com/group/monorepo/deploy@deploy-1.0.0' }; + assert.strictEqual(isVersionLookupShape(cached), true); + }); + + test('rejects a component with no sourcePath', () => { + assert.strictEqual(isVersionLookupShape(without('sourcePath')), false); + }); + + test('rejects a component with no gitlabInstance', () => { + assert.strictEqual(isVersionLookupShape(without('gitlabInstance')), false); + }); + + test('rejects a component with no version', () => { + assert.strictEqual(isVersionLookupShape(without('version')), false); + }); +}); From 70e144c6f5b5391faa7d4a1cfa389288d31f9c7c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 10 Sep 2026 13:48:46 +0000 Subject: [PATCH 15/54] chore(release): 0.17.5 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e1837c99..59f8e9b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.17.4", + "version": "0.17.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.17.4", + "version": "0.17.5", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.2.2", diff --git a/package.json b/package.json index 25b46cbf..5d3344bc 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gitlab-component-helper", "displayName": "GitLab Component Helper", "description": "Provides intellisense for GitLab CI components", - "version": "0.17.4", + "version": "0.17.5", "icon": "images/icon.png", "engines": { "node": ">=22.0.0", From b020d2d652237d8c114d00dd442f8df853a73180 Mon Sep 17 00:00:00 2001 From: Simon Heather <32168619+X-Guardian@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:54:45 +0100 Subject: [PATCH 16/54] refactor(webview): serve loading-view CSS from linted external file under CSP (#275) * refactor(webview): serve loading-view CSS from linted external file under CSP * Bump stylelint version * Fix review comments --------- Co-authored-by: Simon Heather --- .ai/architecture.yaml | 16 +- .ai/workflows.yaml | 1 + .github/workflows/ci.yml | 2 +- esbuild.js | 147 ++- package-lock.json | 1336 ++++++++++++++++++++- package.json | 11 +- src/providers/componentBrowserProvider.ts | 40 +- src/webview/styles/loading.css | 31 + src/webview/webviewHtml.ts | 65 + stylelint.config.mjs | 8 + 10 files changed, 1566 insertions(+), 91 deletions(-) create mode 100644 src/webview/styles/loading.css create mode 100644 src/webview/webviewHtml.ts create mode 100644 stylelint.config.mjs diff --git a/.ai/architecture.yaml b/.ai/architecture.yaml index c96e7296..b0d8a868 100644 --- a/.ai/architecture.yaml +++ b/.ai/architecture.yaml @@ -77,14 +77,16 @@ components: depends_on: - types - utils - templates: - location: src/templates/ - purpose: HTML template generation for webview UIs + webview: + location: src/webview/ + purpose: Helpers and assets for the webview documents rendered by the providers files: - detachedComponent.ts: Detached component view template - helpers/htmlBuilder.ts: HTML construction helper - helpers/styleBuilder.ts: CSS style helper - index.ts: Public exports + webviewHtml.ts: Nonce, Content-Security-Policy and asset-URI helpers for webview documents + inlineMarkdown.ts: HTML escaping and inline-Markdown rendering (vscode-free, unit-tested) + scriptData.ts: Safe JSON serialization for embedding data in a script block (vscode-free, unit-tested) + styles/: Stylesheets built to out/webview/styles/ and loaded via a CSP'd link + notes: Assets under styles/ (and client/ as scripts are extracted) are built by the webview esbuild + context and resolved at runtime through assetUri; they are not bundled into out/extension.js. depends_on: - types constants: diff --git a/.ai/workflows.yaml b/.ai/workflows.yaml index e9285c21..09699ef5 100644 --- a/.ai/workflows.yaml +++ b/.ai/workflows.yaml @@ -145,6 +145,7 @@ common_commands: debug: F5 (in VS Code) package: npm run package lint: npm run lint + lint_ci: npm run lint:ci (eslint + stylelint with GitHub annotation formatters; run by CI) git_workflow: branch_naming: feature/description or fix/description commit_format: 'type(scope): description' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5839f328..bdabbde2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: run: npm ci - name: Run lint - run: npm run lint + run: npm run lint:ci - name: Run compile run: node esbuild.js diff --git a/esbuild.js b/esbuild.js index 8eec0ae0..7303585b 100644 --- a/esbuild.js +++ b/esbuild.js @@ -1,9 +1,16 @@ const esbuild = require('esbuild'); +const fs = require('fs'); const production = process.argv.includes('--production'); const watch = process.argv.includes('--watch'); /** + * Emits the `[watch] build started`/`finished` markers VS Code's background problem matcher keys on. + * + * Only one context may emit them: the matcher treats the first `finished` as "task ready", so a second pair from a + * parallel build would mark the task ready while that build is still running. {@link errorReporterPlugin} covers the + * other contexts. + * * @type {import('esbuild').Plugin} */ const esbuildProblemMatcherPlugin = { @@ -14,42 +21,126 @@ const esbuildProblemMatcherPlugin = { console.log('[watch] build started'); }); build.onEnd((result) => { - result.errors.forEach(({ text, location }) => { - console.error(`✘ [ERROR] ${text}`); - console.error(` ${location.file}:${location.line}:${location.column}:`); - }); + reportErrors(result); console.log('[watch] build finished'); }); }, }; -async function main() { - const ctx = await esbuild.context({ - entryPoints: ['src/extension.ts'], - bundle: true, - format: 'cjs', - minify: production, - sourcemap: !production, - sourcesContent: false, - platform: 'node', - outfile: 'out/extension.js', - external: ['vscode'], - logLevel: 'silent', - // Additional optimizations - treeShaking: true, - metafile: production, - // Drop console logs in production for smaller bundle - drop: production ? ['console', 'debugger'] : [], - plugins: [ - /* add to the end of plugins array */ - esbuildProblemMatcherPlugin, - ], +/** + * Reports build errors without emitting watch markers, for contexts that build alongside the marker-emitting one. + * + * @param {string} label Which build the errors came from, since the output is interleaved. + * @returns {import('esbuild').Plugin} + */ +const errorReporterPlugin = (label) => ({ + name: `esbuild-error-reporter-${label}`, + + setup(build) { + build.onEnd((result) => reportErrors(result, label)); + }, +}); + +/** @param {import('esbuild').BuildResult} result @param {string} [label] */ +function reportErrors(result, label) { + const prefix = label ? `✘ [ERROR] [${label}] ` : '✘ [ERROR] '; + result.errors.forEach(({ text, location }) => { + console.error(`${prefix}${text}`); + if (location) { + console.error(` ${location.file}:${location.line}:${location.column}:`); + } + }); +} + +/** + * Build config for the Node-side extension bundle. + */ +const extensionConfig = { + entryPoints: ['src/extension.ts'], + bundle: true, + format: 'cjs', + minify: production, + sourcemap: !production, + sourcesContent: false, + platform: 'node', + outfile: 'out/extension.js', + external: ['vscode'], + logLevel: 'silent', + // Additional optimizations + treeShaking: true, + metafile: production, + // Drop console logs in production for smaller bundle + drop: production ? ['console', 'debugger'] : [], + plugins: [ + /* add to the end of plugins array */ + esbuildProblemMatcherPlugin, + ], +}; + +/** + * Every webview asset to build, discovered rather than listed. + * + * A stylesheet missing from a hand-maintained list still lints and typechecks, the build still exits 0, and the + * `` only 404s once the extension is packaged. Discovery keeps the build in step with the directory. + * + * Only the top level of each directory is collected, so shared modules imported by a client script are bundled into + * it rather than becoming entry points of their own. Directories that don't exist yet (`client/`, until scripts are + * extracted) contribute nothing. + * + * @returns {string[]} Paths of every asset entry point, relative to the repo root. + */ +function webviewEntryPoints() { + const assetDirs = [ + { dir: 'src/webview/styles', ext: '.css' }, + { dir: 'src/webview/client', ext: '.ts' }, + ]; + + return assetDirs.flatMap(({ dir, ext }) => { + if (!fs.existsSync(dir)) { + return []; + } + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(ext)) + .map((entry) => `${dir}/${entry.name}`); }); +} + +/** + * Build config for webview assets (styles, and client scripts as they are + * added). These run in the browser-like webview context, not Node, so they + * build separately and emit under out/webview for asWebviewUri loading. + */ +const webviewConfig = { + entryPoints: webviewEntryPoints(), + bundle: true, + minify: production, + sourcemap: !production, + platform: 'browser', + // Pin the syntax level: webview assets run in the Electron renderer, not Node, so they must not inherit + // esbuild's `esnext` default. + target: ['es2020'], + format: 'iife', + outdir: 'out/webview', + // Preserve the src/webview/* folder structure (styles/, client/) under the + // output dir so asset URIs resolve to the same sub-path as the source. + outbase: 'src/webview', + logLevel: 'silent', + plugins: [errorReporterPlugin('webview')], +}; + +async function main() { + // Clear stale output: esbuild never removes files, so a renamed or deleted asset would leave a copy behind that + // still resolves through asWebviewUri — masking a broken reference until the extension is packaged. + fs.rmSync(webviewConfig.outdir, { recursive: true, force: true }); + + const ctx = await esbuild.context(extensionConfig); + const webviewCtx = await esbuild.context(webviewConfig); if (watch) { - await ctx.watch(); + await Promise.all([ctx.watch(), webviewCtx.watch()]); } else { - await ctx.rebuild(); - await ctx.dispose(); + await Promise.all([ctx.rebuild(), webviewCtx.rebuild()]); + await Promise.all([ctx.dispose(), webviewCtx.dispose()]); } } diff --git a/package-lock.json b/package-lock.json index 59f8e9b0..340aebd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "devDependencies": { "@commitlint/cli": "^21.2.2", "@commitlint/config-conventional": "^21.0.2", + "@csstools/stylelint-formatter-github": "^2.0.0", "@digitalroute/cz-conventional-changelog-for-jira": "^8.0.1", "@eslint/js": "^10.0.1", "@octokit/core": "^7.0.8", @@ -31,6 +32,7 @@ "dotenv": "^17.4.2", "esbuild": "^0.28.2", "eslint": "^10.9.1", + "eslint-formatter-gha": "^2.0.1", "globals": "^17.12.0", "husky": "^9.1.6", "is-ci": "^4.0.0", @@ -40,6 +42,8 @@ "npm-run-all": "^4.1.5", "release-it": "^21.0.2", "semver": "^7.8.5", + "stylelint": "^17.15.0", + "stylelint-config-standard": "^40.0.0", "tsx": "^4.23.13", "typescript": "^6.0.3", "typescript-eslint": "^8.69.0" @@ -74,6 +78,67 @@ "node": ">=6.9.0" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/memory/node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/memory/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/@commitlint/cli": { "version": "21.2.2", "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.2.2.tgz", @@ -375,6 +440,191 @@ "node": ">=22" } }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.12.tgz", + "integrity": "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/media-query-list-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-5.0.0.tgz", + "integrity": "sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/selector-resolve-nested": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-4.0.1.tgz", + "integrity": "sha512-j3vdQu0XwLME5qOTWxm8cnmvsf423R2YL6DbKklCHZwkDm7UdKNu6RPlw4REIJhSlKBICY3B70/7QZdicLqZgg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.1.1" + } + }, + "node_modules/@csstools/selector-specificity": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz", + "integrity": "sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.1.1" + } + }, + "node_modules/@csstools/stylelint-formatter-github": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@csstools/stylelint-formatter-github/-/stylelint-formatter-github-2.0.0.tgz", + "integrity": "sha512-LAzHf7W1FmBNi/cA4n7ZqGW063UK+KZgcaoAmtkJZlbAxRlnthA/VEojmO06+eBAczKMMU1yslqsDh6Etvok8g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "stylelint": "^17.0.0" + } + }, "node_modules/@digitalroute/cz-conventional-changelog-for-jira": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/@digitalroute/cz-conventional-changelog-for-jira/-/cz-conventional-changelog-for-jira-8.0.1.tgz", @@ -1421,6 +1671,51 @@ } } }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@octokit/auth-token": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", @@ -1814,6 +2109,19 @@ "url": "https://ko-fi.com/dangreen" } }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -2318,6 +2626,16 @@ "node": ">=4" } }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -2633,6 +2951,30 @@ } } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/cacheable/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/cachedir": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", @@ -2946,6 +3288,13 @@ "dev": true, "license": "MIT" }, + "node_modules/colord": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz", + "integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==", + "dev": true, + "license": "MIT" + }, "node_modules/commitizen": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/commitizen/-/commitizen-4.3.2.tgz", @@ -3226,9 +3575,9 @@ "license": "MIT" }, "node_modules/cosmiconfig": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", - "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "dev": true, "license": "MIT", "dependencies": { @@ -3308,6 +3657,43 @@ "node": ">= 8" } }, + "node_modules/css-functions-list": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.3.3.tgz", + "integrity": "sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/cz-conventional-changelog": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/cz-conventional-changelog/-/cz-conventional-changelog-3.3.0.tgz", @@ -4036,22 +4422,134 @@ } } }, - "node_modules/eslint-scope": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "node_modules/eslint-formatter-gha": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/eslint-formatter-gha/-/eslint-formatter-gha-2.0.1.tgz", + "integrity": "sha512-HbiPXXjIley/JRcQ9zRZ4HJhhU8ZIf0wVLbfyQKm/7HIcbvOsA2xwl/Lblyd8fhYalUWxKcodJOwsOwxw7O0Cw==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { + "eslint-formatter-json": "^9.0.0", + "eslint-formatter-stylish": "^9.0.0" + } + }, + "node_modules/eslint-formatter-json": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/eslint-formatter-json/-/eslint-formatter-json-9.0.1.tgz", + "integrity": "sha512-EQ/X+bPjiOUDo4frNbkY+5R6emaHTL7t2jHsJw5s8p8w1BTJ6ao6S7g/PaRyx/CqqaYcjJkWAPHH/onNyFlJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/eslint-formatter-stylish": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/eslint-formatter-stylish/-/eslint-formatter-stylish-9.0.1.tgz", + "integrity": "sha512-FlhMRUiDUHztGj+bhm3pYo5DJ6Gbjh/FGPClcDENme7f9+Sc3+HZg6wMq80KJmRD8/oj/Ib6gy4D/ppjmHfGVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/eslint-formatter-stylish/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint-formatter-stylish/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint-formatter-stylish/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/eslint-formatter-stylish/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-formatter-stylish/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint-formatter-stylish/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { "url": "https://opencollective.com/eslint" } }, @@ -4259,6 +4757,36 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4317,6 +4845,26 @@ "fast-string-width": "^3.0.2" } }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fd-package-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", @@ -4801,6 +5349,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globby": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.4.tgz", + "integrity": "sha512-c8B/VNLmxRcmqqenRA9t+9IyOjf9+V6lTxPaUJLqOCONdQkWZ0ETYgX0qbtJqPsgCNusT9MZ5Jeidw8Eb9tn2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.5", + "is-path-inside": "^4.0.0", + "micromatch": "^4.0.8", + "slash": "^5.1.0", + "unicorn-magic": "^0.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globjoin": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", + "integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==", + "dev": true, + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4902,6 +5492,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -4928,6 +5531,26 @@ "node": ">=0.10.0" } }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-tags": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-5.1.0.tgz", + "integrity": "sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -5011,9 +5634,9 @@ "license": "BSD-3-Clause" }, "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", + "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", "dev": true, "license": "MIT", "engines": { @@ -5054,6 +5677,17 @@ "node": ">=4" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -6037,6 +6671,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -6173,6 +6817,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.uniqby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", @@ -6306,6 +6957,24 @@ "node": ">= 0.4" } }, + "node_modules/mathml-tag-names": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-4.0.0.tgz", + "integrity": "sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/memorystream": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", @@ -6315,6 +6984,19 @@ "node": ">= 0.10.0" } }, + "node_modules/meow": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", + "integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/merge/-/merge-2.1.1.tgz", @@ -6322,6 +7004,16 @@ "dev": true, "license": "MIT" }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -6506,6 +7198,25 @@ "dev": true, "license": "ISC" }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -6559,6 +7270,16 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npm-run-all": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", @@ -7374,10 +8095,87 @@ "node": ">= 0.4" } }, - "node_modules/powershell-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", - "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-safe-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.1.0.tgz", + "integrity": "sha512-1WzZxRLaAFwEh6Do+zyGpjWV3nGJNxxhuh7Ubu/q1ICImMgZJnLwhoaEpRbG4pJppp2Y1ncL9ffA2f+LhrefQg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", "dev": true, "license": "MIT", "engines": { @@ -7519,6 +8317,47 @@ "node": ">=6" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/quickjs-wasi": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-2.2.0.tgz", @@ -7982,6 +8821,17 @@ "node": ">= 4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/right-pad": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/right-pad/-/right-pad-1.1.1.tgz", @@ -8016,6 +8866,30 @@ "node": ">=0.12.0" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -8307,6 +9181,73 @@ "dev": true, "license": "ISC" }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/slice-ansi/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -8369,6 +9310,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -8571,6 +9522,231 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stylelint": { + "version": "17.15.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.15.0.tgz", + "integrity": "sha512-mWIkesYQvQjf4Kvdeu9ns0IL8/K/wtjaGxPqsPd6DlLZvaQxGysJsocxUDrBcyHQ5VleAk4uSMat4yMrVED7PA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + }, + { + "type": "github", + "url": "https://github.com/sponsors/stylelint" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-syntax-patches-for-csstree": "^1.1.9", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/media-query-list-parser": "^5.0.0", + "@csstools/selector-resolve-nested": "^4.0.1", + "@csstools/selector-specificity": "^6.0.0", + "colord": "^2.10.0", + "cosmiconfig": "^9.0.2", + "css-functions-list": "^3.3.3", + "css-tree": "^3.2.1", + "debug": "^4.4.3", + "fast-glob": "^3.3.3", + "fastest-levenshtein": "^1.0.16", + "file-entry-cache": "^11.1.5", + "global-modules": "^2.0.0", + "globby": "^16.2.4", + "globjoin": "^0.1.4", + "html-tags": "^5.1.0", + "ignore": "^7.0.6", + "import-meta-resolve": "^4.2.0", + "mathml-tag-names": "^4.0.0", + "meow": "^14.1.0", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.5.26", + "postcss-safe-parser": "^7.0.1", + "postcss-selector-parser": "^7.1.5", + "postcss-value-parser": "^4.2.0", + "string-width": "^8.2.2", + "supports-hyperlinks": "^4.5.0", + "svg-tags": "^1.0.0", + "table": "^6.9.0", + "write-file-atomic": "^7.0.1" + }, + "bin": { + "stylelint": "bin/stylelint.mjs" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/stylelint-config-recommended": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-18.0.0.tgz", + "integrity": "sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + }, + { + "type": "github", + "url": "https://github.com/sponsors/stylelint" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "stylelint": "^17.0.0" + } + }, + "node_modules/stylelint-config-standard": { + "version": "40.0.0", + "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-40.0.0.tgz", + "integrity": "sha512-EznGJxOUhtWck2r6dJpbgAdPATIzvpLdK9+i5qPd4Lx70es66TkBPljSg4wN3Qnc6c4h2n+WbUrUynQ3fanjHw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + }, + { + "type": "github", + "url": "https://github.com/sponsors/stylelint" + } + ], + "license": "MIT", + "dependencies": { + "stylelint-config-recommended": "^18.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "stylelint": "^17.0.0" + } + }, + "node_modules/stylelint/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/stylelint/node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/stylelint/node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/stylelint/node_modules/global-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", + "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-prefix": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/stylelint/node_modules/global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/stylelint/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/stylelint/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylelint/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/stylelint/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -8584,6 +9760,49 @@ "node": ">=4" } }, + "node_modules/supports-hyperlinks": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.5.0.tgz", + "integrity": "sha512-ZW2OvfeCXrNTbLakPUzjQG922EeGCOteFSVoek5DKStTh898wf7zgtuFlzQN8HfZCxC3Eh02yJVrRW51hADf+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^5.0.1", + "supports-color": "^10.2.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/supports-hyperlinks/node_modules/has-flag": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz", + "integrity": "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-hyperlinks/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -8597,6 +9816,36 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-tags": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", + "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", + "dev": true + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -8899,6 +10148,19 @@ "dev": true, "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/universal-user-agent": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", @@ -9206,6 +10468,32 @@ "dev": true, "license": "ISC" }, + "node_modules/write-file-atomic": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", + "integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==", + "dev": true, + "license": "ISC", + "dependencies": { + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/wsl-utils": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", diff --git a/package.json b/package.json index 5d3344bc..18f85da2 100644 --- a/package.json +++ b/package.json @@ -281,7 +281,12 @@ "watch:tsc": "tsc --noEmit --watch --project tsconfig.json", "package": "npm run check-types && node esbuild.js --production", "check-types": "tsc --noEmit && tsc --noEmit --project tsconfig.tests.json", - "lint": "eslint --max-warnings 0", + "lint": "npm-run-all -c lint:eslint lint:stylelint", + "lint:eslint": "eslint --max-warnings 0", + "lint:stylelint": "stylelint \"src/webview/**/*.css\"", + "lint:ci": "npm-run-all -c lint:ci:*", + "lint:ci:eslint": "eslint --max-warnings 0 --format gha", + "lint:ci:stylelint": "stylelint \"src/webview/**/*.css\" --custom-formatter @csstools/stylelint-formatter-github", "pretest": "npm run compile", "prepare": "husky", "test": "mocha", @@ -306,6 +311,7 @@ "devDependencies": { "@commitlint/cli": "^21.2.2", "@commitlint/config-conventional": "^21.0.2", + "@csstools/stylelint-formatter-github": "^2.0.0", "@digitalroute/cz-conventional-changelog-for-jira": "^8.0.1", "@eslint/js": "^10.0.1", "@octokit/core": "^7.0.8", @@ -326,6 +332,7 @@ "dotenv": "^17.4.2", "esbuild": "^0.28.2", "eslint": "^10.9.1", + "eslint-formatter-gha": "^2.0.1", "globals": "^17.12.0", "husky": "^9.1.6", "is-ci": "^4.0.0", @@ -335,6 +342,8 @@ "npm-run-all": "^4.1.5", "release-it": "^21.0.2", "semver": "^7.8.5", + "stylelint": "^17.15.0", + "stylelint-config-standard": "^40.0.0", "tsx": "^4.23.13", "typescript": "^6.0.3", "typescript-eslint": "^8.69.0" diff --git a/src/providers/componentBrowserProvider.ts b/src/providers/componentBrowserProvider.ts index 428222ed..8e176fb7 100644 --- a/src/providers/componentBrowserProvider.ts +++ b/src/providers/componentBrowserProvider.ts @@ -15,6 +15,7 @@ import { generateComponentText } from './componentBrowserGenerate'; import { findComponentLineRange, parseExistingComponentText } from './componentBrowserEdit'; import { transformCachedComponentsToGroups } from './componentBrowserTransform'; import { buildVersionLabels, compileTagTemplate, stripTagPrefix } from '../services/component/tagScoping'; +import { assetUri, createNonce, cspMetaTag } from '../webview/webviewHtml'; /** * Component shape carried through the detach-hover webview's "Open in Detailed View" round trip. @@ -75,13 +76,13 @@ export class ComponentBrowserProvider { enableScripts: true, retainContextWhenHidden: true, localResourceRoots: [ - vscode.Uri.joinPath(this.context.extensionUri, 'media') + vscode.Uri.joinPath(this.context.extensionUri, 'out', 'webview') ] } ); // Set initial HTML content with loading message - this.panel.webview.html = this.getLoadingHtml(); + this.panel.webview.html = this.getLoadingHtml(this.panel.webview); // Handle panel disposal this.panel.onDidDispose(() => { @@ -152,7 +153,7 @@ export class ComponentBrowserProvider { try { // Show loading state - this.panel.webview.html = this.getLoadingHtml(); + this.panel.webview.html = this.getLoadingHtml(this.panel.webview); this.logger.debug(`[ComponentBrowser] Loading components, forceRefresh: ${forceRefresh}`, 'ComponentBrowser'); @@ -605,39 +606,18 @@ export class ComponentBrowserProvider { ); } - private getLoadingHtml(): string { + private getLoadingHtml(webview: vscode.Webview): string { + const nonce = createNonce(); + const styleUri = assetUri(webview, this.context.extensionUri, 'styles/loading.css'); return ` + ${cspMetaTag(webview, nonce)} + GitLab CI/CD Components -
@@ -2528,7 +2508,7 @@ ${sourceErrors.size > 0 ? '\nErrors:\n' + Array.from(sourceErrors.entries()).map // Clear the browser and show empty state if (this.panel) { - this.panel.webview.html = this.getLoadingHtml(); + this.panel.webview.html = this.getLoadingHtml(this.panel.webview); } // Reload components in the browser (this will fetch fresh data) diff --git a/src/webview/styles/loading.css b/src/webview/styles/loading.css new file mode 100644 index 00000000..9460b326 --- /dev/null +++ b/src/webview/styles/loading.css @@ -0,0 +1,31 @@ +body { + font-family: var(--vscode-font-family); + color: var(--vscode-editor-foreground); + padding: 20px; + background-color: var(--vscode-editor-background); +} + +.loading { + text-align: center; + padding: 40px; +} + +.spinner { + border: 4px solid rgb(0 0 0 / 10%); + width: 36px; + height: 36px; + border-radius: 50%; + border-left-color: var(--vscode-button-background); + animation: spin 1s linear infinite; + margin: 0 auto 20px; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} diff --git a/src/webview/webviewHtml.ts b/src/webview/webviewHtml.ts new file mode 100644 index 00000000..0b3ff864 --- /dev/null +++ b/src/webview/webviewHtml.ts @@ -0,0 +1,65 @@ +import * as vscode from 'vscode'; + +/** + * Shared helpers for rendering webview HTML with a Content-Security-Policy, + * a per-render nonce, and webview-safe asset URIs. + * + * VS Code webviews cannot load extension files by path: every / & 'q'`), + '<script>alert("x")</script> & 'q'', + ); + }); + + test('matches the server renderer across representative descriptions', () => { + const render = loadClientRenderer(); + const samples = [ + 'CI Job template to deploy a service to an ECS cluster', + 'A [GitLab CI/CD component](https://example.com/c) that lints Dockerfiles using [hadolint](https://example.com/h)', + 'Installs the `yu-ci-tools` binary CLI', + '**Bold** lead-in, *emphasis*, and a `code` span', + 'Ampersands & and "quotes"', + '', + ]; + + for (const sample of samples) { + assert.equal(render(sample), renderInlineMarkdown(sample), `mismatch for: ${sample}`); + } + }); +}); From 4ba468adfd90a7f648dd2391040b2265882e308c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 15 Sep 2026 01:49:12 +0000 Subject: [PATCH 25/54] chore(release): 0.17.10 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2c86d2cc..33efbcb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.17.9", + "version": "0.17.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.17.9", + "version": "0.17.10", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.2.2", diff --git a/package.json b/package.json index 788cb609..6c110cb6 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gitlab-component-helper", "displayName": "GitLab Component Helper", "description": "Provides intellisense for GitLab CI components", - "version": "0.17.9", + "version": "0.17.10", "icon": "images/icon.png", "engines": { "node": ">=22.0.0", From 8a9374500072e47cea57eb9aece48711a36bbe6d Mon Sep 17 00:00:00 2001 From: eFAILution <128437814+eFAILution@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:06:05 -0400 Subject: [PATCH 26/54] fix(ai): repair dangling component references in architecture.yaml (#298) The AICaC Adoption check has been failing since #275: architecture.yaml[providers].depends_on references unknown component 'templates' #275 correctly renamed the 'templates' component to 'webview' when src/templates/ was removed, but left providers.depends_on pointing at the old name. Repointed at 'webview', which is what the providers actually import (componentBrowserProvider pulls in inlineMarkdown, scriptData, webviewHtml and clientInlineMarkdown). aicac.yml triggers only on main, so no beta PR runs it. The failures surface on the open release PR #271 (beta -> main), which re-runs on every push to beta, and would follow onto main itself on merge. Two adjacent staleness issues the checker does not catch, fixed while here: - providers.files listed componentHtmlRenderer.ts, deleted as dead code in #158. Replaced with hoverContentBuilder.ts, which holds that role now. - The hover_documentation data flow named the same deleted file and claimed it renders HTML 'using templates/helpers'. Hover builds a MarkdownString; there are no HTML templates in that path and no src/templates/ directory. Also lists clientInlineMarkdown.ts under the webview component, added in #290. Co-authored-by: eFAILution --- .ai/architecture.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.ai/architecture.yaml b/.ai/architecture.yaml index b0d8a868..21773d61 100644 --- a/.ai/architecture.yaml +++ b/.ai/architecture.yaml @@ -22,12 +22,12 @@ components: validationProvider.ts: Input validation with Quick Fixes componentBrowserProvider.ts: Component browser webview UI componentDetector.ts: Detect GitLab CI component usage in YAML - componentHtmlRenderer.ts: Render component docs as HTML + hoverContentBuilder.ts: Build the hover popup's markdown body (vscode-free, unit-tested) depends_on: - services - utils - types - - templates + - webview services: location: src/services/ purpose: Business logic and data management @@ -84,6 +84,7 @@ components: webviewHtml.ts: Nonce, Content-Security-Policy and asset-URI helpers for webview documents inlineMarkdown.ts: HTML escaping and inline-Markdown rendering (vscode-free, unit-tested) scriptData.ts: Safe JSON serialization for embedding data in a script block (vscode-free, unit-tested) + clientInlineMarkdown.ts: Source text for the browser-side twin of renderInlineMarkdown (vscode-free, unit-tested) styles/: Stylesheets built to out/webview/styles/ and loaded via a CSP'd link notes: Assets under styles/ (and client/ as scripts are extracted) are built by the webview esbuild context and resolved at runtime through assetUri; they are not bundled into out/extension.js. @@ -215,8 +216,8 @@ data_flow: component: componentService action: Fetch component details (cache-first) - step: 4 - component: componentHtmlRenderer - action: Render documentation as HTML using templates/helpers + component: hoverContentBuilder + action: Build the hover markdown body (a MarkdownString, not HTML) - step: 5 component: hoverProvider action: Display hover card From 7ebd81a0bb9faa2088a810c926d5090677360f7d Mon Sep 17 00:00:00 2001 From: eFAILution <128437814+eFAILution@users.noreply.github.com> Date: Tue, 15 Sep 2026 02:06:25 +0000 Subject: [PATCH 27/54] chore(ai): regenerate .ai/index.yaml --- .ai/index.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.ai/index.yaml b/.ai/index.yaml index efc14d4c..e06db923 100644 --- a/.ai/index.yaml +++ b/.ai/index.yaml @@ -25,9 +25,9 @@ keys: - providers - scripts - services - - templates - types - utils + - webview workflows: - add_configuration_option - add_new_command @@ -43,6 +43,7 @@ keys: - BATCH_API_REQUESTS - CACHE_COMPONENTS - CENTRALIZED_ERROR_HANDLING + - DEPENDABOT_TARGETS_BETA - EXTENSION_HOST_TEST_LAYER - FILE_SIZE_POLICY - MOCHA_OVER_JEST From a2f4b09bb006a57aae4d976fca7fd9a43994c91d Mon Sep 17 00:00:00 2001 From: Simon Heather <32168619+X-Guardian@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:28:20 +0100 Subject: [PATCH 28/54] fix(completion): treat boolean inputs as booleans, not quoted strings (#283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(completion): treat boolean inputs as booleans, not quoted strings * Fix review comments * fix(parser): tell input names from input keys by depth, not by name The reserved-key set added for the phantom-`default`-input fix drops any input actually named `type`, `default`, `options`, `description` or `regex` — all legal GitLab input names, and `type` a plausible one (deployment type, runner type). The input vanished from completion, hover and validation with no diagnostic. Anchors on the indentation of the first input name instead: siblings share it, and an input's own keys are always deeper. That settles the shallow layout the set was added for (`inputs:` at column 0 puts both input names and their keys at four spaces) without reserving any name. The property branch keys off the same anchor rather than a fixed four spaces, so the two halves can no longer disagree about which is which. --------- Co-authored-by: Simon Heather Co-authored-by: eFAILution --- src/parsers/parameterDefaultShape.ts | 31 +++ src/parsers/specParser.ts | 164 +++++++++++-- src/providers/completionInputContext.ts | 66 +++-- src/providers/completionProvider.ts | 15 +- src/providers/localComponentResolver.ts | 16 +- src/providers/validationProvider.ts | 8 +- src/services/cache/componentCacheManager.ts | 2 +- src/services/component/componentFetcher.ts | 9 +- .../component/componentFetcherTemplates.ts | 42 +++- tests/unit/GitLabSpecParser.test.ts | 232 +++++++++++++++++- tests/unit/completionInputContext.test.ts | 41 +++- tests/unit/componentFetcherTemplates.test.ts | 66 ++++- 12 files changed, 601 insertions(+), 91 deletions(-) create mode 100644 src/parsers/parameterDefaultShape.ts diff --git a/src/parsers/parameterDefaultShape.ts b/src/parsers/parameterDefaultShape.ts new file mode 100644 index 00000000..7d151164 --- /dev/null +++ b/src/parsers/parameterDefaultShape.ts @@ -0,0 +1,31 @@ +/** + * Shared type-guard for "is this value usable as an input's `default:`?". + * + * `vscode`-free and pure so the Mocha unit suite (and `specParser`, which must load under plain Node) can import it + * directly. Both spec parsers narrow the same union, so they share one definition rather than a copy each. + */ + +import type { ParameterDefault } from '../types/git-component'; + +/** + * Narrow an unknown value to {@link ParameterDefault}, the union accepted by an input's `default:` field. + * + * Guards the boundary where a parsed spec becomes a typed parameter: YAML and the catalog API both hand back + * `unknown`, and anything outside the union (a mapping, a nested array) is rejected rather than carried forward as + * a default no consumer can render. + * + * @param value - A value of unknown shape, typically straight from a YAML parse or an API response. + * @returns `true` when `value` is a string, number, boolean, `null`, or an array of those primitives. + */ +export function isParameterDefault(value: unknown): value is ParameterDefault { + if (value === null) return true; + const t = typeof value; + if (t === 'string' || t === 'number' || t === 'boolean') return true; + if (Array.isArray(value)) { + return value.every(v => { + const vt = typeof v; + return vt === 'string' || vt === 'number' || vt === 'boolean'; + }); + } + return false; +} diff --git a/src/parsers/specParser.ts b/src/parsers/specParser.ts index 2386a19a..acf6b000 100644 --- a/src/parsers/specParser.ts +++ b/src/parsers/specParser.ts @@ -3,17 +3,97 @@ import { Logger } from '../utils/logger'; // Import direct from errors/types (not the barrel) so this module can load from plain Node — the barrel re-exports // from errors/handler.ts which imports `vscode` at module load. Required for the Mocha unit suite. import { ParseError } from '../errors/types'; +import type { ParameterDefault } from '../types/git-component'; +import { isParameterDefault } from './parameterDefaultShape'; +import * as yaml from 'js-yaml'; +import { isYamlNode, GITLAB_CI_SCHEMA } from '../utils/yamlParser'; const logger = Logger.getInstance(); +/** + * Resolve one already-trimmed scalar to the value YAML reads it as. + * + * @param trimmed - A single scalar's text, surrounding whitespace already removed. + * @returns The parsed value, or the text with surrounding quotes stripped when it isn't a parseable YAML scalar. + */ +function parseScalar(trimmed: string): ParameterDefault { + try { + // `yaml.load` directly rather than `parseYaml`: these probes are one-off strings, so memoising them only evicts + // the document parses sharing that cache. + const parsed = yaml.load(`probe: ${trimmed}`, { schema: GITLAB_CI_SCHEMA }); + if (isYamlNode(parsed) && isParameterDefault(parsed.probe)) { + return parsed.probe; + } + } catch { + // Not valid YAML on its own (an unquoted `*`, a stray `{`, …) — fall back to the literal text. + } + return trimmed.replace(/^["']|["']$/g, '').trim(); +} + +/** + * Parse the text after `default:` into the value the input's type says it is. + * + * This parser reads the spec line-by-line rather than as a document, so a `default:` value arrives as raw text. Left + * as text, `default: false` on a `type: boolean` input becomes the string `"false"`, which downstream renders back + * as a quoted `"false"` — a string where GitLab expects a boolean. + * + * A `string` input's default is text by declaration, so it is never sent through YAML: `default: 1.0` stays `"1.0"` + * and `0755` keeps its leading zero. That covers an omitted `type:` as well — GitLab resolves an untyped input as + * `StringInput` (its `matches?` accepts a spec with no `:type` key) and coerces the default with `to_s`, so an + * untyped default is a string no matter what it looks like. + * + * @param rawValue - The text following `default:` on the line, e.g. `false`, `"0"`, `[a, b]`. + * @param inputType - The input's resolved `type:`; `'string'` when the spec omits it. + * @returns The parsed value, or the trimmed raw text when it doesn't parse as a YAML scalar. + */ +function parseDefaultValue(rawValue: string, inputType: string): ParameterDefault { + const trimmed = rawValue.trim(); + // An empty `default:` is an explicit empty string, not an absent default — absent inputs are marked required. + if (trimmed.length === 0) { + return ''; + } + if (inputType === 'string') { + return trimmed.replace(/^["']|["']$/g, '').trim(); + } + return parseScalar(trimmed); +} + +/** + * Parse one `options:` entry into the value YAML says it is. + * + * Kept in step with {@link parseDefaultValue}: an entry and a default that read the same in the spec must produce + * the same value, or a `default: false` never matches the `false` in `options: [true, false]` and fails to + * pre-select. A `string` input — declared or by omission — keeps entries literal for the same reason defaults do. + * + * @param entry - One option's raw text, quotes and surrounding whitespace included. + * @param inputType - The input's resolved `type:`; `'string'` when the spec omits it. + * @returns The entry as a string, number, or boolean. + */ +function parseOptionEntry(entry: string, inputType: string): string | number | boolean { + const trimmed = entry.trim(); + if (inputType === 'string') { + return trimmed.replace(/^["']|["']$/g, '').trim(); + } + const parsed = parseScalar(trimmed); + // `options:` entries are scalars; a null or nested-array entry isn't meaningful, so keep those as their text. + return parsed === null || Array.isArray(parsed) ? trimmed.replace(/^["']|["']$/g, '').trim() : parsed; +} + export interface ComponentVariable { name: string; description: string; required: boolean; type: string; - default?: string; - /** Allowed values from the input's `options:` list, in declaration order; absent when no `options:` is given. */ - options?: string[]; + /** + * The input's `default:`, as the value YAML says it is — `default: false` is the boolean `false`, not `"false"`. + * Consumers render it back to YAML by type, so a string default here must be a genuine string. + */ + default?: ParameterDefault; + /** + * Allowed values from the input's `options:` list, in declaration order; absent when no `options:` is given. + * Entries carry their YAML types, so they compare equal to a `default` that names one of them. + */ + options?: Array; } export interface ParsedSpec { @@ -26,11 +106,12 @@ export interface ParsedSpec { * Parse the inline form of an `options:` value (`options: [a, "b", c]`). * * Returns an empty array for the expanded form (`options:` with the values on following `- item` lines), which the - * caller then fills in as it reads those lines. Surrounding brackets and per-entry quotes are stripped; blank entries - * (e.g. a trailing comma) are dropped. + * caller then fills in as it reads those lines. Surrounding brackets are stripped and blank entries (e.g. a trailing + * comma) are dropped, but each entry stays raw text — {@link parseOptionEntry} types it once the input's `type:` is + * known, which may be declared after `options:`. * * @param rawValue - The text after `options:` on the same line, e.g. `[a, "b", c]` (or empty for the expanded form). - * @returns The parsed option values, or an empty array when the value isn't an inline `[...]` list. + * @returns The raw option entries, or an empty array when the value isn't an inline `[...]` list. */ function parseInlineOptions(rawValue: string): string[] { if (!rawValue.startsWith('[')) { @@ -39,7 +120,7 @@ function parseInlineOptions(rawValue: string): string[] { return rawValue .replace(/^\[|\]$/g, '') .split(',') - .map(entry => entry.trim().replace(/^["']|["']$/g, '').trim()) + .map(entry => entry.trim()) .filter(entry => entry.length > 0); } @@ -146,6 +227,35 @@ export class GitLabSpecParser { .filter(line => line.trim() && !line.trim().startsWith('#')); let currentInput: ComponentVariable | null = null; + // Indentation of the first input name seen, which every sibling input shares. Input names and their own keys are + // told apart by depth rather than by name: an input may legitimately be called `type` or `default`, and a spec + // indented `inputs:` at column 0 puts both at four spaces. + let inputIndent: number | null = null; + // The `default:` text for `currentInput`, held until the input ends so the declared `type:` can steer the parse. + let rawDefault: string | null = null; + // Raw `options:` entries for `currentInput`, typed by `finalizeInput` for the same reason as `rawDefault`. + let rawOptions: string[] | null = null; + + /** + * Resolve the pending `default:` and `options:` against the declared type and mark a defaultless input + * required. Deferred to here because `type:` may be declared after either of them. + */ + const finalizeInput = (input: ComponentVariable): ComponentVariable => { + // An omitted `type:` is `string` to GitLab, and `input.type` already holds that fallback. + if (rawDefault !== null) { + input.default = parseDefaultValue(rawDefault, input.type); + } else { + // GitLab CI/CD component behavior: an input with no default is required. + input.required = true; + } + if (rawOptions !== null) { + // Typed against the input's type so an entry matches a `default` naming the same value. + input.options = rawOptions.map(entry => parseOptionEntry(entry, input.type)); + } + rawDefault = null; + rawOptions = null; + return input; + }; for (const line of inputLines) { const trimmedLine = line.trim(); @@ -157,19 +267,24 @@ export class GitLabSpecParser { break; } + const indent = line.length - line.trimStart().length; + // New input parameter (indented under inputs) - handle both 2-space and 4-space indentation. // Match lines like " name:" where the input name ends with ":" and has only whitespace after. // The name class includes `-`: GitLab input names are commonly hyphenated (e.g. `job-name`). Without // it, a hyphenated key isn't recognised as a new input, so its `description:`/`default:` lines bleed // onto the previous (non-hyphenated) input and mis-map every field after it (issue #211). - if (line.match(/^\s{2,4}[a-zA-Z_][a-zA-Z0-9_-]*:\s*$/)) { + // + // Siblings must share the first input's indentation. A valueless `default:` under a spec indented `inputs:` + // at column 0 sits at four spaces and matches the shape of an input name, so without the depth check it + // opens a phantom input named `default` and swallows the real input's value. Depth rather than a list of + // reserved key names, because `type`, `default` and `options` are all legal input names. + const looksLikeInputName = /^\s{2,4}[a-zA-Z_][a-zA-Z0-9_-]*:\s*$/.test(line); + if (looksLikeInputName && (inputIndent === null || indent === inputIndent)) { + inputIndent = indent; // If we have a current input, finalize it before starting a new one if (currentInput) { - // Mark as required if no default was specified (GitLab CI/CD component behavior) - if (currentInput.default === undefined) { - currentInput.required = true; - } - extractedVariables.push(currentInput); + extractedVariables.push(finalizeInput(currentInput)); } const inputName = trimmedLine.split(':')[0]; currentInput = { @@ -181,32 +296,30 @@ export class GitLabSpecParser { }; logger.debug(`${logPrefix} Found input parameter: ${inputName}`, 'SpecParser'); } - // Property of current input (more deeply indented) - handle 4+ spaces of indentation - else if (currentInput && line.match(/^\s{4,}/)) { + // Property of the current input: anything indented deeper than the input names themselves. + else if (currentInput && inputIndent !== null && indent > inputIndent) { if (trimmedLine.startsWith('description:')) { currentInput.description = trimmedLine.substring(12).replace(/^["']|["']$/g, '').trim(); } else if (trimmedLine.startsWith('default:')) { - currentInput.default = trimmedLine.substring(8).replace(/^["']|["']$/g, '').trim(); + // Kept as text until the input is complete: `type:` may follow `default:`, and the declared type decides + // whether the value is parsed as YAML or kept literal. Resolved by `finalizeInput`. + rawDefault = trimmedLine.substring(8); } else if (trimmedLine.startsWith('type:')) { currentInput.type = trimmedLine.substring(5).replace(/^["']|["']$/g, '').trim(); } else if (trimmedLine.startsWith('options:')) { // Open the options list. An inline form (`options: [a, b]`) carries its values on the same line; // the expanded form leaves them for the `- item` lines below. - currentInput.options = parseInlineOptions(trimmedLine.substring(8).trim()); - } else if (trimmedLine.startsWith('- ') && currentInput.options) { + rawOptions = parseInlineOptions(trimmedLine.substring(8).trim()); + } else if (trimmedLine.startsWith('- ') && rawOptions) { // Expanded list item belonging to the open `options:` block. - currentInput.options.push(trimmedLine.substring(2).replace(/^["']|["']$/g, '').trim()); + rawOptions.push(trimmedLine.substring(2).trim()); } } } // Add the last input if (currentInput) { - // Mark as required if no default was specified (GitLab CI/CD component behavior) - if (currentInput.default === undefined) { - currentInput.required = true; - } - extractedVariables.push(currentInput); + extractedVariables.push(finalizeInput(currentInput)); } logger.debug(`${logPrefix} Extracted ${extractedVariables.length} input parameters from spec`, 'SpecParser'); @@ -274,7 +387,8 @@ export class GitLabSpecParser { description: `Parameter: ${varName}`, required: false, type: 'string', - default: defaultValue || undefined + // The legacy format declares no per-variable type, so values stay the text they appear as. + default: defaultValue ? parseDefaultValue(defaultValue, 'string') : undefined }; }); diff --git a/src/providers/completionInputContext.ts b/src/providers/completionInputContext.ts index f1e089de..9ff7b962 100644 --- a/src/providers/completionInputContext.ts +++ b/src/providers/completionInputContext.ts @@ -7,7 +7,7 @@ import { parseYaml, parseYamlDocuments, findDocumentWith, isYamlNode, type YamlNode } from '../utils/yamlParser'; import { findIncludeLine } from '../utils/includeMatcher'; -import type { ComponentParameter } from '../types/git-component'; +import type { ComponentParameter, ParameterDefault } from '../types/git-component'; /** * The completion slot a YAML cursor position resolves to. @@ -324,6 +324,22 @@ export function renderOptionValue(value: string | number | boolean): string { return typeof value === 'string' ? quoteYamlIfUnsafe(value) : String(value); } +/** + * Render an input's `default` as the YAML text to insert for it. + * + * Arrays become flow sequences (`[a, b]`); strings are bare or double-quoted by round-trip safety; everything else + * (booleans, numbers, `null`) is its bare scalar form, so a `false` default inserts as `false` and not `"false"`. + * + * @param value - The declared default for the input. + * @returns The YAML text for that value. + */ +export function renderDefaultValue(value: ParameterDefault): string { + if (Array.isArray(value)) { + return `[${value.map((v) => (typeof v === 'string' ? quoteYamlIfUnsafe(v, true) : String(v))).join(', ')}]`; + } + return typeof value === 'string' ? quoteYamlIfUnsafe(value) : String(value); +} + /** * Narrow a parameter default to the scalar shapes an `options:` entry can take (string/number/boolean), excluding * the `null` and array forms a default may also hold. @@ -335,6 +351,28 @@ function isOptionScalar(value: unknown): value is string | number | boolean { return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'; } +/** + * The values an input is allowed to take, if that set is known and finite. + * + * An explicit `options:` list is used as declared. A `boolean` input has no `options:` in the spec but is still a + * closed two-value enum, so it yields `true, false` — conventional reading order, which is what a reader scanning + * the dropdown expects. A declared default is floated to the front by the caller, so it still pre-selects. + * + * Shared by both completion slots so the input-name snippet and the value-slot dropdown always agree. + * + * @param param - The input being completed. + * @returns The allowed values, or `undefined` when the input accepts free-form text. + */ +export function allowedValuesFor(param: ComponentParameter): Array | undefined { + if (param.options && param.options.length > 0) { + return param.options; + } + if (param.type === 'boolean') { + return [true, false]; + } + return undefined; +} + /** * Build the value portion of the snippet inserted after `param.name: ` when an input is accepted from completion. * @@ -343,20 +381,24 @@ function isOptionScalar(value: unknown): value is string | number | boolean { * Values are inserted bare where a bare YAML scalar round-trips to the same string — GitLab CI parses a bare scalar * by the input's declared type, so a string input is a bare scalar, not a quoted one. Strings that bare YAML would * reinterpret (indicators, embedded `: `/` #`, type-like tokens, etc.) are double-quoted; see {@link quoteYamlIfUnsafe}. - * Precedence: an `options:` enum becomes a `${1|...|}` choice (with the default, if any, pre-selected first) so the - * allowed values stay one keystroke away; otherwise an explicit `default` is rendered as the YAML it represents; - * otherwise a type-appropriate placeholder. + * Precedence: an `options:` enum — or a `boolean` input, which is a closed two-value enum — becomes a `${1|...|}` + * choice (with the default, if any, pre-selected first) so the allowed values stay one keystroke away; otherwise an + * explicit `default` is rendered as the YAML it represents; otherwise a type-appropriate placeholder. * * @param param - The input parameter spec (type, optional default, optional `options` enum, requiredness). * @returns The snippet body to insert after `param.name: ` — a `${1|...|}` choice, a rendered value, or a `${1:...}` placeholder. */ export function buildInputInsertValue(param: ComponentParameter): string { - if (param.options && param.options.length > 0) { - // Offer the allowed values (`options:`) as a choice. Entries stay unquoted so a number/boolean option isn't + // Includes a `boolean` input's implicit true/false. Falling through to the default-rendering branch below would + // pre-fill the default with no alternatives shown. + const options = allowedValuesFor(param); + + if (options && options.length > 0) { + // Offer the allowed values as a choice. Entries stay unquoted so a number/boolean option isn't // turned into a string; string entries are quoted only when bare YAML would reinterpret them. When the input // also has a default, float the matching option to the front — VS Code pre-selects the first choice entry, so // accepting the input keeps the default while leaving the alternatives one arrow-key away. - const rendered = param.options.map(renderOptionValue); + const rendered = options.map(renderOptionValue); // Only a scalar default can name one of the options; a null or array default has no matching entry. const defaultRendered = isOptionScalar(param.default) ? renderOptionValue(param.default) : undefined; const ordered = @@ -367,17 +409,11 @@ export function buildInputInsertValue(param: ComponentParameter): string { } if (param.default !== undefined) { - // Render the default as the YAML the input expects: arrays as flow sequences (`[a, b]`), - // strings bare-or-quoted by round-trip safety, everything else as its bare scalar form. - if (Array.isArray(param.default)) { - return `[${param.default.map((v) => (typeof v === 'string' ? quoteYamlIfUnsafe(v, true) : String(v))).join(', ')}]`; - } - return typeof param.default === 'string' ? quoteYamlIfUnsafe(param.default) : String(param.default); + return renderDefaultValue(param.default); } switch (param.type) { - case 'boolean': - return param.required ? '${1|true,false|}' : '${1|false,true|}'; + // `boolean` is handled above as a two-value choice, with or without a default. case 'number': return '${1:0}'; case 'integer': diff --git a/src/providers/completionProvider.ts b/src/providers/completionProvider.ts index 084d3f1a..6fe7953c 100755 --- a/src/providers/completionProvider.ts +++ b/src/providers/completionProvider.ts @@ -5,7 +5,7 @@ import { getVariableCompletions, containsGitLabVariables, expandComponentUrl } f import { Logger } from '../utils/logger'; import { isGitLabCIFile } from '../utils/gitlabCiFileMatcher'; import { resolveLocalComponent } from './localComponentResolver'; -import { findCompletionInputContextAtLine, buildInputInsertValue, renderOptionValue } from './completionInputContext'; +import { findCompletionInputContextAtLine, buildInputInsertValue, renderOptionValue, allowedValuesFor } from './completionInputContext'; import type { ComponentParameter } from '../types/git-component'; import type { CachedComponent } from '../types/cache'; @@ -389,16 +389,21 @@ export class CompletionProvider implements vscode.CompletionItemProvider { // it also declares a default — the default is just the pre-filled choice, not a reason to hide the others. if (context.slot === 'value') { const param = component.parameters.find((p: ComponentParameter) => p.name === context.inputName); - if (!param?.options?.length) { + // A `boolean` input is a closed two-value enum, so offer `true`/`false` even though it declares no `options:`. + const values = param ? allowedValuesFor(param) : undefined; + if (!values?.length) { this.logger.debug(`[CompletionProvider] Value slot for ${context.inputName} has no options to offer`, 'CompletionProvider'); return null; } - return param.options.map((value, index) => { + return values.map((value, index) => { const rendered = renderOptionValue(value); const item = new vscode.CompletionItem(String(value), vscode.CompletionItemKind.EnumMember); item.insertText = rendered; - item.detail = `${param.type || 'string'} option`; - // Preserve the declared order of `options:` in the dropdown. + // Flag the declared default rather than reordering around it — the list stays in a stable, scannable + // order (`true` before `false`, or the spec's own `options:` order) whatever the default happens to be. + const isDefault = param?.default !== undefined && param.default === value; + item.detail = `${param?.type || 'string'} option${isDefault ? ' (default)' : ''}`; + // Preserve the declared order of the allowed values in the dropdown. item.sortText = String(index).padStart(4, '0'); return item; }); diff --git a/src/providers/localComponentResolver.ts b/src/providers/localComponentResolver.ts index 7f916b60..d40468c9 100644 --- a/src/providers/localComponentResolver.ts +++ b/src/providers/localComponentResolver.ts @@ -4,7 +4,7 @@ import * as yaml from 'js-yaml'; import { Component, ComponentParameter } from './componentDetector'; import { Logger } from '../utils/logger'; import { isYamlNode, GITLAB_CI_SCHEMA } from '../utils/yamlParser'; -import type { ParameterDefault } from '../types/git-component'; +import { isParameterDefault } from '../parsers/parameterDefaultShape'; // Pure parser helpers live in their own module so the unit suite can exercise them under plain Node. Re-exported // here so existing callers (e.g. validationProvider) keep their import path. @@ -142,20 +142,6 @@ function isOptionsList(value: unknown): value is Array { - const vt = typeof v; - return vt === 'string' || vt === 'number' || vt === 'boolean'; - }); - } - return false; -} - /** * Read the text content of a local include target, preferring the open editor buffer over the on-disk file. * diff --git a/src/providers/validationProvider.ts b/src/providers/validationProvider.ts index f1783dcd..90b34113 100644 --- a/src/providers/validationProvider.ts +++ b/src/providers/validationProvider.ts @@ -9,6 +9,7 @@ import { isGitLabCIFile } from '../utils/gitlabCiFileMatcher'; import { spawn } from 'child_process'; import { resolveLocalIncludeOutcome, isUnsupportedLocalPath } from './localComponentResolver'; import { attachDiagnosticMetadata, readDiagnosticMetadata } from './validationMetadata'; +import { renderDefaultValue } from './completionInputContext'; import { isAuthError } from '../errors'; import type { MissingRequiredInputMetadata } from './validationMetadata'; import type { GitApi, GitRepository } from '../types/vscode-git'; @@ -1319,7 +1320,7 @@ export class ValidationProvider implements vscode.CodeActionProvider { quickPick.items = (args.missingInputs || []).map(input => ({ label: input.name, description: input.required ? 'Required' : 'Optional', - detail: `${input.description} (${input.type}${input.default !== undefined ? `, default: ${JSON.stringify(input.default)}` : ''})` + detail: `${input.description} (${input.type}${input.default !== undefined ? `, default: ${renderDefaultValue(input.default)}` : ''})` })); } @@ -1361,9 +1362,10 @@ export class ValidationProvider implements vscode.CodeActionProvider { insertText += `${indentation}${inputName}: `; - // Add appropriate default value + // Add appropriate default value, rendered as the YAML the input's type expects — `JSON.stringify` + // would quote every scalar, inserting `"false"`/`"production"` where a bare scalar belongs. if (inputInfo?.default !== undefined) { - insertText += `${JSON.stringify(inputInfo.default)}`; + insertText += renderDefaultValue(inputInfo.default); } else { switch (inputInfo?.type?.toLowerCase()) { case 'boolean': diff --git a/src/services/cache/componentCacheManager.ts b/src/services/cache/componentCacheManager.ts index 411361ed..93798fdb 100644 --- a/src/services/cache/componentCacheManager.ts +++ b/src/services/cache/componentCacheManager.ts @@ -18,7 +18,7 @@ import { // Bump when the on-disk CachedComponent shape changes. Mismatched caches are discarded on load so the new fields can be // populated by re-fetching. -const CURRENT_CACHE_VERSION = '1.3.0'; +const CURRENT_CACHE_VERSION = '1.4.0'; /** * ComponentCacheManager - Main orchestrator for component caching diff --git a/src/services/component/componentFetcher.ts b/src/services/component/componentFetcher.ts index 2bb36aa9..df67e4ba 100644 --- a/src/services/component/componentFetcher.ts +++ b/src/services/component/componentFetcher.ts @@ -15,7 +15,7 @@ import { isAuthError } from '../../errors'; import { TokenManager } from './tokenManager'; import { UrlParser } from './urlParser'; import { - backfillParameterOptions, + backfillParameterSpecDetail, buildCatalogComponents, fetchAllTemplateFiles, } from './componentFetcherTemplates'; @@ -195,9 +195,10 @@ export class ComponentFetcher { if (extractedParameters.length === 0 && templateResult?.parameters?.length) { extractedParameters = templateResult.parameters; } else if (templateResult?.parameters?.length) { - // The catalog API doesn't return per-input `options`, so backfill them from the parsed template - // (matched by name) onto the catalog-derived parameters we're keeping. - extractedParameters = backfillParameterOptions(extractedParameters, templateResult.parameters); + // The catalog API describes inputs more loosely than the spec does — no `options`, and often no + // `type` — so backfill that detail from the parsed template (matched by name) onto the + // catalog-derived parameters we're keeping. + extractedParameters = backfillParameterSpecDetail(extractedParameters, templateResult.parameters); } // If the catalog omits a description, fall back to the component's README. diff --git a/src/services/component/componentFetcherTemplates.ts b/src/services/component/componentFetcherTemplates.ts index aea339bb..4c7d9658 100644 --- a/src/services/component/componentFetcherTemplates.ts +++ b/src/services/component/componentFetcherTemplates.ts @@ -198,23 +198,39 @@ export async function buildCatalogComponents( } /** - * Backfill `options` from template-parsed parameters onto catalog-derived parameters, matched by name. + * Take an input's type signature (`options`, `type`, `default`) from the locally-parsed template, which the Catalog + * API reports less completely: it omits `options` entirely, often omits `type` (collapsing to the `'string'` + * fallback), and can return a default stringified — so a `default: false` input arrives as a `'string'` holding + * `"false"`, and completion offers no true/false choice. * - * The GitLab catalog API doesn't return per-input `options`, but the parsed template spec does. When we keep the - * catalog's parameters (e.g. for their descriptions) we still want their `options` so completion can offer the - * allowed values — so we graft `options` from the template parse onto the catalog parameter of the same name. + * Both sides read the same `spec.inputs`, so a disagreement is API loss rather than a real difference. `description` + * and `required` stay with the catalog, which resolves them where our parser only infers them. * - * Only template parameters that actually declare `options` contribute; catalog parameters without a matching - * template entry are returned unchanged. The input arrays are not mutated. + * `type` is the one field the template does not always win: both sides fall back to `'string'`, so a template + * `'string'` may mean "the spec said string" or "the line-based parse missed the `type:` line". Overwriting with it + * would downgrade a catalog-typed `number` on a spec layout the parser doesn't match, so the template's `type` is + * taken only when it is more specific than that shared fallback. + * + * @param catalogParams - Parameters built from the Catalog API response; the base each result is merged onto. + * @param templateParams - Parameters from the locally-parsed template, matched to the above by `name`. + * @returns A new array in `catalogParams` order, each entry carrying the template's type signature where it has + * one. Catalog parameters with no matching template entry are passed through unchanged; neither input is mutated. */ -export function backfillParameterOptions( +export function backfillParameterSpecDetail( catalogParams: readonly ComponentParameter[], templateParams: readonly ComponentParameter[] ): ComponentParameter[] { - const optionsByName = new Map( - templateParams.filter((p) => p.options?.length).map((p) => [p.name, p.options]) - ); - return catalogParams.map((p) => - optionsByName.has(p.name) ? { ...p, options: optionsByName.get(p.name) } : p - ); + const templateByName = new Map(templateParams.map((p) => [p.name, p])); + return catalogParams.map((p) => { + const fromTemplate = templateByName.get(p.name); + if (!fromTemplate) return p; + + const typeIsMoreSpecific = Boolean(fromTemplate.type) && fromTemplate.type !== 'string'; + return { + ...p, + ...(fromTemplate.options?.length ? { options: fromTemplate.options } : {}), + ...(typeIsMoreSpecific ? { type: fromTemplate.type } : {}), + ...(fromTemplate.default !== undefined ? { default: fromTemplate.default } : {}), + }; + }); } diff --git a/tests/unit/GitLabSpecParser.test.ts b/tests/unit/GitLabSpecParser.test.ts index 5ec18bdc..fea2b4cd 100644 --- a/tests/unit/GitLabSpecParser.test.ts +++ b/tests/unit/GitLabSpecParser.test.ts @@ -51,7 +51,8 @@ deploy-job: const debug = parsed.variables.find((v) => v.name === 'debug'); assert.ok(debug, 'debug input missing'); assert.strictEqual(debug.type, 'boolean'); - assert.strictEqual(debug.default, 'false'); + // The boolean `false`, not the string "false" — a stringified default renders back as a quoted `"false"`. + assert.strictEqual(debug.default, false); const names = parsed.variables.map((v) => v.name); for (const unwanted of ['ENV_VAR', 'ANOTHER_VAR', 'script', 'after_script']) { @@ -107,6 +108,8 @@ $[[ inputs.job-name ]]: assert.strictEqual(byName['architecture'].description, 'Target CPU architecture'); assert.strictEqual(byName['architecture'].default, 'amd64'); assert.strictEqual(byName['skip-find-images'].description, 'Skip image discovery'); + // No `type:` on this input, so GitLab resolves it as a string — the default stays the text it appears as. + assert.strictEqual(byName['skip-find-images'].default, 'false'); // A hyphenated input with no default is marked required, same as a non-hyphenated one. assert.strictEqual(byName['package-name'].default, undefined); @@ -234,6 +237,233 @@ suite('GitLabSpecParser.parse — options (enum) extraction', () => { }); }); +suite('GitLabSpecParser.parse — default value types', () => { + // The parser reads the spec line-by-line, so a `default:` arrives as raw text. Keeping it as text turned + // `default: false` into the string "false", which completion then inserted as a quoted `"false"` — a string + // where GitLab expects a boolean. + test('parses scalar defaults as their YAML types, not as strings', () => { + const template = `spec: + inputs: + flag_off: + type: boolean + default: false + flag_on: + type: boolean + default: true + port: + type: number + default: 8080 + ratio: + type: number + default: 0.5 + name: + type: string + default: production + empty: + type: string + default:`; + + const parsed = GitLabSpecParser.parse(template); + const byName = Object.fromEntries(parsed.variables.map((v) => [v.name, v])); + + assert.strictEqual(byName['flag_off'].default, false); + assert.strictEqual(byName['flag_on'].default, true); + assert.strictEqual(byName['port'].default, 8080); + assert.strictEqual(byName['ratio'].default, 0.5); + assert.strictEqual(byName['name'].default, 'production'); + // An explicit but empty `default:` is an empty string, so the input is not required. + assert.strictEqual(byName['empty'].default, ''); + assert.strictEqual(byName['empty'].required, false); + }); + + test('keeps a quoted "false" a string, so it round-trips as a quoted scalar', () => { + const template = `spec: + inputs: + literal: + type: string + default: "false" + numeric_string: + type: string + default: "8080"`; + + const parsed = GitLabSpecParser.parse(template); + const byName = Object.fromEntries(parsed.variables.map((v) => [v.name, v])); + + assert.strictEqual(byName['literal'].default, 'false'); + assert.strictEqual(byName['numeric_string'].default, '8080'); + }); + + test('treats an omitted type as string, leaving the default as written', () => { + // GitLab resolves an untyped input as StringInput and coerces the default with `to_s`, so an untyped default + // is text whatever it looks like. Sending it through YAML instead would renumber it: `0755` loses its leading + // zero, `1.0` becomes `1`, `1e5` becomes `100000` — silent value corruption. + const template = `spec: + inputs: + file_mode: + default: 0755 + version: + default: 1.0 + scientific: + default: 1e5 + padded: + default: 007 + flag: + description: no type declared + default: false + untyped_nothing: + description: no type and no default`; + + const parsed = GitLabSpecParser.parse(template); + const byName = Object.fromEntries(parsed.variables.map((v) => [v.name, v])); + + assert.strictEqual(byName['file_mode'].default, '0755'); + assert.strictEqual(byName['version'].default, '1.0'); + assert.strictEqual(byName['scientific'].default, '1e5'); + assert.strictEqual(byName['padded'].default, '007'); + assert.strictEqual(byName['flag'].default, 'false'); + for (const name of ['file_mode', 'version', 'scientific', 'padded', 'flag', 'untyped_nothing']) { + assert.strictEqual(byName[name].type, 'string', `${name} should resolve as a string input`); + } + }); + + test('parses inputs whose names collide with an input field key', () => { + // `type`, `default` and `options` are ordinary input names as far as GitLab is concerned. Telling them apart + // from an input's own keys by name would drop the input entirely; sibling indentation settles it instead. + const template = `spec: + inputs: + type: + description: deployment type + default: blue-green + default: + description: the default profile + default: standard + options: + default: "--verbose" + region: + default: us-east-1`; + + const parsed = GitLabSpecParser.parse(template); + const byName = Object.fromEntries(parsed.variables.map((v) => [v.name, v])); + + assert.deepStrictEqual(parsed.variables.map((v) => v.name), ['type', 'default', 'options', 'region']); + assert.strictEqual(byName['type'].default, 'blue-green'); + assert.strictEqual(byName['type'].description, 'deployment type'); + assert.strictEqual(byName['default'].default, 'standard'); + assert.strictEqual(byName['options'].default, '--verbose'); + assert.strictEqual(byName['region'].default, 'us-east-1'); + }); + + test('a valueless `default:` deeper than the input names is not read as an input', () => { + // The mirror of the shallow case below: with `inputs:` indented, an input's keys sit at six spaces and never + // match the name pattern. Both layouts must land on the same result. + const template = `spec: + inputs: + flag: + type: boolean + default:`; + + const parsed = GitLabSpecParser.parse(template); + + assert.deepStrictEqual(parsed.variables.map((v) => v.name), ['flag']); + assert.strictEqual(parsed.variables[0].default, ''); + assert.strictEqual(parsed.variables[0].type, 'boolean'); + }); + + test('a valueless `default:` at input-name indentation is not read as an input', () => { + // On a spec indented `inputs:` at 0, an input's keys sit at 4 and match the input-name pattern — a bare + // `default:` would otherwise open a phantom input named `default` and swallow the real input's value. + const template = `spec: +inputs: + flag: + type: boolean + default:`; + + const parsed = GitLabSpecParser.parse(template); + + assert.deepStrictEqual(parsed.variables.map((v) => v.name), ['flag']); + assert.strictEqual(parsed.variables[0].default, ''); + assert.strictEqual(parsed.variables[0].required, false); + }); + + test('an explicit `type: string` keeps a numeric-looking default as text', () => { + // The declared type wins over what YAML would make of the text: `1.0` must not become the number 1, and + // `0755` must keep its leading zero. + const template = `spec: + inputs: + ratio: + type: string + default: 1.0 + mode: + type: string + default: 0755`; + + const parsed = GitLabSpecParser.parse(template); + const byName = Object.fromEntries(parsed.variables.map((v) => [v.name, v])); + + assert.strictEqual(byName['ratio'].default, '1.0'); + assert.strictEqual(byName['mode'].default, '0755'); + }); + + test('types options entries like defaults, so a default matches the option naming it', () => { + // Options parsed as strings while defaults are typed would leave `false` unable to match `'false'` — the + // default would not pre-select, and the choice would insert quoted values into a boolean/number input. + const template = `spec: + inputs: + mode: + type: boolean + default: false + options: [true, false] + size: + type: number + default: 2 + options: [1, 2, 3]`; + + const parsed = GitLabSpecParser.parse(template); + const byName = Object.fromEntries(parsed.variables.map((v) => [v.name, v])); + + assert.deepStrictEqual(byName['mode'].options, [true, false]); + assert.strictEqual(byName['mode'].default, false); + assert.deepStrictEqual(byName['size'].options, [1, 2, 3]); + assert.strictEqual(byName['size'].default, 2); + }); + + test('types options against a `type:` declared after the options block', () => { + const template = `spec: + inputs: + size: + options: [1, 2] + type: number`; + + const parsed = GitLabSpecParser.parse(template); + + assert.deepStrictEqual(parsed.variables[0].options, [1, 2]); + }); + + test('an explicit `type: string` keeps numeric-looking options as text', () => { + const template = `spec: + inputs: + version: + type: string + options: ["1.0", "2.0"]`; + + const parsed = GitLabSpecParser.parse(template); + + assert.deepStrictEqual(parsed.variables[0].options, ['1.0', '2.0']); + }); + + test('an input with a false default is optional, not required', () => { + const template = `spec: + inputs: + debug: + type: boolean + default: false`; + + const parsed = GitLabSpecParser.parse(template); + + assert.strictEqual(parsed.variables[0].required, false); + }); +}); + suite('GitLabSpecParser.parse — description extraction', () => { test('extracts description from a leading `#` comment', () => { const template = `# Deploys a service to the target environment diff --git a/tests/unit/completionInputContext.test.ts b/tests/unit/completionInputContext.test.ts index d1ca979a..313d1b5a 100644 --- a/tests/unit/completionInputContext.test.ts +++ b/tests/unit/completionInputContext.test.ts @@ -13,6 +13,7 @@ import * as assert from 'node:assert/strict'; import { findCompletionInputContextAtLine, buildInputInsertValue, + allowedValuesFor, } from '../../src/providers/completionInputContext'; import type { ComponentParameter } from '../../src/types/git-component'; @@ -410,7 +411,6 @@ suite('buildInputInsertValue', () => { test('renders a string default bare and stringifies a non-string default', () => { assert.strictEqual(buildInputInsertValue({ ...base, default: 'dev' }), 'dev'); assert.strictEqual(buildInputInsertValue({ ...base, type: 'number', default: 42 }), '42'); - assert.strictEqual(buildInputInsertValue({ ...base, type: 'boolean', default: true }), 'true'); }); test('quotes a string default only when a bare scalar would not round-trip', () => { @@ -437,9 +437,44 @@ suite('buildInputInsertValue', () => { assert.strictEqual(buildInputInsertValue({ ...base, type: 'array', default: ['a,b', 'c'] }), '["a,b", c]'); }); - test('offers both boolean values, leading with the safer one by requiredness', () => { + test('offers both boolean values in conventional order when there is no default', () => { + // `true, false` reads the way someone scanning the dropdown expects, regardless of requiredness. assert.strictEqual(buildInputInsertValue({ ...base, type: 'boolean', required: true }), '${1|true,false|}'); - assert.strictEqual(buildInputInsertValue({ ...base, type: 'boolean', required: false }), '${1|false,true|}'); + assert.strictEqual(buildInputInsertValue({ ...base, type: 'boolean', required: false }), '${1|true,false|}'); + }); + + test('offers both boolean values when the input has a default, pre-selecting the default', () => { + // A boolean is a closed two-value enum, so a default pre-fills the choice rather than replacing it. + assert.strictEqual(buildInputInsertValue({ ...base, type: 'boolean', default: false }), '${1|false,true|}'); + assert.strictEqual(buildInputInsertValue({ ...base, type: 'boolean', default: true }), '${1|true,false|}'); + // A default always leads, overriding the conventional true-first order. + assert.strictEqual( + buildInputInsertValue({ ...base, type: 'boolean', required: true, default: false }), + '${1|false,true|}' + ); + }); + + test('allowedValuesFor covers the value slot, where a boolean has no literal options list', () => { + // The value slot (cursor after `test:`) offers `allowedValuesFor`, so a boolean must yield true/false there + // even though the spec declares no `options:` — otherwise typing after `test:` suggests nothing. + assert.deepStrictEqual(allowedValuesFor({ ...base, type: 'boolean' }), [true, false]); + assert.deepStrictEqual(allowedValuesFor({ ...base, type: 'boolean', required: true }), [true, false]); + // An explicit options list is used as declared, and a free-text input offers nothing. + assert.deepStrictEqual(allowedValuesFor({ ...base, options: ['aws', 'gcp'] }), ['aws', 'gcp']); + assert.strictEqual(allowedValuesFor({ ...base, type: 'string' }), undefined); + }); + + test('offers the choice for a boolean input whose type was inferred from an untyped default', () => { + // A spec that declares `default: false` with no `type:` is a boolean input; the parser infers the type, so + // the snippet builder sees `type: 'boolean'` and offers the choice like any other boolean. + assert.strictEqual(buildInputInsertValue({ ...base, type: 'boolean', default: false }), '${1|false,true|}'); + }); + + test('inserts a boolean default unquoted, so GitLab reads it as a boolean', () => { + // Regression: a stringified `"false"` default (from the line-based spec parser) inserted `p: "false"` — a + // string where a boolean was declared. The choice list must carry bare `false`, never `"false"`. + const built = buildInputInsertValue({ ...base, type: 'boolean', default: false }); + assert.ok(!built.includes('"'), `boolean choice must not be quoted, got ${built}`); }); test('seeds empty literals for array and object inputs', () => { diff --git a/tests/unit/componentFetcherTemplates.test.ts b/tests/unit/componentFetcherTemplates.test.ts index 9768040c..dc7a0e86 100644 --- a/tests/unit/componentFetcherTemplates.test.ts +++ b/tests/unit/componentFetcherTemplates.test.ts @@ -12,7 +12,7 @@ import * as assert from 'node:assert/strict'; import type { GitLabTreeItem } from '../../src/types/api'; import type { ComponentParameter } from '../../src/types/git-component'; import { - backfillParameterOptions, + backfillParameterSpecDetail, deriveComponentName, filterSubdirectories, filterYamlBlobs, @@ -129,7 +129,7 @@ suite('deriveComponentName — deeper nesting and edge cases', () => { }); }); -suite('backfillParameterOptions', () => { +suite('backfillParameterSpecDetail', () => { const param = (name: string, extra: Partial = {}): ComponentParameter => ({ name, description: '', @@ -142,7 +142,7 @@ suite('backfillParameterOptions', () => { const catalog = [param('registry_type'), param('region')]; const template = [param('registry_type', { options: ['aws', 'gcp'] }), param('region')]; - const merged = backfillParameterOptions(catalog, template); + const merged = backfillParameterSpecDetail(catalog, template); assert.deepStrictEqual(merged.find((p) => p.name === 'registry_type')?.options, ['aws', 'gcp']); assert.strictEqual(merged.find((p) => p.name === 'region')?.options, undefined); @@ -152,7 +152,7 @@ suite('backfillParameterOptions', () => { const catalog = [param('region')]; const template = [param('registry_type', { options: ['aws'] })]; // different name - const merged = backfillParameterOptions(catalog, template); + const merged = backfillParameterSpecDetail(catalog, template); assert.strictEqual(merged[0].options, undefined); }); @@ -161,7 +161,7 @@ suite('backfillParameterOptions', () => { const catalog = [param('a'), param('b')]; const template = [param('a', { options: [] }), param('b')]; // empty + missing - const merged = backfillParameterOptions(catalog, template); + const merged = backfillParameterSpecDetail(catalog, template); assert.strictEqual(merged.find((p) => p.name === 'a')?.options, undefined); assert.strictEqual(merged.find((p) => p.name === 'b')?.options, undefined); @@ -172,8 +172,62 @@ suite('backfillParameterOptions', () => { const catalog = [catalogParam]; const template = [param('registry_type', { options: ['aws'] })]; - backfillParameterOptions(catalog, template); + backfillParameterSpecDetail(catalog, template); assert.strictEqual(catalogParam.options, undefined, 'original catalog param was mutated'); }); + + test('recovers a boolean type the catalog reported as an untyped string', () => { + // The catalog can describe a boolean input without a type, leaving the 'string' fallback in place. Completion + // keys off `type`, so without this the input gets no true/false choice. + const catalog = [param('debug', { default: 'false' })]; + const template = [param('debug', { type: 'boolean', default: false })]; + + const merged = backfillParameterSpecDetail(catalog, template); + + assert.strictEqual(merged[0].type, 'boolean'); + assert.strictEqual(merged[0].default, false, 'a stringified catalog default should yield to the parsed one'); + }); + + test('the local parse wins on the type signature, since both sides describe the same spec', () => { + // The catalog reports what GitLab read from this same template, only lossily — so on `type`/`default`/`options` + // the local parse is preferred rather than arbitrated against. + const catalog = [param('env', { type: 'string', default: 'production' })]; + const template = [param('env', { type: 'string', default: 'staging' })]; + + const merged = backfillParameterSpecDetail(catalog, template); + + assert.strictEqual(merged[0].default, 'staging'); + }); + + test('does not downgrade a catalog type to the template parse fallback', () => { + // Both sides fall back to 'string', so a template 'string' may just mean the line-based parse missed the + // `type:` line. Overwriting with it would lose a type the catalog got right. + const catalog = [param('count', { type: 'number' })]; + const template = [param('count', { type: 'string' })]; + + const merged = backfillParameterSpecDetail(catalog, template); + + assert.strictEqual(merged[0].type, 'number'); + }); + + test('keeps a catalog default the template parse does not have', () => { + const catalog = [param('env', { default: 'production' })]; + const template = [param('env')]; // no default parsed + + const merged = backfillParameterSpecDetail(catalog, template); + + assert.strictEqual(merged[0].default, 'production'); + }); + + test('leaves the fields the catalog is authoritative for untouched', () => { + const catalog = [param('debug', { description: 'from catalog', required: true })]; + const template = [param('debug', { description: 'from template', required: false, type: 'boolean' })]; + + const merged = backfillParameterSpecDetail(catalog, template); + + assert.strictEqual(merged[0].description, 'from catalog'); + assert.strictEqual(merged[0].required, true); + assert.strictEqual(merged[0].type, 'boolean', 'the type signature still comes from the local parse'); + }); }); From 8f1a9911574939a49dd6e9ec6048cf28fa11e280 Mon Sep 17 00:00:00 2001 From: Simon Heather <32168619+X-Guardian@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:28:49 +0100 Subject: [PATCH 29/54] fix(details): share one message handler across both details-panel entry points (#300) * fix(details): share one message handler across both details-panel entry points * docs(details): correct what detachedFrom actually controls The option's doc said its presence makes an insert refocus the target editor, and that the Component Browser's panel does not. Both paths refocus: insertComponent calls showTextDocument on originalEditor, and editExistingComponentFromDetached opens and focuses the hover's own document. Neither is conditional on the option. What the option does control is adopting the editor as the insertion target at registration (the detached entry point builds its own provider, so nothing else populates it) and disposing the panel after an insert. --------- Co-authored-by: Simon Heather Co-authored-by: eFAILution --- src/extension.ts | 140 +---------------- src/providers/componentBrowserProvider.ts | 174 ++++++++++++++-------- 2 files changed, 112 insertions(+), 202 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index b4c54387..b940ffad 100755 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,12 +5,10 @@ import { CompletionProvider } from './providers/completionProvider'; import { ComponentDocumentLinkProvider } from './providers/documentLinkProvider'; import { ComponentBrowserProvider } from './providers/componentBrowserProvider'; import { detectIncludeComponent, Component } from './providers/componentDetector'; -import { buildVersionLabels } from './services/component/tagScoping'; import { getComponentCacheManager, ComponentCacheManager } from './services/cache/componentCacheManager'; import { Logger } from './utils/logger'; import { ValidationProvider } from './providers/validationProvider'; import type { CachedComponent } from './types/cache'; -import { isVersionLookupShape } from './services/component/versionLookupShape'; import type { GitLabYamlFragment } from './types/gitlab-catalog'; import type { HoverContext } from './providers/hoverContentBuilder'; @@ -415,143 +413,7 @@ export function activate(context: vscode.ExtensionContext) { }); }, PANEL_FOCUS_DELAY_MS); - // Handle messages from the detached webview - panel.webview.onDidReceiveMessage(async (message) => { - switch (message.command) { - case 'insertComponent': - try { - // Ensure the original editor is active and focused - await vscode.window.showTextDocument(originalEditor.document, originalEditor.viewColumn); - - // Wait a brief moment for the editor to fully activate - await new Promise(resolve => setTimeout(resolve, PANEL_FOCUS_DELAY_MS)); - - // Verify we have the correct active editor - const currentEditor = vscode.window.activeTextEditor; - if (!currentEditor || currentEditor.document.uri.toString() !== originalEditor.document.uri.toString()) { - vscode.window.showErrorMessage('Could not activate the original editor'); - return; - } - - // Handle different insertion options - const { version, includeInputs, selectedInputs } = message; - - // Update component version if specified - if (version && version !== activeComponent.version) { - if (!activeComponent.sourcePath || !activeComponent.gitlabInstance) { - vscode.window.showErrorMessage(`Cannot fetch version: component is missing source path or GitLab instance.`); - return; - } - const updatedComponent = await cacheManager.fetchSpecificVersion( - activeComponent.name, - activeComponent.sourcePath, - activeComponent.gitlabInstance, - version - ); - if (updatedComponent) { - if (activeComponent._hoverContext) { - await componentBrowser.editExistingComponentFromDetached( - updatedComponent, - activeComponent._hoverContext.documentUri, - activeComponent._hoverContext.position, - includeInputs || false, - selectedInputs || [] - ); - } else { - await componentBrowser.insertComponentFromDetached( - updatedComponent, - includeInputs || false, - selectedInputs || [] - ); - } - } else { - vscode.window.showErrorMessage(`Failed to fetch version ${version} of component ${activeComponent.name}`); - } - } else { - if (activeComponent._hoverContext) { - await componentBrowser.editExistingComponentFromDetached( - activeComponent, - activeComponent._hoverContext.documentUri, - activeComponent._hoverContext.position, - includeInputs || false, - selectedInputs || [] - ); - } else { - await componentBrowser.insertComponentFromDetached( - activeComponent, - includeInputs || false, - selectedInputs || [] - ); - } - } - - // Close the panel after successful insertion/edit - panel.dispose(); - } catch (error) { - logger.error(`[Extension] Error inserting component from detached view: ${error}`, 'Extension'); - vscode.window.showErrorMessage(`Error inserting component: ${error}`); - } - break; - case 'fetchVersions': - try { - if (!isVersionLookupShape(activeComponent)) { - throw new Error(`Cannot look up versions for ${activeComponent.name}: missing source path or GitLab instance.`); - } - // Bind the narrowed value: `activeComponent` is reassignable, so TS widens it back across the await. - const lookupTarget = activeComponent; - const versions = await cacheManager.fetchComponentVersions(lookupTarget); - // For a monorepo source, map each full tag to its stripped {version} so the dropdown shows short - // labels while keeping the full tag as the option value (the inserted ref). - const versionLabels = buildVersionLabels(versions, lookupTarget.name, lookupTarget.tagPattern); - panel.webview.postMessage({ - command: 'versionsLoaded', - versions: versions, - versionLabels, - currentVersion: lookupTarget.version - }); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - logger.error(`[Extension] Error fetching versions for detached details panel: ${reason}`, 'Extension'); - panel.webview.postMessage({ - command: 'versionsError', - error: reason - }); - } - break; - case 'versionChanged': - try { - const { selectedVersion } = message; - if (!activeComponent.sourcePath || !activeComponent.gitlabInstance) { - vscode.window.showErrorMessage(`Cannot change version: component is missing source path or GitLab instance.`); - return; - } - const updatedComponent = await cacheManager.fetchSpecificVersion( - activeComponent.name, - activeComponent.sourcePath, - activeComponent.gitlabInstance, - selectedVersion - ); - if (updatedComponent) { - activeComponent = updatedComponent; - panel.webview.postMessage({ - command: 'componentDetailsUpdated', - component: updatedComponent - }); - } else { - panel.webview.postMessage({ - command: 'versionChangeError', - error: `Failed to fetch details for version ${selectedVersion}` - }); - } - } catch (error) { - panel.webview.postMessage({ - command: 'versionChangeError', - error: error instanceof Error ? error.message : String(error) - }); - } - break; - } - }); + componentBrowser.registerDetailsPanelMessageHandler(panel, activeComponent, { detachedFrom: originalEditor }); }) ); diff --git a/src/providers/componentBrowserProvider.ts b/src/providers/componentBrowserProvider.ts index e24587bf..e767e1b2 100644 --- a/src/providers/componentBrowserProvider.ts +++ b/src/providers/componentBrowserProvider.ts @@ -26,6 +26,22 @@ import { assetUri, createNonce, cspMetaTag } from '../webview/webviewHtml'; */ type DetachableComponent = Component & { _hoverContext?: HoverContext }; +/** Per-entry-point behaviour for the shared details-panel message handler. */ +interface DetailsPanelOptions { + /** + * Set by the detached (hover) entry point to the editor the panel was opened from. Two effects, both of which + * exist because that entry point constructs its own provider rather than going through `show()`: + * + * 1. The editor is adopted as this provider's insertion target at registration, since nothing else has populated + * it and `insertComponent` refuses to insert without one. + * 2. The panel is disposed once an insert completes — it is a one-shot view, where the Component Browser's own + * details panel stays open. + * + * Refocusing the target editor is not one of them: `insertComponent` and `editExistingComponentFromDetached` each + * focus and verify their own document, on both entry points. + */ + detachedFrom?: vscode.TextEditor; +} /** * Pre-existing component shape parsed out of a `.gitlab-ci.yml` include line by @@ -469,11 +485,6 @@ export class ComponentBrowserProvider { vscode.window.showInformationMessage(message); } - // Public method to insert component from detached view (called from extension.ts) - public async insertComponentFromDetached(component: Component, includeInputs: boolean = false, selectedInputs?: string[]) { - return this.insertComponent(component, includeInputs, selectedInputs); - } - public async showComponentDetails(component: DetachableComponent) { // Create a new webview panel for component details const detailsPanel = vscode.window.createWebviewPanel( @@ -492,97 +503,125 @@ export class ComponentBrowserProvider { // Show component details detailsPanel.webview.html = this.getComponentDetailsHtml({ ...component, ...enriched }); - // Handle messages from the details panel - detailsPanel.webview.onDidReceiveMessage( - async message => { - if (message.command === 'insertComponent') { - // Handle different insertion options - const { version, includeInputs, selectedInputs } = message; + this.registerDetailsPanelMessageHandler(detailsPanel, { ...component, ...enriched }); + } - // Update component version if specified - if (version && version !== component.version) { - if (!component.sourcePath || !component.gitlabInstance) { - vscode.window.showErrorMessage('Cannot fetch version: component is missing source path or GitLab instance.'); - return; - } - const updatedComponent = await this.cacheManager.fetchSpecificVersion( - component.name, - component.sourcePath, - component.gitlabInstance, - version - ); - if (updatedComponent) { - if (component._hoverContext) { - await this.editExistingComponentFromDetached( - updatedComponent, - component._hoverContext.documentUri, - component._hoverContext.position, - includeInputs || false, - selectedInputs || [] - ); - } else { - await this.insertComponent(updatedComponent, includeInputs, selectedInputs); + /** + * Wires the details panel's message handling. Both entry points — the Component Browser's Details button and the + * hover's "Open in Detailed View" — render the same HTML and speak the same protocol, so they share this handler; + * `options.detachedFrom` carries the only behavioural difference between them. + * + * @param panel The details webview panel to attach to. + * @param component The component being shown. Held mutably: a `versionChanged` round trip replaces it so later + * `fetchVersions`/`insertComponent` messages act on the version the user is actually looking at. + * @param options Per-entry-point behaviour; see {@link DetailsPanelOptions}. + */ + public registerDetailsPanelMessageHandler( + panel: vscode.WebviewPanel, + component: DetachableComponent, + options: DetailsPanelOptions = {} + ): void { + let active = component; + + if (options.detachedFrom) { + this.adoptOriginalEditor(options.detachedFrom); + } + + panel.webview.onDidReceiveMessage(async message => { + switch (message.command) { + case 'insertComponent': { + const { version, includeInputs, selectedInputs } = message; + try { + let target = active; + if (version && version !== active.version) { + if (!active.sourcePath || !active.gitlabInstance) { + vscode.window.showErrorMessage('Cannot fetch version: component is missing source path or GitLab instance.'); + return; } - } else { - vscode.window.showErrorMessage(`Failed to fetch version ${version} of component ${component.name}`); + const updatedComponent = await this.cacheManager.fetchSpecificVersion( + active.name, + active.sourcePath, + active.gitlabInstance, + version + ); + if (!updatedComponent) { + vscode.window.showErrorMessage(`Failed to fetch version ${version} of component ${active.name}`); + return; + } + target = updatedComponent; } - } else { - if (component._hoverContext) { + + if (active._hoverContext) { await this.editExistingComponentFromDetached( - component, - component._hoverContext.documentUri, - component._hoverContext.position, + target, + active._hoverContext.documentUri, + active._hoverContext.position, includeInputs || false, selectedInputs || [] ); } else { - await this.insertComponent(component, includeInputs, selectedInputs); + await this.insertComponent(target, includeInputs || false, selectedInputs || []); } + + // The detached panel is a one-shot view opened from a hover, so it closes once it has done its job. + if (options.detachedFrom) { + panel.dispose(); + } + } catch (error) { + this.logger.error(`[ComponentBrowser] Error inserting component from details panel: ${error}`, 'ComponentBrowser'); + vscode.window.showErrorMessage(`Error inserting component: ${error}`); } - } else if (message.command === 'fetchVersions') { - // Fetch available versions for the component + break; + } + + case 'fetchVersions': { try { - if (!isVersionLookupShape(component)) { - throw new Error(`Cannot look up versions for ${component.name}: missing source path or GitLab instance.`); + if (!isVersionLookupShape(active)) { + throw new Error(`Cannot look up versions for ${active.name}: missing source path or GitLab instance.`); } - const versions = await this.cacheManager.fetchComponentVersions(component); + // Bind the narrowed value: `active` is reassignable, so TS widens it back across the await below. + const lookupTarget = active; + const versions = await this.cacheManager.fetchComponentVersions(lookupTarget); // Same monorepo labelling as the detached panel: the webview can't reach the template matcher, so the // full tag → stripped {version} map is built here. Without it a refresh reverts the dropdown to full tags. - const versionLabels = buildVersionLabels(versions, component.name, component.tagPattern); - detailsPanel.webview.postMessage({ + panel.webview.postMessage({ command: 'versionsLoaded', - versions: versions, - versionLabels, - currentVersion: component.version + versions, + versionLabels: buildVersionLabels(versions, lookupTarget.name, lookupTarget.tagPattern), + currentVersion: lookupTarget.version }); } catch (error) { const reason = error instanceof Error ? error.message : String(error); this.logger.error(`[ComponentBrowser] Error fetching versions: ${reason}`, 'ComponentBrowser'); - detailsPanel.webview.postMessage({ + panel.webview.postMessage({ command: 'versionsError', error: reason }); } - } else if (message.command === 'versionChanged') { - // Fetch details for the selected version and update the display + break; + } + + case 'versionChanged': { const { selectedVersion } = message; try { this.logger.debug(`[ComponentBrowser] Version changed to ${selectedVersion}, fetching details...`, 'ComponentBrowser'); - if (!component.sourcePath || !component.gitlabInstance) { + if (!active.sourcePath || !active.gitlabInstance) { vscode.window.showErrorMessage('Cannot change version: component is missing source path or GitLab instance.'); return; } const updatedComponent = await this.cacheManager.fetchSpecificVersion( - component.name, - component.sourcePath, - component.gitlabInstance, + active.name, + active.sourcePath, + active.gitlabInstance, selectedVersion ); if (updatedComponent) { + // Carry the hover context forward so a later insert still edits in place rather than inserting anew. + active = { ...updatedComponent, _hoverContext: active._hoverContext }; // Send the updated component details to the webview, with the template-file URL precomputed // server-side so the webview never has to do its own URL routing. - detailsPanel.webview.postMessage({ + panel.webview.postMessage({ command: 'componentDetailsUpdated', component: { ...updatedComponent, @@ -590,21 +629,30 @@ export class ComponentBrowserProvider { } }); } else { - detailsPanel.webview.postMessage({ + panel.webview.postMessage({ command: 'versionChangeError', error: `Failed to fetch details for version ${selectedVersion}` }); } } catch (error) { this.logger.error(`[ComponentBrowser] Error fetching version details: ${error}`, 'ComponentBrowser'); - detailsPanel.webview.postMessage({ + panel.webview.postMessage({ command: 'versionChangeError', error: error instanceof Error ? error.message : String(error) }); } + break; } } - ); + }); + } + + /** + * Adopts `editor` as the insertion target for a panel opened outside `show()`. The detached (hover) panel builds its + * own provider, so nothing has populated `originalEditor` and `insertComponent` would otherwise refuse to insert. + */ + private adoptOriginalEditor(editor: vscode.TextEditor): void { + this.originalEditor = editor; } private getLoadingHtml(webview: vscode.Webview): string { From 8b0012eb942960bc6348773aaf43615c8b1ea139 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 21 Sep 2026 12:31:43 +0000 Subject: [PATCH 30/54] chore(release): 0.17.11 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 33efbcb2..b6f5832d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.17.10", + "version": "0.17.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.17.10", + "version": "0.17.11", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.2.2", diff --git a/package.json b/package.json index 6c110cb6..22e5b928 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gitlab-component-helper", "displayName": "GitLab Component Helper", "description": "Provides intellisense for GitLab CI components", - "version": "0.17.10", + "version": "0.17.11", "icon": "images/icon.png", "engines": { "node": ">=22.0.0", From b236672378506c091928011ed4a2bd0baa9d27c2 Mon Sep 17 00:00:00 2001 From: Simon Heather <32168619+X-Guardian@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:57:12 +0100 Subject: [PATCH 31/54] chore(webview): declare asset resource roots and unit-test the CSP helpers (#305) Co-authored-by: Simon Heather --- esbuild.js | 2 +- eslint.config.js | 11 ++++ src/extension.ts | 3 +- src/providers/componentBrowserProvider.ts | 11 ++-- src/webview/csp.ts | 52 ++++++++++++++++++ src/webview/webviewHtml.ts | 66 +++++++++-------------- tests/unit/csp.test.ts | 66 +++++++++++++++++++++++ 7 files changed, 163 insertions(+), 48 deletions(-) create mode 100644 src/webview/csp.ts create mode 100644 tests/unit/csp.test.ts diff --git a/esbuild.js b/esbuild.js index 7303585b..5f164dd0 100644 --- a/esbuild.js +++ b/esbuild.js @@ -115,7 +115,7 @@ const webviewConfig = { entryPoints: webviewEntryPoints(), bundle: true, minify: production, - sourcemap: !production, + sourcemap: production ? false : 'inline', platform: 'browser', // Pin the syntax level: webview assets run in the Electron renderer, not Node, so they must not inherit // esbuild's `esnext` default. diff --git a/eslint.config.js b/eslint.config.js index 12cc5627..964ec2b5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -40,4 +40,15 @@ module.exports = [ ...js.configs.recommended.rules, }, }, + { + // Webview client scripts run in the Electron renderer, not the extension host + files: ['src/webview/client/**/*.ts'], + languageOptions: { + globals: { + ...globals.browser, + // Injected by VS Code into the webview; not a browser global. + acquireVsCodeApi: 'readonly', + }, + }, + }, ]; diff --git a/src/extension.ts b/src/extension.ts index b940ffad..7c011fe6 100755 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,6 +6,7 @@ import { ComponentDocumentLinkProvider } from './providers/documentLinkProvider' import { ComponentBrowserProvider } from './providers/componentBrowserProvider'; import { detectIncludeComponent, Component } from './providers/componentDetector'; import { getComponentCacheManager, ComponentCacheManager } from './services/cache/componentCacheManager'; +import { assetRoots } from './webview/webviewHtml'; import { Logger } from './utils/logger'; import { ValidationProvider } from './providers/validationProvider'; import type { CachedComponent } from './types/cache'; @@ -318,7 +319,7 @@ export function activate(context: vscode.ExtensionContext) { { enableScripts: true, retainContextWhenHidden: true, - localResourceRoots: [] + localResourceRoots: assetRoots(context.extensionUri) } ); diff --git a/src/providers/componentBrowserProvider.ts b/src/providers/componentBrowserProvider.ts index e767e1b2..4c2e7821 100644 --- a/src/providers/componentBrowserProvider.ts +++ b/src/providers/componentBrowserProvider.ts @@ -16,7 +16,7 @@ import { generateComponentText } from './componentBrowserGenerate'; import { findComponentLineRange, parseExistingComponentText } from './componentBrowserEdit'; import { transformCachedComponentsToGroups } from './componentBrowserTransform'; import { buildVersionLabels, compileTagTemplate, stripTagPrefix } from '../services/component/tagScoping'; -import { assetUri, createNonce, cspMetaTag } from '../webview/webviewHtml'; +import { assetRoots, assetUri, createNonce, cspMetaTag } from '../webview/webviewHtml'; /** * Component shape carried through the detach-hover webview's "Open in Detailed View" round trip. @@ -92,9 +92,7 @@ export class ComponentBrowserProvider { { enableScripts: true, retainContextWhenHidden: true, - localResourceRoots: [ - vscode.Uri.joinPath(this.context.extensionUri, 'out', 'webview') - ] + localResourceRoots: assetRoots(this.context.extensionUri) } ); @@ -492,7 +490,8 @@ export class ComponentBrowserProvider { `Component: ${component.name}`, vscode.ViewColumn.Beside, { - enableScripts: true + enableScripts: true, + localResourceRoots: assetRoots(this.context.extensionUri) } ); @@ -664,7 +663,7 @@ export class ComponentBrowserProvider { - ${cspMetaTag(webview, nonce)} + ${cspMetaTag(webview.cspSource, nonce)} GitLab CI/CD Components diff --git a/src/webview/csp.ts b/src/webview/csp.ts new file mode 100644 index 00000000..72e0dd6f --- /dev/null +++ b/src/webview/csp.ts @@ -0,0 +1,52 @@ +/** + * Content-Security-Policy plumbing for webview documents. + * + * `vscode`-free and pure so the unit suite can drive it directly: `cspMetaTag` takes the webview's `cspSource` as a + * string rather than the webview itself, leaving `src/webview/webviewHtml.ts` to hold the parts that need the API. + */ + +const NONCE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; +const NONCE_LENGTH = 32; + +/** + * Generates a random nonce for the CSP `script-src 'nonce-...'` directive. + * + * The alphabet is 64 characters so that indexing by a random byte (`byte % 64`) draws uniformly — a 62-character + * alphabet would over-represent its first two characters. `crypto.getRandomValues` is available in the extension + * host runtime and is preferred over `Math.random` for a value that gates script execution. + * + * @returns 32 characters drawn from `[A-Za-z0-9-_]`, unique per call. + */ +export function createNonce(): string { + const bytes = new Uint8Array(NONCE_LENGTH); + crypto.getRandomValues(bytes); + let nonce = ''; + for (const byte of bytes) { + nonce += NONCE_CHARS[byte % NONCE_CHARS.length]; + } + return nonce; +} + +/** + * Builds the Content-Security-Policy meta tag for a webview document. + * + * Styles and scripts must come from files under the webview's own origin — no inline ` +

Configure Component Sources

@@ -2317,15 +2294,9 @@ export class ComponentBrowserProvider { ]
- + - + `; @@ -2340,7 +2311,11 @@ export class ComponentBrowserProvider { * @param errors Map of source name to its error message (as stored by the cache manager). * @returns A complete HTML document string for the webview panel. */ - private getErrorsHtml(errors: Record): string { + private getErrorsHtml(webview: vscode.Webview, errors: Record): string { + const nonce = createNonce(); + const styleUri = assetUri(webview, this.context.extensionUri, 'styles/errors.css'); + const scriptUri = assetUri(webview, this.context.extensionUri, 'client/errors.js'); + const entries = Object.entries(errors); const hasAuthError = entries.some(([, error]) => this.classifySourceError(error).isAuth); @@ -2351,8 +2326,8 @@ export class ComponentBrowserProvider {
${this.escapeHtml(source)}
${this.escapeHtml(summary)}
- - + +
`; }).join(''); @@ -2363,61 +2338,9 @@ export class ComponentBrowserProvider { + ${cspMetaTag(webview.cspSource, nonce)} GitLab CI/CD Components - +

Component Loading Errors

@@ -2429,33 +2352,12 @@ export class ComponentBrowserProvider {
- ${hasAuthError ? '' : ''} - - + ${hasAuthError ? '' : ''} + +
- + `; @@ -2572,57 +2474,21 @@ ${sourceErrors.size > 0 ? '\nErrors:\n' + Array.from(sourceErrors.entries()).map * @param error The thrown value caught while loading components (typed `unknown` at the catch site). * @returns A complete HTML document string for the webview panel. */ - private getErrorHtml(error: unknown): string { + private getErrorHtml(webview: vscode.Webview, error: unknown): string { const message = error instanceof Error ? error.message : String(error); const { isAuth, summary } = this.classifySourceError(message); + const nonce = createNonce(); + const styleUri = assetUri(webview, this.context.extensionUri, 'styles/errors.css'); + const scriptUri = assetUri(webview, this.context.extensionUri, 'client/errors.js'); return ` + ${cspMetaTag(webview.cspSource, nonce)} GitLab CI/CD Components - Error - +

Component Loading Error

@@ -2630,39 +2496,18 @@ ${sourceErrors.size > 0 ? '\nErrors:\n' + Array.from(sourceErrors.entries()).map
${isAuth ? `${this.escapeHtml(summary)} - - ` + + ` : `Error: ${this.escapeHtml(message)}`}
- ${isAuth ? '' : ''} - - + ${isAuth ? '' : ''} + +
- + `; diff --git a/src/services/cache/componentCacheManager.ts b/src/services/cache/componentCacheManager.ts index 93798fdb..7264cc42 100644 --- a/src/services/cache/componentCacheManager.ts +++ b/src/services/cache/componentCacheManager.ts @@ -12,7 +12,6 @@ import { sameCachedComponent } from '../../utils/cachedComponentEquality'; import { CACHE_LOCATION_GLOBAL_STATE, CACHE_LOCATION_MEMORY_ONLY, - SOURCE_LOCAL, DEFAULT_COMPONENT_TYPE_PROJECT, } from '../../constants/cache'; @@ -265,11 +264,9 @@ export class ComponentCacheManager implements vscode.Disposable { if (sources.length === 0) { this.logger.debug( - '[ComponentCache] No sources configured, using local components', + '[ComponentCache] No sources configured, leaving the cache empty', 'ComponentCache' ); - // Add local fallback components - newComponents.push(...this.getLocalFallbackComponents()); } else { // Fetch from all configured sources in parallel const fetchPromises = sources.map(async source => { @@ -804,61 +801,6 @@ export class ComponentCacheManager implements vscode.Disposable { }; } - /** - * Get local fallback components when no sources are configured - */ - private getLocalFallbackComponents(): CachedComponent[] { - return [ - { - name: 'deploy-component', - description: 'Deploys the application to the specified environment', - parameters: [ - { - name: 'environment', - description: 'Target environment for deployment', - required: true, - type: 'string', - }, - { - name: 'version', - description: 'Version to deploy', - required: false, - type: 'string', - default: 'latest', - }, - ], - source: SOURCE_LOCAL, - sourcePath: 'local', - gitlabInstance: 'local', - version: 'latest', - url: 'deploy-component', - }, - { - name: 'test-component', - description: 'Runs tests for the application', - parameters: [ - { - name: 'test_type', - description: 'Type of tests to run', - required: true, - type: 'string', - }, - { - name: 'coverage', - description: 'Whether to collect coverage information', - required: false, - type: 'boolean', - default: false, - }, - ], - source: SOURCE_LOCAL, - sourcePath: 'local', - gitlabInstance: 'local', - version: 'latest', - url: 'test-component', - }, - ]; - } } // Singleton instance diff --git a/src/webview/client/errors.ts b/src/webview/client/errors.ts new file mode 100644 index 00000000..d2c72323 --- /dev/null +++ b/src/webview/client/errors.ts @@ -0,0 +1,63 @@ +/** + * Client script shared by the per-source error list and the single fatal-error view. + * + * Both documents offer the same controls and differ only in how many errors they show, so they load one script. + * Handlers are delegated from the document rather than bound with `onclick` attributes, which the document's + * nonce-based CSP blocks. + */ + +const vscode = acquireVsCodeApi(); + +/** Commands a button can post by naming them in `data-action`. */ +const COMMANDS: Record = { + refresh: 'refreshComponents', + openSettings: 'openSettings', + updateToken: 'updateToken', +}; + +/** + * Show or hide the raw error text a button points at via `data-details-id`, and flip its label. + * + * Toggles the `is-hidden` class rather than `style.display`: the document's CSP forbids `style` attributes, so + * visibility is carried by the stylesheet. + * + * @param button The clicked toggle, whose label is rewritten to match the new state. + * @param detailsId Element id of the block to show or hide. A no-op if no such element exists. + */ +function toggleDetails(button: HTMLElement, detailsId: string): void { + const details = document.getElementById(detailsId); + if (!details) { + return; + } + + const hidden = details.classList.toggle('is-hidden'); + button.textContent = hidden ? 'Show details' : 'Hide details'; +} + +document.addEventListener('click', event => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + const button = target.closest('[data-action]'); + if (!button) { + return; + } + + const action = button.dataset.action; + if (action === 'toggleDetails') { + const detailsId = button.dataset.detailsId; + if (detailsId) { + toggleDetails(button, detailsId); + } + return; + } + + const command = action ? COMMANDS[action] : undefined; + if (command) { + vscode.postMessage({ command }); + } +}); + +export {}; diff --git a/src/webview/client/noSources.ts b/src/webview/client/noSources.ts new file mode 100644 index 00000000..258ce365 --- /dev/null +++ b/src/webview/client/noSources.ts @@ -0,0 +1,14 @@ +/** + * Client script for the "no component sources configured" view. + * + * Runs in the webview, not the extension host. Handlers are bound here rather than with `onclick` attributes, which + * the document's nonce-based CSP blocks. + */ + +const vscode = acquireVsCodeApi(); + +document.querySelector('[data-action="openSettings"]')?.addEventListener('click', () => { + vscode.postMessage({ command: 'openSettings' }); +}); + +export {}; diff --git a/src/webview/styles/errors.css b/src/webview/styles/errors.css new file mode 100644 index 00000000..df02b87a --- /dev/null +++ b/src/webview/styles/errors.css @@ -0,0 +1,77 @@ +body { + font-family: var(--vscode-font-family); + color: var(--vscode-editor-foreground); + padding: 20px; + background-color: var(--vscode-editor-background); +} + +/* Per-source error list (getErrorsHtml). */ +.errors { + background-color: var(--vscode-inputValidation-errorBackground); + border: 1px solid var(--vscode-inputValidation-errorBorder); + padding: 10px; + border-radius: 5px; + margin: 20px 0; +} + +/* Single fatal error (getErrorHtml). */ +.error { + color: var(--vscode-errorForeground); + background-color: var(--vscode-inputValidation-errorBackground); + border: 1px solid var(--vscode-inputValidation-errorBorder); + padding: 15px; + border-radius: 5px; + margin: 20px 0; +} + +.error-item { + margin: 12px 0; +} + +.error-item:not(:last-child) { + border-bottom: 1px solid var(--vscode-inputValidation-errorBorder); + padding-bottom: 12px; +} + +.error-source { + font-weight: 600; + margin-bottom: 4px; +} + +.error-summary { + color: var(--vscode-errorForeground); +} + +.error-raw { + margin: 8px 0 0; + padding: 8px; + white-space: pre-wrap; + overflow-wrap: break-word; + font-size: 0.85em; + background-color: var(--vscode-textCodeBlock-background); + border-radius: 3px; +} + +button { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: none; + padding: 8px 16px; + border-radius: 2px; + cursor: pointer; + margin-right: 8px; +} + +.link-button { + background: none; + color: var(--vscode-textLink-foreground); + padding: 0; + margin: 4px 0 0; + text-decoration: underline; +} + +/* Initial state for the raw error text, which the details toggle then shows. A `style` attribute would be blocked + by the document's CSP. */ +.is-hidden { + display: none; +} diff --git a/src/webview/styles/noSources.css b/src/webview/styles/noSources.css new file mode 100644 index 00000000..596767e4 --- /dev/null +++ b/src/webview/styles/noSources.css @@ -0,0 +1,30 @@ +body { + font-family: var(--vscode-font-family); + color: var(--vscode-editor-foreground); + padding: 20px; + background-color: var(--vscode-editor-background); +} + +.guidance { + background-color: var(--vscode-panel-background); + padding: 20px; + border-radius: 5px; + margin: 20px 0; +} + +pre { + background-color: var(--vscode-textCodeBlock-background); + padding: 10px; + border-radius: 3px; + overflow-x: auto; +} + +button { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: none; + padding: 8px 16px; + border-radius: 2px; + cursor: pointer; + margin-top: 10px; +} diff --git a/stylelint.config.mjs b/stylelint.config.mjs index d3980d44..6e51f942 100644 --- a/stylelint.config.mjs +++ b/stylelint.config.mjs @@ -5,4 +5,12 @@ */ export default { extends: 'stylelint-config-standard', + rules: { + // VS Code's theme variables are camelCase after the component segment (`--vscode-textLink-foreground`), and + // stylelint checks this pattern against `var()` reads as well as declarations, so the standard kebab-case rule + // has to admit them. Kept as an alternative rather than the whole pattern: requiring the `vscode-` prefix + // outright would reject a property this project declares for itself (`--spacing-sm`), with a message + // complaining about kebab-case on a name that is already kebab-case. + 'custom-property-pattern': '^(vscode-[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*|[a-z][a-z0-9]*(-[a-z0-9]+)*)$', + }, }; diff --git a/tests/unit/webviewAssets.test.ts b/tests/unit/webviewAssets.test.ts new file mode 100644 index 00000000..9b0675c4 --- /dev/null +++ b/tests/unit/webviewAssets.test.ts @@ -0,0 +1,67 @@ +// @mocha +/** + * Guards the built webview assets under out/webview. + * + * The HTML builders live on a class that imports `vscode` and cannot be loaded here, so these assert on the build + * output instead — which is what the webview actually loads, and where an escaping or bundling fault would show up. + */ + +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const OUT_DIR = path.resolve(__dirname, '..', '..', 'out', 'webview'); + +/** Client scripts expected to be emitted, without the extension. */ +const CLIENT_SCRIPTS = ['errors', 'noSources']; + +/** Stylesheets expected to be emitted, without the extension. */ +const STYLESHEETS = ['errors', 'loading', 'noSources']; + +function read(relativePath: string): string { + return fs.readFileSync(path.join(OUT_DIR, relativePath), 'utf8'); +} + +suite('built webview assets', function () { + suiteSetup(function () { + // `npm test` runs after `pretest` compiles, but skip rather than fail if the suite is run standalone. + if (!fs.existsSync(OUT_DIR)) { + this.skip(); + } + }); + + for (const name of STYLESHEETS) { + test(`styles/${name}.css is emitted and non-empty`, () => { + assert.ok(read(`styles/${name}.css`).trim().length > 0); + }); + } + + for (const name of CLIENT_SCRIPTS) { + test(`client/${name}.js is emitted as a self-contained IIFE`, () => { + const script = read(`client/${name}.js`); + // `format: 'iife'` keeps each script from leaking globals into the document or colliding with another. + assert.match(script, /^"use strict";\n\(\(\) => \{/); + // A bare `export`/`import` would mean the bundle needs `type="module"`, which the ` + + `; @@ -2419,8 +1993,7 @@ ${sourceErrors.size > 0 ? '\nErrors:\n' + Array.from(sourceErrors.entries()).map const confirmation = await vscode.window.showWarningMessage( 'Are you sure you want to reset the cache? This will clear all cached components and force them to be re-downloaded.', { modal: true }, - 'Reset Cache', - 'Cancel' + 'Reset Cache' ); if (confirmation === 'Reset Cache') { diff --git a/src/webview/client/componentDetails.ts b/src/webview/client/componentDetails.ts new file mode 100644 index 00000000..22d4e5da --- /dev/null +++ b/src/webview/client/componentDetails.ts @@ -0,0 +1,478 @@ +/** + * Client script for the component details panel. + * + * Runs in the webview, not the extension host. Handlers are delegated from the document rather than bound with + * `onclick`/`onchange` attributes, which the document's nonce-based CSP blocks, and visibility is carried by the + * `is-hidden` class rather than `style.display` for the same reason. + */ + +import { renderInlineMarkdown } from '../inlineMarkdown'; + +/** + * State the document embeds for the panel's initial render. + * + * `loaded` says whether the server-rendered dropdown already holds the component's full version list; when it does + * not, the script fetches them rather than leaving the panel showing a single version. + */ +interface DetailsBootstrap { + loaded: boolean; +} + +/** A component parameter as it arrives in a `componentDetailsUpdated` payload. */ +interface ComponentParameter { + name: string; + description?: string; + required?: boolean; + type?: string; + default?: unknown; +} + +/** The component payload sent when the selected version changes. */ +interface ComponentDetails { + name: string; + description?: string; + summary?: string; + usage?: string; + notes?: string[]; + rawYaml?: string; + source?: string; + gitlabInstance?: string; + documentationUrl?: string; + url?: string; + templateFileUrl?: string; + parameters?: ComponentParameter[]; +} + +const vscode = acquireVsCodeApi(); + +/** How long to wait before auto-fetching versions, letting the initial render settle first. */ +const AUTO_FETCH_DELAY_MS = 500; + +let versionsLoaded = readBootstrap().loaded; + +/** + * Read the state the document embedded as JSON. + * + * @returns The embedded state, or an unloaded default when the block is missing or malformed — which leaves the + * auto-fetch to populate the dropdown rather than trusting a partial list. + */ +function readBootstrap(): DetailsBootstrap { + const unloaded: DetailsBootstrap = { loaded: false }; + const element = document.getElementById('details-bootstrap'); + if (!element?.textContent) { + return unloaded; + } + + let parsed: unknown; + try { + parsed = JSON.parse(element.textContent); + } catch { + return unloaded; + } + + return isBootstrap(parsed) ? parsed : unloaded; +} + +/** Narrow a parsed value to the bootstrap shape. */ +function isBootstrap(value: unknown): value is DetailsBootstrap { + if (typeof value !== 'object' || value === null) { + return false; + } + + const candidate: Partial> = value; + return typeof candidate.loaded === 'boolean'; +} + +/** + * Look up an element by id, narrowing it to the expected subtype. + * + * @param id Element id. + * @param construct The DOM constructor the element must be an instance of. + * @returns The element, or null when it is absent or of another type. + */ +function elementById(id: string, construct: new () => T): T | null { + const element = document.getElementById(id); + return element instanceof construct ? element : null; +} + +/** + * Show or hide an element. + * + * @param element Target, or null when the document does not carry it. + * @param visible Whether it should be shown. + */ +function setVisible(element: HTMLElement | null, visible: boolean): void { + element?.classList.toggle('is-hidden', !visible); +} + +/** + * Set an element's text, ignoring the call when the document does not carry it. + * + * @param id Element id. + * @param value Replacement text. + */ +function setText(id: string, value: string): void { + const element = document.getElementById(id); + if (element) { + element.textContent = value; + } +} + +/** + * Report progress or failure in the slot beside the version dropdown. + * + * @param message Text to show. + * @param isError Whether to style it as a failure rather than progress. + */ +function setVersionStatus(message: string, isError: boolean): void { + const status = document.getElementById('versionLoading'); + if (!status) { + return; + } + + status.textContent = message; + status.classList.toggle('version-error', isError); + setVisible(status, true); +} + +/** Ask the extension to write the component into the active file, at the selected version and inputs. */ +function insertComponent(): void { + const select = elementById('versionSelect', HTMLSelectElement); + const includeInputs = elementById('includeInputs', HTMLInputElement); + + const selectedInputs = [...document.querySelectorAll('.input-checkbox:checked')] + .map(checkbox => checkbox.dataset.paramName) + .filter((name): name is string => Boolean(name)); + + vscode.postMessage({ + command: 'insertComponent', + version: select?.value, + includeInputs: includeInputs?.checked ?? false, + selectedInputs, + }); +} + +/** Match every input checkbox to the "select all" box, then reconcile the derived state. */ +function toggleAllInputs(): void { + const selectAll = elementById('selectAllInputs', HTMLInputElement); + if (!selectAll) { + return; + } + + for (const checkbox of document.querySelectorAll('.input-checkbox')) { + checkbox.checked = selectAll.checked; + } + updateInputSelection(); +} + +/** Reconcile the "select all" tri-state with the individual boxes, and opt into inputs once any is chosen. */ +function updateInputSelection(): void { + const all = document.querySelectorAll('.input-checkbox'); + const checked = document.querySelectorAll('.input-checkbox:checked'); + const selectAll = elementById('selectAllInputs', HTMLInputElement); + const includeInputs = elementById('includeInputs', HTMLInputElement); + + if (selectAll) { + selectAll.checked = checked.length > 0 && checked.length === all.length; + selectAll.indeterminate = checked.length > 0 && checked.length < all.length; + } + + if (checked.length > 0 && includeInputs) { + includeInputs.checked = true; + } +} + +/** Ask the extension for the selected version's details, which arrive as `componentDetailsUpdated`. */ +function onVersionChange(): void { + const select = elementById('versionSelect', HTMLSelectElement); + setVersionStatus('Loading version details...', false); + vscode.postMessage({ command: 'versionChanged', selectedVersion: select?.value }); +} + +/** Re-fetch the component's version list, disabling the dropdown until the reply arrives. */ +function refreshVersions(): void { + const select = elementById('versionSelect', HTMLSelectElement); + setVersionStatus('Loading version details...', false); + if (select) { + select.disabled = true; + } + vscode.postMessage({ command: 'fetchVersions' }); +} + +/** Show or hide the component's raw YAML, flipping the trigger's label between Show and Hide. */ +function toggleRawYaml(): void { + const content = document.getElementById('raw-yaml-content'); + const toggle = document.getElementById('toggle-raw-yaml'); + if (!content || !toggle) { + return; + } + + const nowVisible = content.classList.toggle('is-hidden') === false; + toggle.textContent = nowVisible ? 'Hide' : 'Show'; +} + +/** + * Build the parameter list for a component. + * + * Constructed with `createElement` rather than an HTML string: `name` and `description` come from a component's + * catalog entry and are not this extension's to trust. + * + * @param parameters The component's inputs; an empty list renders a placeholder instead. + */ +function renderParameters(parameters: ComponentParameter[]): void { + const container = document.getElementById('parametersContainer'); + if (!container) { + return; + } + + container.replaceChildren(); + + if (parameters.length === 0) { + const empty = document.createElement('p'); + empty.textContent = 'No parameters documented for this component.'; + container.append(empty); + return; + } + + const list = document.createElement('div'); + list.className = 'parameters'; + + for (const param of parameters) { + const row = document.createElement('div'); + row.className = 'parameter'; + + const content = document.createElement('div'); + content.className = 'parameter-content'; + + const heading = document.createElement('div'); + const name = document.createElement('span'); + name.className = 'parameter-name'; + name.textContent = param.name; + const requirement = document.createElement('span'); + requirement.className = param.required ? 'parameter-required' : 'parameter-optional'; + requirement.textContent = param.required ? '(required)' : '(optional)'; + heading.append(name, requirement); + + const description = document.createElement('div'); + description.textContent = param.description || `Parameter: ${param.name}`; + + const type = document.createElement('div'); + const typeLabel = document.createElement('strong'); + typeLabel.textContent = 'Type:'; + type.append(typeLabel, ` ${param.type || 'string'}`); + + content.append(heading, description, type); + + if (param.default !== undefined) { + const defaultRow = document.createElement('div'); + const defaultLabel = document.createElement('strong'); + defaultLabel.textContent = 'Default:'; + const defaultValue = document.createElement('span'); + defaultValue.className = 'parameter-default'; + defaultValue.textContent = String(param.default); + defaultRow.append(defaultLabel, ' ', defaultValue); + content.append(defaultRow); + } + + const checkboxCell = document.createElement('div'); + checkboxCell.className = 'parameter-checkbox'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.id = `input-${param.name}`; + checkbox.className = 'input-checkbox'; + checkbox.dataset.paramName = param.name; + const label = document.createElement('label'); + label.htmlFor = checkbox.id; + label.textContent = 'Insert'; + checkboxCell.append(checkbox, label); + + row.append(content, checkboxCell); + list.append(row); + } + + container.append(list); +} + +/** + * Repaint the whole panel for a newly selected version. + * + * @param component The version's details, as sent in a `componentDetailsUpdated` message. Rows whose field is + * absent are hidden rather than left showing the previous version's value. + */ +function updateComponentDetails(component: ComponentDetails): void { + setText('componentName', component.name); + + const descriptionEl = document.getElementById('componentDescription'); + if (descriptionEl) { + descriptionEl.innerHTML = renderInlineMarkdown(component.description || ''); + } + + const summary = component.summary || ''; + const usage = component.usage || ''; + const notes = Array.isArray(component.notes) ? component.notes : []; + + setVisible(document.getElementById('componentContext'), Boolean(summary || usage || notes.length > 0)); + + setVisible(document.getElementById('componentSummaryRow'), Boolean(summary)); + setText('componentSummary', summary); + + setVisible(document.getElementById('componentUsageRow'), Boolean(usage)); + setText('componentUsage', usage); + + setVisible(document.getElementById('componentNotesRow'), notes.length > 0); + const notesEl = document.getElementById('componentNotes'); + if (notesEl) { + notesEl.replaceChildren(...notes.map(note => { + const item = document.createElement('li'); + item.textContent = note; + return item; + })); + } + + const rawYaml = component.rawYaml || ''; + setVisible(document.getElementById('rawYamlSection'), rawYaml.length > 0); + const rawContent = document.getElementById('raw-yaml-content'); + if (rawContent) { + rawContent.textContent = rawYaml; + setVisible(rawContent, false); + } + setText('toggle-raw-yaml', 'Show'); + + if (component.source) { + setText('componentSource', component.source); + } + if (component.gitlabInstance) { + setText('componentInstance', component.gitlabInstance); + } + + // Link text only. The anchors carry no URL: clicking one asks the extension host to open the link, and the host + // opens the URL it holds for the active component rather than anything this document sends. So nothing a + // component's metadata supplies ever reaches an `href` here. The server sanitised these before posting them. + if (component.documentationUrl) { + setText('componentDocUrl', component.documentationUrl); + } + + if (component.url) { + setText('componentUrl', component.url); + } + + // The server precomputes templateFileUrl; its absence means no resolved templatePath, so the row is hidden. + const templateFileUrl = document.getElementById('templateFileUrl'); + if (templateFileUrl) { + if (component.templateFileUrl) { + templateFileUrl.textContent = component.templateFileUrl; + } + setVisible(templateFileUrl, Boolean(component.templateFileUrl)); + } + + const parameters = component.parameters || []; + renderParameters(parameters); + + setVisible(document.querySelector('.select-all-group'), parameters.length > 0); + const selectAll = elementById('selectAllInputs', HTMLInputElement); + if (selectAll) { + selectAll.checked = false; + selectAll.indeterminate = false; + } + + const includeInputs = document.getElementById('includeInputs'); + setVisible(includeInputs?.parentElement?.parentElement ?? null, parameters.length > 0); + + setVisible(document.getElementById('versionLoading'), false); +} + +/** + * Repopulate the version dropdown, keeping the full tag as each option's value. + * + * @param versions Every version available for the component, in display order. + * @param currentVersion The one to select. + * @param versionLabels Optional `tag → label` map for monorepo sources, whose tags embed the component name; a tag + * with no entry shows as itself. + */ +function updateVersionDropdown( + versions: string[], + currentVersion: string, + versionLabels?: Record +): void { + const select = elementById('versionSelect', HTMLSelectElement); + if (!select) { + return; + } + + const labels = versionLabels || {}; + select.replaceChildren(...versions.map(version => { + const option = document.createElement('option'); + option.value = version; + // Monorepo sources send a stripped `{version}` label; other sources show the tag itself. + option.textContent = labels[version] || version; + option.selected = version === currentVersion; + return option; + })); + + const loading = document.getElementById('versionLoading'); + setVisible(loading, false); + loading?.classList.remove('version-error'); + select.disabled = false; + versionsLoaded = true; +} + +/** Actions a control can request by naming one in `data-action`. */ +const ACTIONS: Record void> = { + openDocumentation: () => vscode.postMessage({ command: 'openLink', link: 'documentation' }), + openTemplateFile: () => vscode.postMessage({ command: 'openLink', link: 'templateFile' }), + insertComponent, + refreshVersions, + toggleRawYaml, + toggleAllInputs, + updateInputSelection, + onVersionChange, +}; + +for (const eventName of ['click', 'change'] as const) { + document.addEventListener(eventName, event => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + // Delegated so controls rebuilt by `renderParameters` need no rebinding. + const control = target.closest('[data-action]'); + if (control instanceof HTMLAnchorElement) { + event.preventDefault(); + } + const action = control?.dataset.action; + if (action) { + ACTIONS[action]?.(); + } + }); +} + +window.addEventListener('message', event => { + const message = event.data; + + switch (message.command) { + case 'versionsLoaded': + updateVersionDropdown(message.versions, message.currentVersion, message.versionLabels); + break; + case 'versionsError': { + setVersionStatus(`Could not load versions: ${message.error || 'unknown error'}`, true); + const select = elementById('versionSelect', HTMLSelectElement); + if (select) { + select.disabled = false; + } + break; + } + case 'componentDetailsUpdated': + updateComponentDetails(message.component); + break; + case 'versionChangeError': + setVersionStatus(`Could not load that version: ${message.error || 'unknown error'}`, true); + break; + } +}); + +if (!versionsLoaded) { + setTimeout(refreshVersions, AUTO_FETCH_DELAY_MS); +} + +export {}; diff --git a/src/webview/client/tsconfig.json b/src/webview/client/tsconfig.json new file mode 100644 index 00000000..2d35130a --- /dev/null +++ b/src/webview/client/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "typeRoots": [ + "../../../node_modules/@types" + ], + "types": [ + "vscode-webview" + ] + }, + "include": [ + "**/*" + ], + "exclude": [] +} diff --git a/src/webview/safeUrl.ts b/src/webview/safeUrl.ts new file mode 100644 index 00000000..977b554b --- /dev/null +++ b/src/webview/safeUrl.ts @@ -0,0 +1,41 @@ +/** + * The one check a URL from component metadata passes before the details panel shows it as a link. + * + * `vscode`-free and pure so the unit suite can drive it directly, and shared by the HTML builder and the extension-host + * message handler, so the first render and every later update apply the same rule. + * + * These URLs are attacker-influenced: `documentationUrl` comes straight from a catalog project's `documentation_url`, + * which whoever publishes the component controls. + */ + +const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']); + +/** + * Accept a URL only if it is an absolute `http(s)` URL with no embedded credentials. + * + * Returns the *parsed* `href`, not the input, so what is displayed and opened is exactly what was validated: the URL + * parser strips the tabs and newlines that would otherwise let `java\nscript:` through a string comparison. + * + * Userinfo is rejected because it is a phishing shape rather than a real use: `https://gitlab.com@evil.example/` + * reads as a gitlab.com link and resolves to evil.example. + * + * @param value Candidate URL, typically from a component's metadata. + * @returns The normalised URL when acceptable, otherwise `undefined`. + */ +export function safeHttpUrl(value: unknown): string | undefined { + if (typeof value !== 'string' || value.trim() === '') { + return undefined; + } + + let parsed: URL; + try { + parsed = new URL(value.trim()); + } catch { + return undefined; + } + + if (!ALLOWED_PROTOCOLS.has(parsed.protocol) || parsed.username || parsed.password) { + return undefined; + } + return parsed.href; +} diff --git a/src/webview/styles/componentDetails.css b/src/webview/styles/componentDetails.css new file mode 100644 index 00000000..7e1fa77f --- /dev/null +++ b/src/webview/styles/componentDetails.css @@ -0,0 +1,187 @@ +body { + font-family: var(--vscode-font-family); + color: var(--vscode-editor-foreground); + padding: 20px; + background-color: var(--vscode-editor-background); +} + +h1 { + border-bottom: 1px solid var(--vscode-panel-border); + padding-bottom: 10px; +} + +.description { + margin-bottom: 20px; +} + +.metadata { + background-color: var(--vscode-panel-background); + padding: 10px; + border-radius: 5px; + margin-bottom: 20px; +} + +.metadata div { + margin-bottom: 5px; +} + +.version-control { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +.version-control select { + background-color: var(--vscode-dropdown-background); + color: var(--vscode-dropdown-foreground); + border: 1px solid var(--vscode-dropdown-border); + border-radius: 2px; + padding: 4px 8px; + min-width: 120px; +} + +.version-loading { + font-size: 0.9em; + color: var(--vscode-disabledForeground); +} + +.version-loading.version-error { + color: var(--vscode-errorForeground); +} + +.parameters { + border: 1px solid var(--vscode-panel-border); + border-radius: 5px; +} + +.parameter { + padding: 10px; + border-bottom: 1px solid var(--vscode-panel-border); + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.parameter:last-child { + border-bottom: none; +} + +.parameter-content { + flex: 1; + margin-right: 15px; +} + +.parameter-checkbox { + display: flex; + align-items: center; + gap: 5px; + margin-top: 5px; +} + +.parameter-name { + font-weight: bold; +} + +.parameter-required { + color: var(--vscode-errorForeground); + font-size: 0.9em; +} + +.parameter-optional { + color: var(--vscode-disabledForeground); + font-size: 0.9em; +} + +.parameter-default { + font-family: monospace; + background-color: var(--vscode-textCodeBlock-background); + padding: 2px 4px; + border-radius: 3px; +} + +.parameters-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.select-all-group { + display: flex; + align-items: center; + gap: 5px; + font-size: 0.9em; +} + +.insert-options { + margin-top: 20px; + padding: 15px; + background-color: var(--vscode-panel-background); + border-radius: 5px; +} + +.insert-options h3 { + margin-top: 0; + margin-bottom: 15px; +} + +.checkbox-group { + margin-bottom: 15px; +} + +.checkbox-group label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.checkbox-group input[type="checkbox"] { + margin: 0; +} + +button { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: none; + padding: 8px 16px; + border-radius: 2px; + cursor: pointer; +} + +button:hover { + background-color: var(--vscode-button-hoverBackground); +} + +button.secondary { + background-color: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); +} + +button.secondary:hover { + background-color: var(--vscode-button-secondaryHoverBackground); +} + +#raw-yaml-content { + white-space: pre; + overflow-x: auto; + background-color: var(--vscode-textCodeBlock-background); + padding: 10px; + border-radius: 5px; + border: 1px solid var(--vscode-panel-border); +} + +/* Visibility, carried by class rather than a `style` attribute the document's CSP would block. Rows whose content + is optional start hidden and are revealed as data arrives. */ +.is-hidden { + display: none; +} + +.raw-yaml-header { + margin-bottom: 5px; +} + +.raw-yaml-header p { + margin: 0; +} diff --git a/tests/unit/safeUrl.test.ts b/tests/unit/safeUrl.test.ts new file mode 100644 index 00000000..39e27a10 --- /dev/null +++ b/tests/unit/safeUrl.test.ts @@ -0,0 +1,54 @@ +// @mocha +/** + * Tests src/webview/safeUrl.ts — the check a component-metadata URL passes before the details panel shows or opens it. + */ + +import * as assert from 'node:assert/strict'; +import { safeHttpUrl } from '../../src/webview/safeUrl'; + +suite('safeHttpUrl', () => { + test('accepts http and https, returning the normalised URL', () => { + assert.equal(safeHttpUrl('https://gitlab.com/group/project'), 'https://gitlab.com/group/project'); + assert.equal(safeHttpUrl('http://gitlab.example.com'), 'http://gitlab.example.com/'); + assert.equal(safeHttpUrl(' https://gitlab.com/x '), 'https://gitlab.com/x'); + }); + + test('rejects every script-bearing or non-web scheme', () => { + for (const value of [ + 'javascript:alert(1)', + 'JavaScript:alert(1)', + ' javascript:alert(1)', + 'java\nscript:alert(1)', + 'java\tscript:alert(1)', + 'data:text/html,', + 'vbscript:msgbox(1)', + 'file:///etc/passwd', + 'vscode:extension/foo', + 'command:workbench.action.terminal.new', + ]) { + assert.equal(safeHttpUrl(value), undefined, `should reject ${JSON.stringify(value)}`); + } + }); + + test('rejects relative and protocol-relative URLs, which would resolve against the webview origin', () => { + assert.equal(safeHttpUrl('//evil.example.com/x'), undefined); + assert.equal(safeHttpUrl('/-/blob/main/x.yml'), undefined); + }); + + test('rejects embedded credentials, which disguise the real host', () => { + // Reads as a gitlab.com link; resolves to evil.example. + assert.equal(safeHttpUrl('https://gitlab.com@evil.example/'), undefined); + assert.equal(safeHttpUrl('https://user:pass@gitlab.com/'), undefined); + }); + + test('returns the parsed form, so the displayed and opened URL is the validated one', () => { + const result = safeHttpUrl('https://gitlab.com/a b'); + assert.equal(result, 'https://gitlab.com/a%20b'); + }); + + test('rejects empty and non-string input', () => { + for (const value of [undefined, null, '', ' ', 42, {}]) { + assert.equal(safeHttpUrl(value), undefined); + } + }); +}); diff --git a/tests/unit/webviewBuilderMarkup.test.ts b/tests/unit/webviewBuilderMarkup.test.ts index 16b5506a..42ffbf1a 100644 --- a/tests/unit/webviewBuilderMarkup.test.ts +++ b/tests/unit/webviewBuilderMarkup.test.ts @@ -41,3 +41,21 @@ suite('componentBrowserProvider markup', () => { assert.deepEqual(offenders, [], `duplicate attributes:\n${offenders.join('\n')}`); }); }); + +suite('component details link handling', () => { + const client = fs.readFileSync( + path.resolve(__dirname, '..', '..', 'src', 'webview', 'client', 'componentDetails.ts'), 'utf8', + ); + const provider = fs.readFileSync(PROVIDER, 'utf8'); + + test('the client script never assigns a URL to a navigable property', () => { + // Component metadata reaches this script by message and is attacker-influenced (`documentation_url` is set by + // whoever publishes the component). Links are opened by the extension host instead, so there must be no sink here. + assert.doesNotMatch(client, /\.(href|src|action)\s*=/); + assert.doesNotMatch(client, /\b(location|window\.open)\b/); + }); + + test('the builder never interpolates a metadata URL into an href', () => { + assert.doesNotMatch(provider, /href="\$\{[^}]*(documentationUrl|templateFileUrl|safeDocUrl|safeTemplateUrl)/); + }); +}); diff --git a/tsconfig.client.json b/tsconfig.client.json deleted file mode 100644 index 20074a28..00000000 --- a/tsconfig.client.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": true, - "typeRoots": [ - "./node_modules/@types" - ], - "types": [ - "vscode-webview" - ] - }, - "include": [ - "src/webview/client/**/*" - ], - "exclude": [ - "node_modules", - ".vscode-test", - "out", - "out-test", - "tests" - ] -} From 6eef973dfedbcd228d757f302dbbf46a0c960309 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 24 Sep 2026 22:54:04 +0000 Subject: [PATCH 39/54] chore(release): 0.17.15 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9979792e..fd47bee5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.17.14", + "version": "0.17.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.17.14", + "version": "0.17.15", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.2.2", diff --git a/package.json b/package.json index 6a98eb76..359d13ed 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gitlab-component-helper", "displayName": "GitLab Component Helper", "description": "Provides intellisense for GitLab CI components", - "version": "0.17.14", + "version": "0.17.15", "icon": "images/icon.png", "engines": { "node": ">=22.0.0", From 66ab25bc558fbc13ac9fb371c179f69613130b5d Mon Sep 17 00:00:00 2001 From: Simon Heather <32168619+X-Guardian@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:17:06 +0100 Subject: [PATCH 40/54] refactor(webview): serve the Component Browser from linted external assets (#311) * refactor(webview): serve the Component Browser from linted external assets * fix(browser): stop publisher-controlled text from running in the Component Browser Moving the browser's script into a linted file let CodeQL see it for the first time: 8 client-side XSS and 2 prototype-pollution alerts. They are real and pre-existing, not introduced by the extraction. Version tags, component names and spec fields are set by whoever publishes a component, and git accepts quotes and angle brackets in a tag name. The browser concatenated those values into `innerHTML` and into `onclick` source, and this panel runs scripts with no CSP yet. Client (componentBrowser.ts): - The dropdown, Details/Insert buttons, error banner and version line are built as elements, with text via `textContent` and handlers as closures, instead of as markup strings. - Switching version repoints the buttons by assigning `onclick` closures, found by a `data-role`, instead of rewriting their handler text with the version inside it. - Selectors built from names go through `CSS.escape`. - Version entries are written through `storeVersion`, which refuses `__proto__`, `constructor` and `prototype` and uses null-prototype maps. Server (componentBrowserProvider.ts), where the first render had the same flaw: - Values passed to an `onclick`/`onchange` handler use `handlerArg`: `JSON.stringify` for the JavaScript string, then `escapeHtml` for the attribute. `escapeHtml` alone is not enough; the browser decodes `'` back to `'` before the handler runs. - Names, paths, instances, versions and source titles are escaped in text and attributes. - The version map is built from null-prototype objects, so a component or version named `__proto__` cannot write to Object.prototype in the extension host. - The details panel's name, source, instance, URL, summary, usage, notes and parameter fields are escaped (#238). Tests: `handlerArg` against hostile tag names, checked by decoding the attribute the way a browser does and confirming it parses as exactly one string equal to the input; plus source guards that the client builds markup only through the escaping renderer, never writes handler text, and stores versions only through the key check. * fix(browser): hold component versions in Maps, not objects keyed by publisher text The previous commit refused `__proto__`, `constructor` and `prototype` before writing a version entry. That guard was correct, but it is a blocklist, and CodeQL (rightly) does not treat one as proof: its medium prototype-pollution alert stayed open on the write. Component names and version strings are set by whoever publishes a component. Storing them as keys of a plain object is the root of the problem, since both reads and writes then reach `Object.prototype` for a handful of names. A `Map` has no such keys, so the bug class is removed rather than filtered. `readVersionData` builds `Map>` from the embedded JSON, keeping only well-formed entries; `storeVersion` and `findVersion` are the only way in and out. The `window` global it replaces had no readers outside this bundle. --------- Co-authored-by: Simon Heather Co-authored-by: eFAILution --- src/providers/componentBrowserProvider.ts | 784 ++----------------- src/webview/client/componentBrowser.ts | 571 ++++++++++++++ src/webview/clientInlineMarkdown.ts | 34 - src/webview/inlineMarkdown.ts | 15 + src/webview/styles/componentBrowser.css | 353 +++++++++ tests/unit/clientInlineMarkdown.test.ts | 63 -- tests/unit/componentBrowserInjection.test.ts | 84 ++ 7 files changed, 1085 insertions(+), 819 deletions(-) create mode 100644 src/webview/client/componentBrowser.ts delete mode 100644 src/webview/clientInlineMarkdown.ts create mode 100644 src/webview/styles/componentBrowser.css delete mode 100644 tests/unit/clientInlineMarkdown.test.ts create mode 100644 tests/unit/componentBrowserInjection.test.ts diff --git a/src/providers/componentBrowserProvider.ts b/src/providers/componentBrowserProvider.ts index 5b6eb4a2..dd472d73 100644 --- a/src/providers/componentBrowserProvider.ts +++ b/src/providers/componentBrowserProvider.ts @@ -9,8 +9,7 @@ import type { HoverContext } from './hoverContentBuilder'; import { containsGitLabVariables } from '../utils/gitlabVariables'; import { Logger } from '../utils/logger'; import { templateFileUrlForResolved } from '../utils/templateFileUrl'; -import { escapeHtml, renderInlineMarkdown } from '../webview/inlineMarkdown'; -import { clientRenderInlineMarkdownSource } from '../webview/clientInlineMarkdown'; +import { escapeHtml, handlerArg, renderInlineMarkdown } from '../webview/inlineMarkdown'; import { serializeForScript } from '../webview/scriptData'; import { generateComponentText } from './componentBrowserGenerate'; import { findComponentLineRange, parseExistingComponentText } from './componentBrowserEdit'; @@ -352,7 +351,7 @@ export class ComponentBrowserProvider { this.logger.debug(`[ComponentBrowser] Filtered errors: ${Object.keys(filteredErrors).length} of ${Object.keys(cacheErrors).length}`, 'ComponentBrowser'); - this.panel.webview.html = this.getComponentBrowserHtml(allComponents, filteredErrors); + this.panel.webview.html = this.getComponentBrowserHtml(this.panel.webview, allComponents, filteredErrors); } catch (error) { this.logger.error(`[ComponentBrowser] Error in loadComponents: ${error}`, 'ComponentBrowser'); if (this.panel) { @@ -730,17 +729,35 @@ export class ComponentBrowserProvider { .join(''); } - private getComponentBrowserHtml(componentGroups: SourceGroup[], cacheErrors: Record = {}): string { + /** + * Render the Component Browser: every configured source, its projects and their components, as a collapsible tree. + * + * @param webview The panel this document is for, used to resolve its asset URIs. + * @param componentGroups Sources with their projects and components, already grouped for display. + * @param cacheErrors Per-source failure messages, keyed by source name. Sources that failed are listed in a + * banner above the tree; an empty record omits it. + * @returns The panel's complete HTML document. + */ + private getComponentBrowserHtml( + webview: vscode.Webview, + componentGroups: SourceGroup[], + cacheErrors: Record = {} + ): string { + const styleUri = assetUri(webview, this.context.extensionUri, 'styles/componentBrowser.css'); + const scriptUri = assetUri(webview, this.context.extensionUri, 'client/componentBrowser.js'); const hasErrors = Object.keys(cacheErrors).length > 0; // Prepare version data as a safe JSON string + // Null-prototype maps: component names and versions are publisher-controlled, and `__proto__` is a legal template + // file name and git tag. On a plain object, `acc['__proto__'][v] = …` would write to Object.prototype in the + // extension host. const versionData = componentGroups.reduce>>((acc, source) => { if (source.projects && Array.isArray(source.projects)) { source.projects.forEach(project => { if (project.components && Array.isArray(project.components)) { project.components.forEach(component => { - if (!acc[component.name]) { - acc[component.name] = {}; + if (!Object.prototype.hasOwnProperty.call(acc, component.name)) { + acc[component.name] = Object.create(null) as Record; } if (component.versions && Array.isArray(component.versions)) { component.versions.forEach(version => { @@ -752,7 +769,7 @@ export class ComponentBrowserProvider { }); } return acc; - }, {}); + }, Object.create(null) as Record>); const versionDataJson = serializeForScript(versionData); @@ -788,34 +805,35 @@ export class ComponentBrowserProvider { '

No components found in this project

' : components.map(component => { const componentKey = `${component.name}-${component.sourcePath}`; + const initialVersion = component.defaultVersion || component.availableVersions[0]; const hasVersions = component.availableVersions && component.availableVersions.length > 0; return ` -
+
- ${component.name} + ${this.escapeHtml(component.name)} ${hasVersions && component.availableVersions.length > 1 ? `${component.availableVersions.length} versions` : ''} -
+
${hasVersions ? ` ${component.availableVersions.length > 1 ? ` - ${this.renderVersionOptions(component)} - ` : `${component.availableVersions[0] || 'latest'}`} - - + ` : `${this.escapeHtml(component.availableVersions[0] || 'latest')}`} + + ` : ` - - + + `}
-
${this.renderInlineMarkdown(component.description || '')}
+
${this.renderInlineMarkdown(component.description || '')}
${hasVersions && component.availableVersions.length > 1 ? ` -
- Default version: ${component.defaultVersion} +
+ Default version: ${this.escapeHtml(component.defaultVersion)}
` : ''}
@@ -826,8 +844,8 @@ export class ComponentBrowserProvider {
${project.isExpanded ? '▼' : '▶'} - ${project.name} (${components.length}) - ${project.gitlabInstance}/${project.path} + ${this.escapeHtml(project.name)} (${components.length}) + ${this.escapeHtml(project.gitlabInstance)}/${this.escapeHtml(project.path)}
${componentsHtml} @@ -840,7 +858,7 @@ export class ComponentBrowserProvider {
${source.isExpanded ? '▼' : '▶'} - ${source.source} (${source.projects?.length || 0} projects, ${source.totalComponents || 0} components) + ${this.escapeHtml(source.source)} (${source.projects?.length || 0} projects, ${source.totalComponents || 0} components)
${projectsHtml} @@ -856,306 +874,7 @@ export class ComponentBrowserProvider { GitLab CI/CD Components - +
@@ -1181,388 +900,8 @@ export class ComponentBrowserProvider {
Always Use Latest
- + + `; @@ -1648,12 +987,12 @@ export class ComponentBrowserProvider { - Component: ${component.name} + Component: ${this.escapeHtml(component.name)} ${cspMetaTag(webview.cspSource, nonce)} -

${component.name}

+

${this.escapeHtml(component.name)}

${this.renderInlineMarkdown(component.description || '')} @@ -1662,15 +1001,15 @@ export class ComponentBrowserProvider { @@ -1684,8 +1023,8 @@ export class ComponentBrowserProvider {
`).join('')} @@ -1804,12 +1143,13 @@ export class ComponentBrowserProvider { return escapeHtml(value); } - private renderInlineMarkdown(value: string): string { - return renderInlineMarkdown(value); + /** See {@link handlerArg}: a JS string argument, safe inside an event-handler attribute. */ + private jsArg(value: string | undefined): string { + return handlerArg(value); } - private clientRenderInlineMarkdownSource(): string { - return clientRenderInlineMarkdownSource(); + private renderInlineMarkdown(value: string): string { + return renderInlineMarkdown(value); } private buildTemplateFileUrl(component: Component): string | undefined { diff --git a/src/webview/client/componentBrowser.ts b/src/webview/client/componentBrowser.ts new file mode 100644 index 00000000..28f3ea61 --- /dev/null +++ b/src/webview/client/componentBrowser.ts @@ -0,0 +1,571 @@ +/** + * Client script for the Component Browser. + * + * Runs in the webview, not the extension host. The document still binds its controls with `onclick`/`onchange` + * attributes, which resolve against the global scope, so the functions they name are assigned to `window` at the end + * of this file — the bundle is an IIFE and would otherwise keep them private. Those attributes, and this export + * block with them, go when the browser moves to delegated listeners under a CSP. + */ + +import { renderInlineMarkdown } from '../inlineMarkdown'; + +/** A component version as the browser holds it, keyed by component name then version string. */ +interface VersionEntry { + version: string; + description?: string; + sourcePath: string; + gitlabInstance?: string; +} + +/** Which component a context-menu action applies to, captured when the menu opens. */ +interface ContextMenuData { + componentName: string; + version: string; + projectId: string; +} + +const vscode = acquireVsCodeApi(); + +/** + * Every version the browser knows about, by component name then version string. + * + * Maps rather than plain objects: both keys are set by whoever publishes the component, and `__proto__` is a legal + * file name and git tag. On an object, `data[name][version] = …` with either key set to `__proto__` writes through + * to `Object.prototype`, and reading `data['constructor']` returns a function. A `Map` has no such keys. + */ +const versionStore = readVersionData(); + +let contextMenuData: ContextMenuData | null = null; + +/** + * Read the version map the document embedded as JSON. + * + * @returns The map, or an empty one when the block is missing or malformed. Only well-formed entries are kept. + */ +function readVersionData(): Map> { + const store = new Map>(); + const element = document.getElementById('component-version-data'); + if (!element?.textContent) { + return store; + } + + let parsed: unknown; + try { + parsed = JSON.parse(element.textContent); + } catch { + return store; + } + if (!isObject(parsed)) { + return store; + } + + for (const [componentName, versions] of Object.entries(parsed)) { + if (!isObject(versions)) { + continue; + } + const entries = Object.entries(versions).filter((pair): pair is [string, VersionEntry] => isObject(pair[1])); + store.set(componentName, new Map(entries)); + } + return store; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Record one version of a component. */ +function storeVersion(componentName: string, version: string, entry: VersionEntry): void { + let versions = versionStore.get(componentName); + if (!versions) { + versions = new Map(); + versionStore.set(componentName, versions); + } + versions.set(version, entry); +} + +/** A component's entry at one version, if the browser has it. */ +function findVersion(componentName: string, version: string): VersionEntry | undefined { + return versionStore.get(componentName)?.get(version); +} + +/** A card's control, found by the `data-role` both the server and this script put on it. */ +function cardControl(componentName: string, projectId: string, role: string): HTMLElement | null { + return document.querySelector( + `[data-component-name="${CSS.escape(componentName)}"][data-project-id="${CSS.escape(projectId)}"] [data-role="${role}"]` + ); +} + +/** + * Build the Details and Insert buttons for a component at a version. + * + * Built as elements with listeners rather than as markup with `onclick` text: the component name and version are set + * by whoever publishes the component, and git accepts quotes and angle brackets in a tag name, so interpolating them + * into HTML or into a handler's JavaScript lets a tag run code in this panel. + */ +function actionButtons(componentName: string, version: string): HTMLButtonElement[] { + const details = document.createElement('button'); + details.textContent = 'Details'; + details.dataset.role = 'details'; + details.onclick = () => viewDetailsById(componentName, version); + + const insert = document.createElement('button'); + insert.textContent = 'Insert'; + insert.dataset.role = 'insert'; + insert.onclick = () => insertComponentById(componentName, version); + + return [details, insert]; +} + +/** A `` holding plain text, for the version line under a component's description. */ +function smallText(text: string): HTMLElement { + const small = document.createElement('small'); + small.textContent = text; + return small; +} + +function toggleError(errorId: string): void { + const errorDiv = document.getElementById(errorId); + if (!errorDiv) { + return; + } + const hidden = errorDiv.style.display === 'none' || errorDiv.style.display === ''; + errorDiv.style.display = hidden ? 'block' : 'none'; +} + +function updateToken(): void { + vscode.postMessage({ command: 'updateToken' }); +} + +/** Expand or collapse a source group, flipping its disclosure arrow. */ +function toggleSource(sourceId: string): void { + toggleDisclosure(`source-content-${sourceId}`, `source-icon-${sourceId}`); +} + +/** Expand or collapse a project group, flipping its disclosure arrow. */ +function toggleProject(projectId: string): void { + toggleDisclosure(`project-content-${projectId}`, `project-icon-${projectId}`); +} + +/** + * Show or hide a collapsible region and point its arrow accordingly. + * + * @param contentId Element holding the region's contents. + * @param iconId Element holding the disclosure arrow. + */ +function toggleDisclosure(contentId: string, iconId: string): void { + const content = document.getElementById(contentId); + const icon = document.getElementById(iconId); + if (!content || !icon) { + return; + } + + const collapsed = content.style.display === 'none'; + content.style.display = collapsed ? 'block' : 'none'; + icon.textContent = collapsed ? '▼' : '▶'; +} + +/** + * Ask the extension for a component's versions, showing the card's loading state until they arrive. + * + * @param _projectId Unused, but part of the signature the document's `onclick` attributes call with. + */ +function loadComponentVersions( + componentName: string, + sourcePath: string, + gitlabInstance: string, + _projectId: string +): void { + const componentKey = `${componentName}-${sourcePath}`; + const loadingElement = document.getElementById(`loading-${componentKey}`); + const loadButton = document.getElementById(`component-${componentKey}`)?.querySelector('.load-versions-btn'); + if (loadingElement) { + loadingElement.style.display = 'inline'; + } + if (loadButton) { + loadButton.style.display = 'none'; + } + vscode.postMessage({ command: 'fetchVersions', componentName, sourcePath, gitlabInstance }); +} + +window.addEventListener('message', event => { + const message = event.data; + if (message.command === 'versionsLoaded') { + handleVersionsLoaded(message); + } else if (message.command === 'versionsError') { + handleVersionsError(message); + } +}); + +/** Populate a component's actions with the versions the extension fetched. */ +function handleVersionsLoaded(message: { + componentName: string; + sourcePath: string; + versions: string[]; + defaultVersion: string; + gitlabInstance?: string; + versionLabels?: Record; +}): void { + const { componentName, sourcePath, versions, defaultVersion } = message; + const componentKey = `${componentName}-${sourcePath}`; + + versions.forEach(v => { + storeVersion(componentName, v, { + version: v, + sourcePath, + gitlabInstance: message.gitlabInstance || 'gitlab.com', + }); + }); + + const componentCard = document.getElementById(`component-${componentKey}`); + if (!componentCard) { + return; + } + + const projectId = componentCard.getAttribute('data-project-id') || ''; + const actionsDiv = document.getElementById(`actions-${componentKey}`); + if (actionsDiv) { + if (versions.length > 1) { + // For monorepo sources the version values are full tags (e.g. -1.1.0); the server sends a + // versionLabels map (full tag → stripped {version}) so we display the short form while keeping the + // full tag as the option value (the inserted ref). + const labels = message.versionLabels || {}; + const select = document.createElement('select'); + select.className = 'version-dropdown'; + select.onchange = () => updateComponentVersion(componentName, select.value, projectId); + versions.forEach(v => { + const option = document.createElement('option'); + option.value = v; + option.textContent = labels[v] || v; + if (v === defaultVersion) { + option.selected = true; + } + select.appendChild(option); + }); + actionsDiv.replaceChildren(select, ...actionButtons(componentName, defaultVersion)); + + const descElement = document.getElementById(`desc-${componentName}-${projectId}`); + if (descElement && !document.getElementById(`version-info-${componentName}-${projectId}`)) { + const versionInfo = document.createElement('div'); + versionInfo.className = 'version-info'; + versionInfo.id = `version-info-${componentName}-${projectId}`; + versionInfo.replaceChildren(smallText(`Default version: ${defaultVersion}`)); + descElement.parentNode?.insertBefore(versionInfo, descElement.nextSibling); + } + } else { + const singleVersion = versions[0] || 'latest'; + const label = document.createElement('span'); + label.className = 'single-version'; + label.textContent = singleVersion; + actionsDiv.replaceChildren(label, ...actionButtons(componentName, singleVersion)); + } + } + + const titleSpan = componentCard.querySelector('.component-title'); + if (titleSpan && versions.length > 1 && !titleSpan.querySelector('.version-badge')) { + const badge = document.createElement('span'); + badge.className = 'version-badge'; + badge.textContent = `${versions.length} versions`; + titleSpan.appendChild(badge); + } +} + +/** Replace a component's actions with a failure message and a retry button. */ +function handleVersionsError(message: { + componentName: string; + sourcePath: string; + gitlabInstance?: string; +}): void { + const componentKey = `${message.componentName}-${message.sourcePath}`; + const loadingElement = document.getElementById(`loading-${componentKey}`); + if (loadingElement) { + loadingElement.style.display = 'none'; + } + + const actionsDiv = document.getElementById(`actions-${componentKey}`); + if (actionsDiv) { + const failure = document.createElement('span'); + failure.className = 'error-message'; + failure.textContent = 'Failed to load versions'; + + const retry = document.createElement('button'); + retry.className = 'load-versions-btn'; + retry.textContent = 'Retry'; + retry.onclick = () => loadComponentVersions( + message.componentName, message.sourcePath, message.gitlabInstance || 'gitlab.com', '' + ); + + actionsDiv.replaceChildren(failure, retry); + } +} + +/** Repoint a component's card, description and action buttons at the newly selected version. */ +function updateComponentVersion(componentName: string, selectedVersion: string, projectId: string): void { + const versionData = findVersion(componentName, selectedVersion); + if (!versionData) { + console.warn('Version data not found for', componentName, selectedVersion); + + // Try to fetch this version dynamically + const componentCard = document.querySelector( + `[data-component-name="${CSS.escape(componentName)}"][data-project-id="${CSS.escape(projectId)}"]` + ); + if (componentCard) { + const sourcePath = componentCard.getAttribute('data-source-path'); + const gitlabInstance = componentCard.getAttribute('data-gitlab-instance'); + + if (sourcePath && gitlabInstance) { + // Show loading state + const versionInfoElement = document.getElementById(`version-info-${componentName}-${projectId}`); + if (versionInfoElement) { + versionInfoElement.replaceChildren(smallText(`Loading version ${selectedVersion}...`)); + } + + vscode.postMessage({ + command: 'fetchVersion', + componentName, + sourcePath, + gitlabInstance, + version: selectedVersion, + }); + } + } + return; + } + + + const descElement = document.getElementById(`desc-${componentName}-${projectId}`); + if (descElement) { + descElement.innerHTML = renderInlineMarkdown(versionData.description || ''); + } + + const versionInfoElement = document.getElementById(`version-info-${componentName}-${projectId}`); + if (versionInfoElement) { + versionInfoElement.replaceChildren(smallText(`Selected version: ${selectedVersion}`)); + } + + // Repoint the buttons with listeners. Rewriting their `onclick` text would put the version string inside + // JavaScript source, which a tag containing a quote breaks out of. + const insertButton = cardControl(componentName, projectId, 'insert'); + if (insertButton) { + insertButton.onclick = () => insertComponentById(componentName, selectedVersion); + } + + const detailsButton = cardControl(componentName, projectId, 'details'); + if (detailsButton) { + detailsButton.onclick = () => viewDetailsById(componentName, selectedVersion); + } +} + +function refreshComponents(): void { + vscode.postMessage({ command: 'refreshComponents' }); +} + +function updateCache(): void { + vscode.postMessage({ command: 'updateCache' }); +} + +function resetCache(): void { + vscode.postMessage({ command: 'resetCache' }); +} + +/** Open the details panel for a component at a given version. */ +function viewDetailsById(componentName: string, version: string): void { + const versionData = findVersion(componentName, version); + if (versionData) { + // Send the raw component data; the server computes the template-file URL when it renders the details panel. + const component = { ...versionData, name: componentName, version }; + vscode.postMessage({ command: 'viewComponentDetails', component }); + } +} + +/** Insert a component at a given version into the active editor. */ +function insertComponentById(componentName: string, version: string): void { + const versionData = findVersion(componentName, version); + if (versionData) { + vscode.postMessage({ + command: 'insertComponent', + component: { + name: componentName, + sourcePath: versionData.sourcePath, + version, + gitlabInstance: versionData.gitlabInstance || 'gitlab.com', + }, + }); + } +} + +/** + * Filter the tree to components matching the search box, hiding projects and sources left with no matches and + * expanding those that still have some. + */ +function filterComponents(): void { + const search = document.getElementById('search'); + const searchText = search instanceof HTMLInputElement ? search.value.toLowerCase() : ''; + const cards = document.getElementsByClassName('component-card'); + + const visibleProjects = new Set(); + const visibleSources = new Set(); + + for (const card of cards) { + if (!(card instanceof HTMLElement)) { + continue; + } + const name = (card.getAttribute('data-name') || '').toLowerCase(); + const description = (card.getAttribute('data-description') || '').toLowerCase(); + + if (name.includes(searchText) || description.includes(searchText)) { + card.style.display = ''; + + const projectContent = card.closest('.project-content'); + const sourceContent = card.closest('.source-content'); + if (projectContent) { + visibleProjects.add(projectContent.id.replace('project-content-', '')); + } + if (sourceContent) { + visibleSources.add(sourceContent.id.replace('source-content-', '')); + } + } else { + card.style.display = 'none'; + } + } + + revealGroups('project-group', '.project-content', 'project-content-', 'project-icon-', visibleProjects, searchText); + revealGroups('source-group', '.source-content', 'source-content-', 'source-icon-', visibleSources, searchText); +} + +/** + * Show the groups that still contain a match and hide the rest, expanding a matching group while a search is active. + * + * @param groupClass Class on the group wrapper. + * @param contentSelect Selector for the group's collapsible content. + * @param idPrefix Prefix stripped from the content element's id to recover the group id. + * @param iconPrefix Prefix of the group's disclosure-arrow element id. + * @param visible Ids of the groups holding at least one match. + * @param searchText The current query; empty restores every group. + */ +function revealGroups( + groupClass: string, + contentSelect: string, + idPrefix: string, + iconPrefix: string, + visible: Set, + searchText: string +): void { + for (const group of document.getElementsByClassName(groupClass)) { + if (!(group instanceof HTMLElement)) { + continue; + } + const content = group.querySelector(contentSelect); + if (!(content instanceof HTMLElement)) { + continue; + } + + const id = content.id.replace(idPrefix, ''); + const hasMatch = visible.has(id); + + if (hasMatch || searchText === '') { + group.style.display = ''; + if (searchText !== '' && hasMatch) { + content.style.display = 'block'; + const icon = document.getElementById(iconPrefix + id); + if (icon) { + icon.textContent = '▼'; + } + } + } else { + group.style.display = 'none'; + } + } +} + +function showContextMenu(event: MouseEvent, componentName: string, version: string, projectId: string): void { + event.preventDefault(); + event.stopPropagation(); + + const contextMenu = document.getElementById('contextMenu'); + if (!contextMenu) { + return; + } + + contextMenuData = { componentName, version, projectId }; + contextMenu.style.display = 'block'; + contextMenu.style.left = `${event.pageX}px`; + contextMenu.style.top = `${event.pageY}px`; +} + +function hideContextMenu(): void { + const contextMenu = document.getElementById('contextMenu'); + if (contextMenu) { + contextMenu.style.display = 'none'; + } + contextMenuData = null; +} + +function setAsDefaultVersion(): void { + if (contextMenuData) { + vscode.postMessage({ + command: 'setDefaultVersion', + componentName: contextMenuData.componentName, + version: contextMenuData.version, + projectId: contextMenuData.projectId, + }); + } + hideContextMenu(); +} + +function alwaysUseLatest(): void { + if (contextMenuData) { + vscode.postMessage({ + command: 'setAlwaysUseLatest', + componentName: contextMenuData.componentName, + projectId: contextMenuData.projectId, + }); + } + hideContextMenu(); +} + +document.addEventListener('click', event => { + if (event.target instanceof Element && !event.target.closest('.context-menu')) { + hideContextMenu(); + } +}); + +window.addEventListener('message', event => { + const message = event.data; + if (message.command !== 'versionFetched') { + return; + } + + storeVersion(message.componentName, message.version, message.component); + + const projectId = findProjectIdForComponent(message.componentName); + if (projectId) { + updateComponentVersion(message.componentName, message.version, projectId); + } +}); + +/** Find which project a component card belongs to, for messages that carry only a component name. */ +function findProjectIdForComponent(componentName: string): string | null { + const componentCard = document.querySelector(`[data-component-name="${CSS.escape(componentName)}"]`); + return componentCard ? componentCard.getAttribute('data-project-id') : null; +} + +// The document's `onclick`/`onchange` attributes resolve against the global scope, which this bundle's IIFE is not. +Object.assign(window, { + alwaysUseLatest, + filterComponents, + insertComponentById, + loadComponentVersions, + refreshComponents, + resetCache, + setAsDefaultVersion, + showContextMenu, + toggleError, + toggleProject, + toggleSource, + updateCache, + updateComponentVersion, + updateToken, + viewDetailsById, +}); + +export {}; diff --git a/src/webview/clientInlineMarkdown.ts b/src/webview/clientInlineMarkdown.ts deleted file mode 100644 index f35e4b3b..00000000 --- a/src/webview/clientInlineMarkdown.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Client-side twin of {@link import('./inlineMarkdown').renderInlineMarkdown}, as source text. - * - * The webviews render descriptions in the browser, where they cannot import the server module, so the function is - * injected into each ` & 'q'`), - '<script>alert("x")</script> & 'q'', - ); - }); - - test('matches the server renderer across representative descriptions', () => { - const render = loadClientRenderer(); - const samples = [ - 'CI Job template to deploy a service to an ECS cluster', - 'A [GitLab CI/CD component](https://example.com/c) that lints Dockerfiles using [hadolint](https://example.com/h)', - 'Installs the `yu-ci-tools` binary CLI', - '**Bold** lead-in, *emphasis*, and a `code` span', - 'Ampersands & and "quotes"', - '', - ]; - - for (const sample of samples) { - assert.equal(render(sample), renderInlineMarkdown(sample), `mismatch for: ${sample}`); - } - }); -}); diff --git a/tests/unit/componentBrowserInjection.test.ts b/tests/unit/componentBrowserInjection.test.ts new file mode 100644 index 00000000..9d2d0a48 --- /dev/null +++ b/tests/unit/componentBrowserInjection.test.ts @@ -0,0 +1,84 @@ +// @mocha +/** + * Guards against publisher-controlled text being executed in the Component Browser. + * + * Component names, version tags and spec fields are set by whoever publishes the component, and git accepts quotes and + * angle brackets in a tag name. The Component Browser's controls still bind with `onclick` attributes, so every value + * that reaches one must be encoded for both of the contexts it passes through. + */ + +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { handlerArg } from '../../src/webview/inlineMarkdown'; + +/** What the browser does to an attribute value before the handler's JavaScript sees it. */ +function decodeAttribute(value: string): string { + return value + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + +/** Each of these is a legal git tag name (`git check-ref-format`), so a publisher can ship any of them. */ +const HOSTILE = [ + `x'-alert(1)-'`, + `x"-alert(1)-"`, + ``, + `x\\'-alert(1)-\\'`, + `x");alert(1);("`, + `");alert(1);("`, + ``, +]; + +suite('handlerArg', () => { + test('never leaves a raw quote or bracket in the attribute text', () => { + for (const value of HOSTILE) { + assert.doesNotMatch(handlerArg(value), /["'<>]/, `raw metacharacter for ${JSON.stringify(value)}`); + } + }); + + test('decodes to exactly one string literal equal to the input', () => { + // JSON string syntax is a subset of JavaScript string-literal syntax, so if the decoded text parses as a JSON + // string equal to the input, the handler receives that value and nothing else runs. + for (const value of [...HOSTILE, 'my-component', 'v1.2.3', '']) { + assert.equal(JSON.parse(decodeAttribute(handlerArg(value))), value); + } + }); + + test('treats undefined as an empty string', () => { + assert.equal(JSON.parse(decodeAttribute(handlerArg(undefined))), ''); + }); +}); + +suite('Component Browser client script', () => { + const client = fs.readFileSync( + path.resolve(__dirname, '..', '..', 'src', 'webview', 'client', 'componentBrowser.ts'), + 'utf8', + ); + + test('builds markup only through the escaping renderer', () => { + // Every other assignment of HTML here interpolated a version or name. The markdown renderer escapes first. + const assignments = client.match(/\.(innerHTML|outerHTML)\s*=.*$/gm) ?? []; + const unsafe = assignments.filter(line => !/=\s*renderInlineMarkdown\(/.test(line)); + assert.deepEqual(unsafe, []); + assert.doesNotMatch(client, /insertAdjacentHTML/); + }); + + test('never writes handler source text', () => { + // `setAttribute('onclick', `…${version}…`)` puts the version inside JavaScript, which a quote breaks out of. + assert.doesNotMatch(client, /setAttribute\(\s*['"`]on[a-z]+/); + }); + + test('keys publisher-controlled names only into Maps', () => { + // `__proto__` is a legal file name and git tag. On a plain object, `data[name][version] = …` with either set to it + // writes through to Object.prototype; a Map has no such keys, so the class of bug is gone rather than filtered. + assert.match(client, /const versionStore = readVersionData\(\)/); + assert.match(client, /function readVersionData\(\): Map>/); + assert.doesNotMatch(client, /componentVersionData/, 'the old object-keyed store must not come back'); + const code = client.split('\n').filter(line => !/^\s*(\*|\/\/)/.test(line)).join('\n'); + assert.doesNotMatch(code, /\w+\[[^\]\n]+\]\[[^\]\n]+\]\s*=(?!=)/, 'no two-level bracket writes'); + }); +}); From b3ee8e37186fe8fbb77a3b0942f31ec204034d54 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 25 Sep 2026 16:20:20 +0000 Subject: [PATCH 41/54] chore(release): 0.17.16 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index fd47bee5..7bbdef49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.17.15", + "version": "0.17.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.17.15", + "version": "0.17.16", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.2.2", diff --git a/package.json b/package.json index 359d13ed..4b3acdc2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gitlab-component-helper", "displayName": "GitLab Component Helper", "description": "Provides intellisense for GitLab CI components", - "version": "0.17.15", + "version": "0.17.16", "icon": "images/icon.png", "engines": { "node": ">=22.0.0", From b1f50aab0db58c8337b9dbbef48ac1f0407d87d6 Mon Sep 17 00:00:00 2001 From: eFAILution <128437814+eFAILution@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:24:30 -0400 Subject: [PATCH 42/54] test(webview): fail the build when a builder emits inline code (#310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(webview): fail the build when a builder emits inline code Script, style and event handlers written inside a TypeScript template literal are invisible to tsc, eslint, stylelint and CodeQL. That is how the details-panel link flaw (#309) and the duplicate `class` in the fatal-error view (#308) both shipped. #288 has been moving each view's inline code into linted files under src/webview; this keeps it from coming back. Every `get…Html` builder is checked for an inline ` - + + `; @@ -1148,11 +1150,6 @@ export class ComponentBrowserProvider { return escapeHtml(value); } - /** See {@link handlerArg}: a JS string argument, safe inside an event-handler attribute. */ - private jsArg(value: string | undefined): string { - return handlerArg(value); - } - private renderInlineMarkdown(value: string): string { return renderInlineMarkdown(value); } diff --git a/src/webview/client/componentBrowser.ts b/src/webview/client/componentBrowser.ts index d370c1f1..c4b04fd5 100644 --- a/src/webview/client/componentBrowser.ts +++ b/src/webview/client/componentBrowser.ts @@ -1,10 +1,9 @@ /** * Client script for the Component Browser. * - * Runs in the webview, not the extension host. The document still binds its controls with `onclick`/`onchange` - * attributes, which resolve against the global scope, so the functions they name are assigned to `window` at the end - * of this file — the bundle is an IIFE and would otherwise keep them private. Those attributes, and this export - * block with them, go when the browser moves to delegated listeners under a CSP. + * Runs in the webview, not the extension host, under a nonce CSP that forbids inline handlers. Controls carry a + * `data-action` naming an entry in `ACTIONS`; one delegated listener per event type dispatches them, so controls this + * script builds at runtime need no binding of their own. */ import { renderInlineMarkdown } from '../inlineMarkdown'; @@ -90,30 +89,22 @@ function findVersion(componentName: string, version: string): VersionEntry | und return versionStore.get(componentName)?.get(version); } -/** A card's control, found by the `data-role` both the server and this script put on it. */ -function cardControl(componentName: string, projectId: string, role: string): HTMLElement | null { - return document.querySelector( - `[data-component-name="${CSS.escape(componentName)}"][data-project-id="${CSS.escape(projectId)}"] [data-role="${role}"]` - ); -} - /** - * Build the Details and Insert buttons for a component at a version. + * Build the Details and Insert buttons for a component. * - * Built as elements with listeners rather than as markup with `onclick` text: the component name and version are set - * by whoever publishes the component, and git accepts quotes and angle brackets in a tag name, so interpolating them - * into HTML or into a handler's JavaScript lets a tag run code in this panel. + * They carry a `data-action` rather than a listener, so each click resolves the component and version from the + * enclosing card — the version the dropdown currently shows, not the one that was selected when they were built. */ -function actionButtons(componentName: string, version: string): HTMLButtonElement[] { +function actionButtons(): HTMLButtonElement[] { const details = document.createElement('button'); details.textContent = 'Details'; details.dataset.role = 'details'; - details.onclick = () => viewDetailsById(componentName, version); + details.dataset.action = 'viewDetails'; const insert = document.createElement('button'); insert.textContent = 'Insert'; insert.dataset.role = 'insert'; - insert.onclick = () => insertComponentById(componentName, version); + insert.dataset.action = 'insertComponent'; return [details, insert]; } @@ -126,12 +117,7 @@ function smallText(text: string): HTMLElement { } function toggleError(errorId: string): void { - const errorDiv = document.getElementById(errorId); - if (!errorDiv) { - return; - } - const hidden = errorDiv.style.display === 'none' || errorDiv.style.display === ''; - errorDiv.style.display = hidden ? 'block' : 'none'; + document.getElementById(errorId)?.classList.toggle('is-hidden'); } function updateToken(): void { @@ -161,15 +147,14 @@ function toggleDisclosure(contentId: string, iconId: string): void { return; } - const collapsed = content.style.display === 'none'; - content.style.display = collapsed ? 'block' : 'none'; - icon.textContent = collapsed ? '▼' : '▶'; + const expanded = content.classList.toggle('is-hidden') === false; + icon.textContent = expanded ? '▼' : '▶'; } /** * Ask the extension for a component's versions, showing the card's loading state until they arrive. * - * @param _projectId Unused, but part of the signature the document's `onclick` attributes call with. + * @param _projectId Unused. Kept so callers can pass a card's four identifying fields positionally. */ function loadComponentVersions( componentName: string, @@ -180,12 +165,8 @@ function loadComponentVersions( const componentKey = `${componentName}-${sourcePath}`; const loadingElement = document.getElementById(`loading-${componentKey}`); const loadButton = document.getElementById(`component-${componentKey}`)?.querySelector('.load-versions-btn'); - if (loadingElement) { - loadingElement.style.display = 'inline'; - } - if (loadButton) { - loadButton.style.display = 'none'; - } + loadingElement?.classList.remove('is-hidden'); + loadButton?.classList.add('is-hidden'); vscode.postMessage({ command: 'fetchVersions', componentName, sourcePath, gitlabInstance }); } @@ -233,8 +214,7 @@ function handleVersionsLoaded(message: { const labels = message.versionLabels || {}; const select = document.createElement('select'); select.className = 'version-dropdown'; - select.onchange = () => updateComponentVersion(componentName, select.value, projectId); - select.oncontextmenu = event => showContextMenu(event, componentName, select.value, projectId); + select.dataset.action = 'selectVersion'; versions.forEach(v => { const option = document.createElement('option'); option.value = v; @@ -244,7 +224,7 @@ function handleVersionsLoaded(message: { } select.appendChild(option); }); - actionsDiv.replaceChildren(select, ...actionButtons(componentName, defaultVersion)); + actionsDiv.replaceChildren(select, ...actionButtons()); const descElement = document.getElementById(`desc-${componentName}-${projectId}`); if (descElement && !document.getElementById(`version-info-${componentName}-${projectId}`)) { @@ -259,7 +239,9 @@ function handleVersionsLoaded(message: { const label = document.createElement('span'); label.className = 'single-version'; label.textContent = singleVersion; - actionsDiv.replaceChildren(label, ...actionButtons(componentName, singleVersion)); + // No dropdown to read from, so the card's `data-version` is what the buttons resolve against. + componentCard.dataset.version = singleVersion; + actionsDiv.replaceChildren(label, ...actionButtons()); } } @@ -280,9 +262,7 @@ function handleVersionsError(message: { }): void { const componentKey = `${message.componentName}-${message.sourcePath}`; const loadingElement = document.getElementById(`loading-${componentKey}`); - if (loadingElement) { - loadingElement.style.display = 'none'; - } + loadingElement?.classList.add('is-hidden'); const actionsDiv = document.getElementById(`actions-${componentKey}`); if (actionsDiv) { @@ -293,9 +273,7 @@ function handleVersionsError(message: { const retry = document.createElement('button'); retry.className = 'load-versions-btn'; retry.textContent = 'Retry'; - retry.onclick = () => loadComponentVersions( - message.componentName, message.sourcePath, message.gitlabInstance || 'gitlab.com', '' - ); + retry.dataset.action = 'loadVersions'; actionsDiv.replaceChildren(failure, retry); } @@ -345,17 +323,6 @@ function updateComponentVersion(componentName: string, selectedVersion: string, versionInfoElement.replaceChildren(smallText(`Selected version: ${selectedVersion}`)); } - // Repoint the buttons with listeners. Rewriting their `onclick` text would put the version string inside - // JavaScript source, which a tag containing a quote breaks out of. - const insertButton = cardControl(componentName, projectId, 'insert'); - if (insertButton) { - insertButton.onclick = () => insertComponentById(componentName, selectedVersion); - } - - const detailsButton = cardControl(componentName, projectId, 'details'); - if (detailsButton) { - detailsButton.onclick = () => viewDetailsById(componentName, selectedVersion); - } } function refreshComponents(): void { @@ -468,7 +435,7 @@ function revealGroups( if (hasMatch || searchText === '') { group.style.display = ''; if (searchText !== '' && hasMatch) { - content.style.display = 'block'; + content.classList.remove('is-hidden'); const icon = document.getElementById(iconPrefix + id); if (icon) { icon.textContent = '▼'; @@ -565,23 +532,133 @@ function findProjectIdForComponent(componentName: string): string | null { return componentCard ? componentCard.getAttribute('data-project-id') : null; } -// The document's `onclick`/`onchange` attributes resolve against the global scope, which this bundle's IIFE is not. -Object.assign(window, { - alwaysUseLatest, - filterComponents, - insertComponentById, - loadComponentVersions, - refreshComponents, - resetCache, - setAsDefaultVersion, - showContextMenu, - toggleError, - toggleProject, - toggleSource, - updateCache, - updateComponentVersion, - updateToken, - viewDetailsById, +/** The component card a control sits in, and the coordinates the card carries. */ +interface CardContext { + componentName: string; + projectId: string; + sourcePath: string; + gitlabInstance: string; + /** The version the card's actions apply to: what the dropdown shows, or the card's default without one. */ + version: string; +} + +/** + * Read the coordinates of the card a control belongs to. + * + * The version comes from the dropdown when the user has one to choose from, and otherwise from the card's + * `data-version` — which carries the component's default, not merely its first tag. + * + * @param control An element inside a `.component-card`. + * @returns The card's context, or null when the control is not in a card. + */ +function cardContext(control: HTMLElement): CardContext | null { + const card = control.closest('.component-card'); + if (!card) { + return null; + } + + const select = card.querySelector('select.version-dropdown'); + const chosen = select instanceof HTMLSelectElement ? select.value : ''; + return { + componentName: card.dataset.componentName ?? '', + projectId: card.dataset.projectId ?? '', + sourcePath: card.dataset.sourcePath ?? '', + gitlabInstance: card.dataset.gitlabInstance ?? '', + version: chosen || card.dataset.version || '', + }; +} + +/** The event each action answers to. A `