From 625f2fff5b895550f803c391aa7d3da467a9b928 Mon Sep 17 00:00:00 2001 From: Simon Heather <32168619+X-Guardian@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:23:44 +0100 Subject: [PATCH 01/24] fix: Duplicate include line scoping (#176) Co-authored-by: Simon Heather --- src/providers/validationProvider.ts | 163 ++++++++++++------ src/utils/includeMatcher.ts | 96 +++++++++++ .../suite/duplicateInclude.test.ts | 107 ++++++++++++ tests/extension-host/suite/flowInputs.test.ts | 61 +++++++ .../fixtures/duplicate-include/.gitlab-ci.yml | 13 ++ .../duplicate-include/templates/deploy.yml | 16 ++ tests/fixtures/flow-inputs/.gitlab-ci.yml | 13 ++ .../fixtures/flow-inputs/templates/deploy.yml | 9 + tests/unit/includeMatcher.test.ts | 98 +++++++++++ 9 files changed, 524 insertions(+), 52 deletions(-) create mode 100644 src/utils/includeMatcher.ts create mode 100644 tests/extension-host/suite/duplicateInclude.test.ts create mode 100644 tests/extension-host/suite/flowInputs.test.ts create mode 100644 tests/fixtures/duplicate-include/.gitlab-ci.yml create mode 100644 tests/fixtures/duplicate-include/templates/deploy.yml create mode 100644 tests/fixtures/flow-inputs/.gitlab-ci.yml create mode 100644 tests/fixtures/flow-inputs/templates/deploy.yml create mode 100644 tests/unit/includeMatcher.test.ts diff --git a/src/providers/validationProvider.ts b/src/providers/validationProvider.ts index 16e68da9..defa4557 100644 --- a/src/providers/validationProvider.ts +++ b/src/providers/validationProvider.ts @@ -11,25 +11,14 @@ import { resolveLocalComponent, isUnsupportedLocalPath } from './localComponentR import { attachDiagnosticMetadata, readDiagnosticMetadata } from './validationMetadata'; import type { MissingRequiredInputMetadata } from './validationMetadata'; import type { GitApi, GitRepository } from '../types/vscode-git'; - -/** - * A `.gitlab-ci.yml` include entry that this provider validates. Either a remote `component:` URL - * or a `local:` path; both branches carry an optional `inputs` mapping. Built by narrowing a parsed - * YAML node — fields outside the union (anything else under the include) are intentionally not modelled. - */ -type ComponentInclude = { component: string; local?: undefined; inputs?: Record }; -type LocalInclude = { local: string; component?: undefined; inputs?: Record }; -type IncludeEntry = ComponentInclude | LocalInclude; - -/** Narrow a parsed YAML value to an {@link IncludeEntry} (string-typed `component` or `local`). */ -function isIncludeEntry(value: unknown): value is IncludeEntry { - if (!isYamlNode(value)) { - return false; - } - const hasComponent = typeof value.component === 'string'; - const hasLocal = typeof value.local === 'string'; - return hasComponent !== hasLocal; // exactly one of the two -} +import { + type IncludeEntry, + type LocalInclude, + isIncludeEntry, + isLocalInclude, + includeKeyAndUrl, + includeLineMatches, +} from '../utils/includeMatcher'; export class ValidationProvider implements vscode.CodeActionProvider { private diagnosticCollection: vscode.DiagnosticCollection; @@ -137,8 +126,8 @@ export class ValidationProvider implements vscode.CodeActionProvider { for (let includeIndex = 0; includeIndex < includes.length; includeIndex++) { const include = includes[includeIndex]; - if (include.local && !include.component) { - await this.validateLocalInclude(document, include, diagnostics, diagnosticKeys); + if (isLocalInclude(include)) { + await this.validateLocalInclude(document, include, includes, includeIndex, diagnostics, diagnosticKeys); continue; } if (include.component) { @@ -174,7 +163,7 @@ export class ValidationProvider implements vscode.CodeActionProvider { let diagnosticMessage = `Component URL contains unresolved GitLab variables: '${componentUrl}'. `; diagnosticMessage += `This project is hosted on ${nonGitlabInfo.hostname}, not GitLab. GitLab Component Helper requires a GitLab repository to resolve CI/CD variables like $CI_SERVER_FQDN and $CI_PROJECT_PATH.`; - const line = this.findLineForComponent(document, include); + const line = this.findLineForComponent(document, includes, includeIndex); const range = new vscode.Range(line, 0, line, document.lineAt(line).text.length); const diagnostic = new vscode.Diagnostic( @@ -208,7 +197,7 @@ export class ValidationProvider implements vscode.CodeActionProvider { if (containsGitLabVariables(expandedUrl) || expandedUrl.includes('undefined') || expandedUrl === componentUrl) { this.logger.debug(`[ValidationProvider] URL expansion failed or incomplete: ${expandedUrl}`, 'ValidationProvider'); - const line = this.findLineForComponent(document, include); + const line = this.findLineForComponent(document, includes, includeIndex); const range = new vscode.Range(line, 0, line, document.lineAt(line).text.length); const diagnostic = new vscode.Diagnostic( @@ -286,7 +275,7 @@ export class ValidationProvider implements vscode.CodeActionProvider { // If component fetch failed, add a single diagnostic about the fetch failure // instead of showing "unknown input" warnings for all inputs if (componentFetchFailed) { - const line = this.findLineForComponent(document, include); + const line = this.findLineForComponent(document, includes, includeIndex); const range = new vscode.Range(line, 0, line, document.lineAt(line).text.length); const diagnostic = new vscode.Diagnostic( @@ -344,7 +333,7 @@ export class ValidationProvider implements vscode.CodeActionProvider { for (const providedInput in providedInputs) { if (!componentInputs.some(p => p.name === providedInput)) { - const line = this.findLineForInput(document, include, providedInput); + const line = this.findLineForInput(document, includes, includeIndex, providedInput); // Create a unique key to prevent duplicate diagnostics for the same input const diagnosticKey = `unknown-input-${line}-${providedInput}-${componentUrl}`; @@ -398,7 +387,7 @@ export class ValidationProvider implements vscode.CodeActionProvider { for (const componentInput of componentInputs) { if (componentInput.required && !Object.prototype.hasOwnProperty.call(providedInputs, componentInput.name)) { - const line = this.findLineForComponent(document, include); + const line = this.findLineForComponent(document, includes, includeIndex); const range = new vscode.Range(line, 0, line, document.lineAt(line).text.length); const diagnostic = new vscode.Diagnostic( range, @@ -826,22 +815,31 @@ export class ValidationProvider implements vscode.CodeActionProvider { return actions; } - private findLineForInput(document: vscode.TextDocument, include: IncludeEntry, inputName: string): number { - const text = document.getText(); - const lines = text.split('\n'); - const componentLine = this.findLineForComponent(document, include); - - this.logger.debug(`[ValidationProvider] Finding line for input '${inputName}', component line: ${componentLine}`, 'ValidationProvider'); - - for (let i = componentLine + 1; i < lines.length; i++) { + private findLineForInput( + document: vscode.TextDocument, + includes: IncludeEntry[], + includeIndex: number, + inputName: string + ): number { + const lines = document.getText().split('\n'); + const componentLine = this.findLineForComponent(document, includes, includeIndex); + const nextIncludeLine = this.findLineForNextInclude(document, includes, includeIndex, componentLine); + + this.logger.debug(`[ValidationProvider] Finding line for input '${inputName}', component line: ${componentLine}, bounded by next include at ${nextIncludeLine}`, 'ValidationProvider'); + + // Two bounds keep the scan inside this include's own inputs block: + // - nextIncludeLine caps it before a sibling that may share input names (two includes of one component). + // - the indent break ends it at the first non-indented line, so it can't bleed into a top-level section + // (a job, `variables:`, etc.) below the last include, where nextIncludeLine is just end-of-file. + for (let i = componentLine + 1; i < nextIncludeLine; i++) { if (lines[i].includes('inputs:')) { this.logger.debug(`[ValidationProvider] Found inputs section at line ${i}`, 'ValidationProvider'); - for (let j = i + 1; j < lines.length; j++) { + for (let j = i + 1; j < nextIncludeLine; j++) { if (lines[j].includes(`${inputName}:`)) { this.logger.debug(`[ValidationProvider] Found input '${inputName}' at line ${j}`, 'ValidationProvider'); return j; } - if (!lines[j].match(/^\s+/)) { + if (!/^\s/.test(lines[j]) && lines[j].trim() !== '') { break; } } @@ -860,8 +858,10 @@ export class ValidationProvider implements vscode.CodeActionProvider { * * @param document The document being validated — used to read line text for diagnostic ranges and to * anchor workspace-relative path resolution. - * @param include The parsed YAML include node, narrowed to the local-include shape: `local` is the - * workspace-relative path, `inputs` is the optional user-supplied input map. + * @param include This local include, already narrowed to {@link LocalInclude} by the caller. + * @param includes The full parsed include list, in document order. Passed alongside `includeIndex` so the + * line-finders can disambiguate this entry from siblings that share its path. + * @param includeIndex This entry's position in `includes` (i.e. `includes[includeIndex] === include`). * @param diagnostics Accumulator array. New diagnostics are pushed onto it; the caller owns publishing * the final list to the diagnostic collection. * @param diagnosticKeys Dedup set shared across the whole validation pass. Prevents duplicate @@ -871,7 +871,9 @@ export class ValidationProvider implements vscode.CodeActionProvider { */ private async validateLocalInclude( document: vscode.TextDocument, - include: { local: string; inputs?: Record }, + include: LocalInclude, + includes: IncludeEntry[], + includeIndex: number, diagnostics: vscode.Diagnostic[], diagnosticKeys: Set ): Promise { @@ -885,7 +887,7 @@ export class ValidationProvider implements vscode.CodeActionProvider { const component = await resolveLocalComponent(localPath, document); if (!component) { - const line = this.findLineForComponent(document, include); + const line = this.findLineForComponent(document, includes, includeIndex); const range = new vscode.Range(line, 0, line, document.lineAt(line).text.length); const diagnostic = new vscode.Diagnostic( range, @@ -910,7 +912,7 @@ export class ValidationProvider implements vscode.CodeActionProvider { if (componentInputs.some(p => p.name === providedInput)) { continue; } - const line = this.findLineForInput(document, include, providedInput); + const line = this.findLineForInput(document, includes, includeIndex, providedInput); const diagnosticKey = `unknown-input-${line}-${providedInput}-${localPath}`; if (diagnosticKeys.has(diagnosticKey)) continue; diagnosticKeys.add(diagnosticKey); @@ -941,7 +943,7 @@ export class ValidationProvider implements vscode.CodeActionProvider { for (const componentInput of componentInputs) { if (componentInput.required && !Object.prototype.hasOwnProperty.call(providedInputs, componentInput.name)) { - const line = this.findLineForComponent(document, include); + const line = this.findLineForComponent(document, includes, includeIndex); const range = new vscode.Range(line, 0, line, document.lineAt(line).text.length); const diagnostic = new vscode.Diagnostic( range, @@ -964,25 +966,82 @@ export class ValidationProvider implements vscode.CodeActionProvider { } } - private findLineForComponent(document: vscode.TextDocument, include: IncludeEntry): number { - const text = document.getText(); - const lines = text.split('\n'); - const componentUrl = include.component ?? include.local; - const includeKey = include.component ? 'component:' : 'local:'; + /** + * Locate the document line of the include entry at {@link includeIndex}. + * + * Two include entries can share an identical key+URL (e.g. the same component included twice with different + * inputs). Matching on key+URL alone returns the first occurrence for both, mis-anchoring the second entry's + * diagnostics onto the first. To disambiguate, this counts how many earlier entries in the parsed `includes` + * array carry the same key+URL and returns the correspondingly-numbered occurrence in the document — relying + * on the array being built in document order. + * + * @param document The document being validated; its text is scanned line by line for the include. + * @param includes The full parsed include list, in document order. + * @param includeIndex The position in `includes` of the entry to locate. + * @returns The 0-based line number of the matching include declaration, or `0` when no matching line is found + * (e.g. the URL doesn't appear literally on a single line — folded scalar, alias). Callers use the + * returned line as the anchor for a diagnostic range. + */ + private findLineForComponent(document: vscode.TextDocument, includes: IncludeEntry[], includeIndex: number): number { + const { key, url } = includeKeyAndUrl(includes[includeIndex]); + + // The occurrence ordinal: how many entries at-or-before includeIndex share this exact key+URL. + let targetOccurrence = 0; + for (let k = 0; k <= includeIndex; k++) { + const prior = includeKeyAndUrl(includes[k]); + if (prior.key === key && prior.url === url) { + targetOccurrence++; + } + } - this.logger.debug(`[ValidationProvider] Looking for include URL: ${componentUrl}`, 'ValidationProvider'); + this.logger.debug(`[ValidationProvider] Looking for include URL: ${url} (occurrence ${targetOccurrence})`, 'ValidationProvider'); + const lines = document.getText().split('\n'); + let seen = 0; for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (line.includes(includeKey) && line.includes(componentUrl)) { - this.logger.debug(`[ValidationProvider] Found include at line ${i}: ${line.trim()}`, 'ValidationProvider'); - return i; + if (includeLineMatches(lines[i], key, url)) { + seen++; + if (seen === targetOccurrence) { + this.logger.debug(`[ValidationProvider] Found include at line ${i}: ${lines[i].trim()}`, 'ValidationProvider'); + return i; + } } } this.logger.debug(`[ValidationProvider] Include URL not found, returning 0`, 'ValidationProvider'); return 0; } + /** + * The line where the include entry *after* {@link includeIndex} begins, used as an exclusive upper bound when + * scanning an include's inputs. Returns `document.lineCount` when this is the last include, so the scan runs to + * end-of-file. `searchFrom` is the current include's own line — the next entry is found by scanning past it. + * + * @param document The document being validated; its text is scanned line by line for the next include. + * @param includes The full parsed include list, in document order. + * @param includeIndex The position in `includes` of the *current* entry; the next entry is `includeIndex + 1`. + * @param searchFrom The current include's own line; scanning starts at the line after it. + * @returns The 0-based line number where the next include begins, or `document.lineCount` when there is no next + * include or its line can't be found — either way an exclusive upper bound that runs to end-of-file. + */ + private findLineForNextInclude( + document: vscode.TextDocument, + includes: IncludeEntry[], + includeIndex: number, + searchFrom: number + ): number { + if (includeIndex + 1 >= includes.length) { + return document.lineCount; + } + const { key, url } = includeKeyAndUrl(includes[includeIndex + 1]); + const lines = document.getText().split('\n'); + for (let i = searchFrom + 1; i < lines.length; i++) { + if (includeLineMatches(lines[i], key, url)) { + return i; + } + } + return document.lineCount; + } + /** * Find a component in the cache by URL */ diff --git a/src/utils/includeMatcher.ts b/src/utils/includeMatcher.ts new file mode 100644 index 00000000..91709fd1 --- /dev/null +++ b/src/utils/includeMatcher.ts @@ -0,0 +1,96 @@ +import { isYamlNode } from './yamlParser'; + +/** + * A `.gitlab-ci.yml` include entry that the validation provider validates. Either a remote `component:` URL + * or a `local:` path; both branches carry an optional `inputs` mapping. Built by narrowing a parsed YAML node — + * fields outside the union (anything else under the include) are intentionally not modelled. + */ +export type ComponentInclude = { component: string; local?: undefined; inputs?: Record }; +export type LocalInclude = { local: string; component?: undefined; inputs?: Record }; +export type IncludeEntry = ComponentInclude | LocalInclude; + +/** + * Narrow a parsed YAML value to an {@link IncludeEntry} (string-typed `component` or `local`). + * + * @param value A parsed YAML value of unknown shape — typically one element of the `include:` array. + * @returns `true` (narrowing `value` to {@link IncludeEntry}) when `value` is a mapping with exactly one of a + * string `component` or a string `local`; `false` for non-mappings, entries with neither, or entries + * with both (e.g. `project:`/`template:`/`remote:` includes, which this provider does not validate). + */ +export function isIncludeEntry(value: unknown): value is IncludeEntry { + if (!isYamlNode(value)) { + return false; + } + let hasComponent = false; + if (typeof value.component === 'string') { + hasComponent = true; + } + let hasLocal = false; + if (typeof value.local === 'string') { + hasLocal = true; + } + if (hasComponent === hasLocal) { + // Neither key, or both — not a component/local include this provider validates. + return false; + } + return true; +} + +/** + * Narrow an {@link IncludeEntry} to the local-include shape (`local` is a string, `component` is absent). + * + * @param entry An include entry already validated by {@link isIncludeEntry}. + * @returns `true` (narrowing `entry` to {@link LocalInclude}) when `entry.local` is a string; `false` for a + * {@link ComponentInclude}. + */ +export function isLocalInclude(entry: IncludeEntry): entry is LocalInclude { + if (typeof entry.local === 'string') { + return true; + } + return false; +} + +/** + * The YAML key and its URL/path value for an include entry — the two strings needed to locate the entry's line in + * the document. + * + * @param entry An include entry already validated by {@link isIncludeEntry}. + * @returns `{ key, url }` where `key` is the YAML key token (`'component:'` or `'local:'`) and `url` is the + * corresponding remote component URL or local path. + */ +export function includeKeyAndUrl(entry: IncludeEntry): { key: string; url: string } { + if (entry.component !== undefined) { + return { key: 'component:', url: entry.component }; + } + return { key: 'local:', url: entry.local }; +} + +/** + * Whether a document line is the one that declares an include with the given key and URL. The line must contain the + * include key and the URL, and the URL must be terminated at a token boundary — end-of-line, whitespace, or a closing + * quote. The boundary check is what stops a versioned URL from matching a longer sibling: e.g. + * `…/comp@cloud-deploy-aws-ecs-1` must not match the line `…/comp@cloud-deploy-aws-ecs-10`, where it appears only as + * a prefix. A bare substring test would, and that disagrees with the exact-equality occurrence counter the caller + * uses to disambiguate duplicate includes — mis-anchoring the diagnostic onto the wrong include. + * + * @param line A single document line (no trailing newline). + * @param key The include key token to require on the line — `'component:'` or `'local:'`, as returned by + * {@link includeKeyAndUrl}. + * @param url The remote URL or local path that must appear on the line, terminated at a token boundary. + * @returns `true` when `line` contains `key` and `url`, with `url` followed by end-of-line, whitespace, or a + * closing quote; `false` otherwise (including when `url` appears only as a prefix of a longer token). + */ +export function includeLineMatches(line: string, key: string, url: string): boolean { + if (!line.includes(key)) { + return false; + } + const at = line.indexOf(url); + if (at === -1) { + return false; + } + const after = line.charAt(at + url.length); // '' at end-of-line + if (after === '' || /\s/.test(after) || after === '"' || after === "'") { + return true; + } + return false; +} diff --git a/tests/extension-host/suite/duplicateInclude.test.ts b/tests/extension-host/suite/duplicateInclude.test.ts new file mode 100644 index 00000000..be783314 --- /dev/null +++ b/tests/extension-host/suite/duplicateInclude.test.ts @@ -0,0 +1,107 @@ +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', 'duplicate-include'); +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(); +} + +async function waitForDiagnostics(uri: vscode.Uri, timeoutMs = 5000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const diags = vscode.languages.getDiagnostics(uri); + if (diags.length > 0) return diags; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return vscode.languages.getDiagnostics(uri); +} + +/** 0-indexed line of the second `- local:` entry — derived from the document so it survives fixture edits. */ +function secondIncludeLine(doc: vscode.TextDocument): number { + const lines = doc.getText().split('\n'); + let seen = 0; + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes('local:') && lines[i].includes('templates/deploy.yml')) { + seen++; + if (seen === 2) return i; + } + } + throw new Error('fixture missing a second - local: deploy.yml include'); +} + +// The fixture includes the same template twice: the first entry is correct, the second uses a wrong input +// name (`target_cluster` instead of `cluster`). Before the per-entry line scoping fix, the diagnostics for +// the second entry were anchored onto the first entry because the line-finders matched the first occurrence +// of the shared path. These tests pin the diagnostics to the entry they actually belong to. +suite('Duplicate include line scoping', () => { + suiteSetup(ensureActive); + + test('unknown input on the second include is anchored to the second block', async () => { + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(FIXTURE)); + await vscode.window.showTextDocument(doc); + const secondLine = secondIncludeLine(doc); + + const diags = await waitForDiagnostics(doc.uri); + const ourDiags = diags.filter((d) => d.source === 'gitlab-component-helper'); + const unknown = ourDiags.find( + (d) => d.code === 'unknown-input' && /target_cluster/.test(d.message) + ); + assert.ok( + unknown, + `expected an unknown-input diagnostic for target_cluster. Got: ${JSON.stringify(ourDiags.map((d) => ({ code: d.code, msg: d.message, line: d.range.start.line })))}` + ); + assert.ok( + unknown.range.start.line >= secondLine, + `unknown-input for target_cluster should land in the second include block (line >= ${secondLine}), but landed on line ${unknown.range.start.line}` + ); + }); + + test('missing required input on the second include is anchored to the second block', async () => { + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(FIXTURE)); + await vscode.window.showTextDocument(doc); + const secondLine = secondIncludeLine(doc); + + const diags = await waitForDiagnostics(doc.uri); + const ourDiags = diags.filter((d) => d.source === 'gitlab-component-helper'); + + // Only the second include is missing `cluster`; the first supplies it. So there must be exactly one + // missing-required-input diagnostic for `cluster`, and it must sit on the second include line. + const missing = ourDiags.filter( + (d) => d.code === 'missing-required-input' && /cluster/.test(d.message) + ); + assert.strictEqual( + missing.length, + 1, + `expected exactly one missing-required-input for cluster (second include only). Got: ${JSON.stringify(missing.map((d) => ({ msg: d.message, line: d.range.start.line })))}` + ); + assert.strictEqual( + missing[0].range.start.line, + secondLine, + `missing cluster diagnostic should anchor to the second include line (${secondLine}), but landed on line ${missing[0].range.start.line}` + ); + }); + + test('the first include reports no diagnostics', async () => { + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(FIXTURE)); + await vscode.window.showTextDocument(doc); + const secondLine = secondIncludeLine(doc); + + const diags = await waitForDiagnostics(doc.uri); + const firstBlockDiags = diags.filter( + (d) => d.source === 'gitlab-component-helper' && d.range.start.line < secondLine + ); + assert.strictEqual( + firstBlockDiags.length, + 0, + `the first (correct) include should have no diagnostics, but found: ${JSON.stringify(firstBlockDiags.map((d) => ({ code: d.code, msg: d.message, line: d.range.start.line })))}` + ); + }); +}); diff --git a/tests/extension-host/suite/flowInputs.test.ts b/tests/extension-host/suite/flowInputs.test.ts new file mode 100644 index 00000000..d24ac743 --- /dev/null +++ b/tests/extension-host/suite/flowInputs.test.ts @@ -0,0 +1,61 @@ +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', 'flow-inputs'); +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(); +} + +async function waitForDiagnostics(uri: vscode.Uri, timeoutMs = 5000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const diags = vscode.languages.getDiagnostics(uri); + if (diags.length > 0) return diags; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return vscode.languages.getDiagnostics(uri); +} + +/** 0-indexed line of the top-level `deploy:` job that lies below the include. */ +function jobLine(doc: vscode.TextDocument): number { + const lines = doc.getText().split('\n'); + for (let i = 0; i < lines.length; i++) { + if (lines[i] === 'deploy:') return i; + } + throw new Error('fixture missing the top-level deploy: job'); +} + +// The single (last) include uses flow-style `inputs: { ... }`, so its unknown input `bad_input` appears only on +// the `inputs:` line — never on its own line. A top-level job below reuses `bad_input:` under `variables:`. With +// the input scan bounded only by end-of-file, the diagnostic would bleed down onto the job's variable. The +// indent break keeps it inside the include's block. +suite('Flow-style inputs do not bleed into a later top-level section', () => { + suiteSetup(ensureActive); + + test('unknown input in flow-style inputs anchors inside the include, not the job below', async () => { + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(FIXTURE)); + await vscode.window.showTextDocument(doc); + const job = jobLine(doc); + + const diags = await waitForDiagnostics(doc.uri); + const ourDiags = diags.filter((d) => d.source === 'gitlab-component-helper'); + + const badInput = ourDiags.find((d) => d.code === 'unknown-input' && /bad_input/.test(d.message)); + assert.ok( + badInput, + `expected an unknown-input diagnostic for bad_input. Got: ${JSON.stringify(ourDiags.map((d) => ({ code: d.code, msg: d.message, line: d.range.start.line })))}` + ); + assert.ok( + badInput.range.start.line < job, + `bad_input should anchor inside the include block (above the deploy: job at line ${job}), but landed on line ${badInput.range.start.line}` + ); + }); +}); diff --git a/tests/fixtures/duplicate-include/.gitlab-ci.yml b/tests/fixtures/duplicate-include/.gitlab-ci.yml new file mode 100644 index 00000000..71ec9fc7 --- /dev/null +++ b/tests/fixtures/duplicate-include/.gitlab-ci.yml @@ -0,0 +1,13 @@ +include: + # First include of the deploy template — all inputs correct. + - local: "duplicate-include/templates/deploy.yml" + inputs: + job_name: deploy:first + cluster: first-cluster + + # Second include of the SAME template — `target_cluster` is a wrong name + # (the input is `cluster`), and the required `cluster` is therefore missing. + - local: "duplicate-include/templates/deploy.yml" + inputs: + job_name: deploy:second + target_cluster: second-cluster diff --git a/tests/fixtures/duplicate-include/templates/deploy.yml b/tests/fixtures/duplicate-include/templates/deploy.yml new file mode 100644 index 00000000..92572b2d --- /dev/null +++ b/tests/fixtures/duplicate-include/templates/deploy.yml @@ -0,0 +1,16 @@ +spec: + inputs: + job_name: + description: The name of the CI job + type: string + cluster: + description: The target cluster (no default — required) + type: string + region: + description: The deployment region + type: string + default: eu-west-1 +--- +$[[ inputs.job_name ]]: + script: + - echo "deploying to $[[ inputs.cluster ]] in $[[ inputs.region ]]" diff --git a/tests/fixtures/flow-inputs/.gitlab-ci.yml b/tests/fixtures/flow-inputs/.gitlab-ci.yml new file mode 100644 index 00000000..9930f169 --- /dev/null +++ b/tests/fixtures/flow-inputs/.gitlab-ci.yml @@ -0,0 +1,13 @@ +include: + # Last include, flow-style inputs: `bad_input` appears only on the `inputs:` line, + # never on its own line. `bad_input` is unknown (the template only defines job_name). + - local: "flow-inputs/templates/deploy.yml" + inputs: { job_name: deploy, bad_input: oops } + +# A top-level job below the include whose variables reuse the input name. The unknown-input +# diagnostic for `bad_input` must NOT anchor here — the input scan must stop at this dedent. +deploy: + variables: + bad_input: from-job + script: + - echo "$bad_input" diff --git a/tests/fixtures/flow-inputs/templates/deploy.yml b/tests/fixtures/flow-inputs/templates/deploy.yml new file mode 100644 index 00000000..97d45be2 --- /dev/null +++ b/tests/fixtures/flow-inputs/templates/deploy.yml @@ -0,0 +1,9 @@ +spec: + inputs: + job_name: + description: The name of the CI job + type: string +--- +$[[ inputs.job_name ]]: + script: + - echo "deploy" diff --git a/tests/unit/includeMatcher.test.ts b/tests/unit/includeMatcher.test.ts new file mode 100644 index 00000000..a4eefe97 --- /dev/null +++ b/tests/unit/includeMatcher.test.ts @@ -0,0 +1,98 @@ +// @mocha +/** + * includeMatcher tests — the pure include line-matching helpers used by the validation provider to anchor + * diagnostics to the correct include entry. + * + * The critical case is `includeLineMatches` token-boundary matching: a versioned URL (`…@deploy-1`) must not + * match a line declaring a longer sibling (`…@deploy-10`), or the provider's occurrence counter (exact equality) + * disagrees with the document walk (line match) and anchors a diagnostic onto the wrong include. + */ + +import * as assert from 'node:assert/strict'; +import { + isIncludeEntry, + isLocalInclude, + includeKeyAndUrl, + includeLineMatches, +} from '../../src/utils/includeMatcher'; + +suite('includeMatcher — isIncludeEntry', () => { + test('accepts a component include', () => { + assert.equal(isIncludeEntry({ component: 'host/g/c@1' }), true); + }); + + test('accepts a local include', () => { + assert.equal(isIncludeEntry({ local: 'templates/x.yml' }), true); + }); + + test('rejects an entry with both component and local', () => { + assert.equal(isIncludeEntry({ component: 'host/g/c@1', local: 'x.yml' }), false); + }); + + test('rejects an entry with neither', () => { + assert.equal(isIncludeEntry({ project: 'g/p', file: 'x.yml' }), false); + }); + + test('rejects non-objects', () => { + assert.equal(isIncludeEntry('component: x'), false); + assert.equal(isIncludeEntry(null), false); + assert.equal(isIncludeEntry(['component']), false); + }); +}); + +suite('includeMatcher — isLocalInclude / includeKeyAndUrl', () => { + test('isLocalInclude distinguishes local from component', () => { + assert.equal(isLocalInclude({ local: 'x.yml' }), true); + assert.equal(isLocalInclude({ component: 'host/g/c@1' }), false); + }); + + test('includeKeyAndUrl returns component key+url', () => { + assert.deepEqual(includeKeyAndUrl({ component: 'host/g/c@1' }), { + key: 'component:', + url: 'host/g/c@1', + }); + }); + + test('includeKeyAndUrl returns local key+url', () => { + assert.deepEqual(includeKeyAndUrl({ local: 'templates/x.yml' }), { + key: 'local:', + url: 'templates/x.yml', + }); + }); +}); + +suite('includeMatcher — includeLineMatches token boundary', () => { + const url = 'host/g/c@deploy-1'; + + test('matches when the URL is at end of line', () => { + assert.equal(includeLineMatches(` - component: ${url}`, 'component:', url), true); + }); + + test('matches when followed by whitespace (trailing space / comment)', () => { + assert.equal(includeLineMatches(` - component: ${url} # pinned`, 'component:', url), true); + }); + + test('matches when the URL is wrapped in quotes', () => { + assert.equal(includeLineMatches(` - component: "${url}"`, 'component:', url), true); + assert.equal(includeLineMatches(` - component: '${url}'`, 'component:', url), true); + }); + + test('does NOT match a longer sibling where the URL is only a prefix', () => { + // The bug this guards: `@deploy-1` appears as a prefix of `@deploy-10`. + assert.equal(includeLineMatches(' - component: host/g/c@deploy-10', 'component:', url), false); + }); + + test('does NOT match when the include key is absent', () => { + assert.equal(includeLineMatches(` some_input: ${url}`, 'component:', url), false); + }); + + test('does NOT match when the URL is absent', () => { + assert.equal(includeLineMatches(' - component: host/g/other@1', 'component:', url), false); + }); + + test('local-path prefix collision is rejected too', () => { + const localUrl = 'templates/deploy'; + assert.equal(includeLineMatches(' - local: templates/deploy10', 'local:', localUrl), false); + assert.equal(includeLineMatches(' - local: templates/deploy', 'local:', localUrl), true); + }); +}); From 622ff97da16c2e9c82ee5339b16583c4b6cf30ba Mon Sep 17 00:00:00 2001 From: Simon Heather <32168619+X-Guardian@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:24:31 +0100 Subject: [PATCH 02/24] fix: revalidate on edit (#178) Co-authored-by: Simon Heather --- src/providers/validationProvider.ts | 70 +++--------------- .../suite/revalidateOnEdit.test.ts | 73 +++++++++++++++++++ .../revalidate-on-edit/.gitlab-ci.yml | 13 ++++ .../revalidate-on-edit/templates/deploy.yml | 12 +++ 4 files changed, 107 insertions(+), 61 deletions(-) create mode 100644 tests/extension-host/suite/revalidateOnEdit.test.ts create mode 100644 tests/fixtures/revalidate-on-edit/.gitlab-ci.yml create mode 100644 tests/fixtures/revalidate-on-edit/templates/deploy.yml diff --git a/src/providers/validationProvider.ts b/src/providers/validationProvider.ts index defa4557..48bff125 100644 --- a/src/providers/validationProvider.ts +++ b/src/providers/validationProvider.ts @@ -58,8 +58,7 @@ export class ValidationProvider implements vscode.CodeActionProvider { context.subscriptions.push( vscode.workspace.onDidOpenTextDocument(doc => this.validate(doc)), vscode.workspace.onDidChangeTextDocument(e => { - // Only validate if the change affects component inputs - this.validateIfInputsChanged(e); + this.scheduleValidation(e.document); }), vscode.workspace.onDidCloseTextDocument(doc => { const documentId = doc.uri.toString(); @@ -433,35 +432,19 @@ export class ValidationProvider implements vscode.CodeActionProvider { } /** - * Smart validation that only triggers when inputs are actually changed + * Re-validate a document after an edit, throttled to coalesce rapid keystrokes. + * + * Every edit to a GitLab CI file schedules a full pass: diagnostics depend on the whole document (an input edit + * can resolve or raise a component-level error several lines away), so the change must be re-checked as a whole + * rather than judged "relevant" by its surrounding lines. {@link validate} cheaply skips files that aren't GitLab + * CI files or carry no `include`, and the 300ms throttle keeps typing responsive. */ - private validateIfInputsChanged(e: vscode.TextDocumentChangeEvent): void { - const document = e.document; - const documentId = document.uri.toString(); - + private scheduleValidation(document: vscode.TextDocument): void { if (!isGitLabCIFile(document)) { return; } - // Check if the changes affect component inputs - let shouldValidate = false; - for (const change of e.contentChanges) { - const changedText = change.text; - const rangeText = document.getText(change.range); - - // Check if the change is in an inputs section or affects component/include declarations - if (this.isInputRelatedChange(document, change.range, changedText, rangeText)) { - shouldValidate = true; - break; - } - } - - if (!shouldValidate) { - this.logger.debug(`[ValidationProvider] Skipping validation - changes don't affect component inputs`, 'ValidationProvider'); - return; - } - - // Throttle validation to avoid excessive calls + const documentId = document.uri.toString(); const existingTimeout = this.validationTimeouts.get(documentId); if (existingTimeout) { clearTimeout(existingTimeout); @@ -474,41 +457,6 @@ export class ValidationProvider implements vscode.CodeActionProvider { }, 300)); // 300ms delay to allow user to finish typing } - /** - * Check if a change affects component inputs - */ - private isInputRelatedChange(document: vscode.TextDocument, range: vscode.Range, newText: string, oldText: string): boolean { - const startLine = range.start.line; - const endLine = range.end.line; - - // Check lines around the change for component/inputs context - const contextStart = Math.max(0, startLine - 5); - const contextEnd = Math.min(document.lineCount - 1, endLine + 5); - - let hasComponentContext = false; - let hasInputsContext = false; - - for (let i = contextStart; i <= contextEnd; i++) { - const lineText = document.lineAt(i).text; - if (lineText.includes('component:') || lineText.includes('include:')) { - hasComponentContext = true; - } - if (lineText.includes('inputs:')) { - hasInputsContext = true; - } - } - - // If we're in a component context and either: - // 1. We're in an inputs section, or - // 2. The change involves input-like text (contains ':' which is common in YAML key-value pairs) - if (hasComponentContext && (hasInputsContext || newText.includes(':') || oldText.includes(':'))) { - this.logger.debug(`[ValidationProvider] Input-related change detected at line ${startLine}`, 'ValidationProvider'); - return true; - } - - return false; - } - /** * Provide code actions for diagnostics (suggestions for unknown inputs) */ diff --git a/tests/extension-host/suite/revalidateOnEdit.test.ts b/tests/extension-host/suite/revalidateOnEdit.test.ts new file mode 100644 index 00000000..510cbbc0 --- /dev/null +++ b/tests/extension-host/suite/revalidateOnEdit.test.ts @@ -0,0 +1,73 @@ +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', 'revalidate-on-edit'); +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(); +} + +/** Poll until `predicate` holds over the current diagnostics, or the timeout elapses. Returns the last snapshot. */ +async function waitForDiagnostics( + uri: vscode.Uri, + predicate: (diags: vscode.Diagnostic[]) => boolean, + timeoutMs = 5000 +): Promise { + const start = Date.now(); + let diags = vscode.languages.getDiagnostics(uri); + while (Date.now() - start < timeoutMs) { + diags = vscode.languages.getDiagnostics(uri); + if (predicate(diags)) return diags; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return diags; +} + +const ours = (diags: vscode.Diagnostic[]) => diags.filter((d) => d.source === 'gitlab-component-helper'); + +// The edited input sits >5 lines below the include line in the fixture. A proximity-gated change detector skipped +// re-validation for such edits, so the diagnostics went stale. Validation now runs on every edit to a CI file. +suite('Re-validation on edit', () => { + suiteSetup(ensureActive); + + test('renaming a valid input to an unknown one updates diagnostics', async () => { + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(FIXTURE)); + const editor = await vscode.window.showTextDocument(doc); + + // The fixture is valid on open: no unknown-input diagnostics. + const clean = await waitForDiagnostics( + doc.uri, + (d) => !ours(d).some((x) => x.code === 'unknown-input') + ); + assert.ok( + !ours(clean).some((x) => x.code === 'unknown-input'), + `fixture should open clean. Got: ${JSON.stringify(ours(clean).map((d) => ({ code: d.code, msg: d.message })))}` + ); + + // Rename `target_input` -> `target_inputX` (now an unknown input) far below the include line. + const text = doc.getText(); + const offset = text.indexOf('target_input:'); + assert.ok(offset > 0, 'fixture missing target_input'); + const namePos = doc.positionAt(offset + 'target_input'.length); + await editor.edit((b) => b.insert(namePos, 'X')); + + const after = await waitForDiagnostics( + doc.uri, + (d) => ours(d).some((x) => x.code === 'unknown-input' && /target_inputX/.test(x.message)) + ); + const unknown = ours(after).find( + (x) => x.code === 'unknown-input' && /target_inputX/.test(x.message) + ); + assert.ok( + unknown, + `expected an unknown-input diagnostic for target_inputX after the edit. Got: ${JSON.stringify(ours(after).map((d) => ({ code: d.code, msg: d.message, line: d.range.start.line })))}` + ); + }); +}); diff --git a/tests/fixtures/revalidate-on-edit/.gitlab-ci.yml b/tests/fixtures/revalidate-on-edit/.gitlab-ci.yml new file mode 100644 index 00000000..ecc51aeb --- /dev/null +++ b/tests/fixtures/revalidate-on-edit/.gitlab-ci.yml @@ -0,0 +1,13 @@ +include: + # The edited input below sits well more than 5 lines under this `local:` line, so a + # proximity-gated change detector would deem an edit to it "unrelated" and skip + # re-validation — leaving stale diagnostics. The test edits `target_input` and asserts + # the diagnostics track the current text. + - local: "revalidate-on-edit/templates/deploy.yml" + inputs: + a_input: a + b_input: b + c_input: c + d_input: d + e_input: e + target_input: ok diff --git a/tests/fixtures/revalidate-on-edit/templates/deploy.yml b/tests/fixtures/revalidate-on-edit/templates/deploy.yml new file mode 100644 index 00000000..d68c8e4d --- /dev/null +++ b/tests/fixtures/revalidate-on-edit/templates/deploy.yml @@ -0,0 +1,12 @@ +spec: + inputs: + a_input: { type: string } + b_input: { type: string } + c_input: { type: string } + d_input: { type: string } + e_input: { type: string } + target_input: { type: string } +--- +job: + script: + - echo "$[[ inputs.target_input ]]" From ea531b16ef8984adbc0cdd8126b37b9058285b41 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 17 Jun 2026 21:27:04 +0000 Subject: [PATCH 03/24] chore(release): 0.13.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 0f887d01..36daf3c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.12.2", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.12.2", + "version": "0.13.0", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.0.2", diff --git a/package.json b/package.json index 5a9c0bc5..739fe413 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.12.2", + "version": "0.13.0", "icon": "images/icon.png", "engines": { "node": ">=22.0.0", From fc0ec6a8bf0683430fbd9194c6ac23b41a86a41b Mon Sep 17 00:00:00 2001 From: Simon Heather <32168619+X-Guardian@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:35:48 +0100 Subject: [PATCH 04/24] fix: diff diagnostics (#180) Co-authored-by: Simon Heather --- src/providers/validationProvider.ts | 21 ++++- .../suite/diffSourcePanel.test.ts | 77 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 tests/extension-host/suite/diffSourcePanel.test.ts diff --git a/src/providers/validationProvider.ts b/src/providers/validationProvider.ts index 48bff125..d12fb634 100644 --- a/src/providers/validationProvider.ts +++ b/src/providers/validationProvider.ts @@ -92,9 +92,28 @@ export class ValidationProvider implements vscode.CodeActionProvider { }); } + /** + * Only the working-tree copy of a file should carry diagnostics. In a diff view the source (left) panel is a + * read-only document under a VCS scheme (`git`, `gitlens`, `pr`, …) sharing the same `fsPath` as the working-tree + * document, so validating both makes the squiggles appear on both panels. Restricting to the `file` scheme keeps + * diagnostics on the editable (right) panel only. + * + * @param document - The text document a provider is about to validate. + * @returns `true` if the document is the editable working-tree copy (`file` scheme); `false` for diff-source and + * other non-`file` documents that should not receive diagnostics. + */ + private isDiagnosableDocument(document: vscode.TextDocument): boolean { + return document.uri.scheme === 'file'; + } + private async validate(document: vscode.TextDocument) { this.logger.debug(`[ValidationProvider] validate() called for: ${document.fileName}, languageId: ${document.languageId}`, 'ValidationProvider'); + if (!this.isDiagnosableDocument(document)) { + this.logger.debug(`[ValidationProvider] Skipping validation - non-file URI scheme: ${document.uri.scheme}`, 'ValidationProvider'); + return; + } + if (!isGitLabCIFile(document)) { this.logger.debug(`[ValidationProvider] Skipping validation - not a supported file type`, 'ValidationProvider'); return; @@ -440,7 +459,7 @@ export class ValidationProvider implements vscode.CodeActionProvider { * CI files or carry no `include`, and the 300ms throttle keeps typing responsive. */ private scheduleValidation(document: vscode.TextDocument): void { - if (!isGitLabCIFile(document)) { + if (!this.isDiagnosableDocument(document) || !isGitLabCIFile(document)) { return; } diff --git a/tests/extension-host/suite/diffSourcePanel.test.ts b/tests/extension-host/suite/diffSourcePanel.test.ts new file mode 100644 index 00000000..415e296b --- /dev/null +++ b/tests/extension-host/suite/diffSourcePanel.test.ts @@ -0,0 +1,77 @@ +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. +// Reuse the local-include fixture: it reliably produces gitlab-component-helper diagnostics. +const FIXTURE_DIR = path.resolve(__dirname, '..', '..', 'tests', 'fixtures', 'local-include'); +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(); +} + +/** Poll until `predicate` holds over the current diagnostics, or the timeout elapses. Returns the last snapshot. */ +async function waitForDiagnostics( + uri: vscode.Uri, + predicate: (diags: vscode.Diagnostic[]) => boolean, + timeoutMs = 5000 +): Promise { + const start = Date.now(); + let diags = vscode.languages.getDiagnostics(uri); + while (Date.now() - start < timeoutMs) { + diags = vscode.languages.getDiagnostics(uri); + if (predicate(diags)) return diags; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return diags; +} + +const ours = (diags: vscode.Diagnostic[]) => diags.filter((d) => d.source === 'gitlab-component-helper'); + +// In a diff view the source (left) panel is a read-only document under a VCS scheme that shares the working-tree +// file's fsPath. Validating it made the squiggles appear on both panels; only the `file`-scheme copy should carry +// diagnostics now. +suite('Diff source panel diagnostics', () => { + suiteSetup(ensureActive); + + test('the working-tree (file) document still carries diagnostics', async () => { + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(FIXTURE)); + await vscode.window.showTextDocument(doc, { preview: false }); + + const diags = await waitForDiagnostics(doc.uri, (d) => ours(d).length > 0); + assert.ok( + ours(diags).length > 0, + `file-scheme document should carry diagnostics. Got: ${JSON.stringify(ours(diags).map((d) => ({ code: d.code, msg: d.message })))}` + ); + }); + + test('does not produce diagnostics for a non-file (diff source) document', async () => { + const fixtureText = ( + await vscode.workspace.openTextDocument(vscode.Uri.file(FIXTURE)) + ).getText(); + + // Mirror how a diff source panel opens: same fsPath, a VCS-style scheme, read-only content. + const provider: vscode.TextDocumentContentProvider = { provideTextDocumentContent: () => fixtureText }; + const registration = vscode.workspace.registerTextDocumentContentProvider('gitlab-test', provider); + try { + const sourceUri = vscode.Uri.file(FIXTURE).with({ scheme: 'gitlab-test' }); + const sourceDoc = await vscode.workspace.openTextDocument(sourceUri); + await vscode.window.showTextDocument(sourceDoc, { preview: false }); + + // Give validation a window to run (and incorrectly emit) before asserting it stayed silent. + const diags = await waitForDiagnostics(sourceUri, (d) => ours(d).length > 0, 2000); + assert.strictEqual( + ours(diags).length, + 0, + `diff source panel should carry no diagnostics. Got: ${JSON.stringify(ours(diags).map((d) => ({ code: d.code, msg: d.message })))}` + ); + } finally { + registration.dispose(); + } + }); +}); From 186eef932a4af5886c1384cfb6cd82173a87e25f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 17 Jun 2026 21:39:23 +0000 Subject: [PATCH 05/24] chore(release): 0.13.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 36daf3c0..0792791c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.13.0", + "version": "0.13.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.13.0", + "version": "0.13.1", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.0.2", diff --git a/package.json b/package.json index 739fe413..26718a23 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.13.0", + "version": "0.13.1", "icon": "images/icon.png", "engines": { "node": ">=22.0.0", From d32b1e6c9b96d9ffccf20b6bd4debfb34b289642 Mon Sep 17 00:00:00 2001 From: eFAILution <128437814+eFAILution@users.noreply.github.com> Date: Thu, 18 Jun 2026 06:42:23 -0400 Subject: [PATCH 06/24] docs(ci): document dev-scope security-alert policy in dependabot.yml (#183) --- .github/dependabot.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 28bf627b..369a5ca1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,3 +1,11 @@ +# Dependabot version updates. +# +# Security-alert policy (handled OUTSIDE this file): dev-scoped npm vulnerabilities are not gated. +# They are auto-dismissed by a Dependabot auto-triage rule (repo Settings -> Code security -> +# Dependabot -> Auto-triage rules: scope = development -> dismiss), mirroring the dev-group override +# in osv-scanner.toml that the Argus scan honors. Do NOT add a dev-dep `ignore:` here to suppress +# those alerts -- `ignore:` also stops the version-update PRs below, which are how dev tools stay +# current and how a transitive fix (e.g. serialize-javascript >= 7.0.5) actually lands. version: 2 updates: # Maintain npm dependencies From d522fe4ca9df90274b3e5f8eee21781b5c883fd3 Mon Sep 17 00:00:00 2001 From: eFAILution <128437814+eFAILution@users.noreply.github.com> Date: Thu, 18 Jun 2026 06:42:47 -0400 Subject: [PATCH 07/24] fix: recognise braced ${VAR} GitLab variables in component URLs (#182) --- src/utils/gitlabVariables.ts | 19 +++++---- tests/unit/gitlabVariables.test.ts | 64 ++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 tests/unit/gitlabVariables.test.ts diff --git a/src/utils/gitlabVariables.ts b/src/utils/gitlabVariables.ts index 766e9a35..b2758d37 100644 --- a/src/utils/gitlabVariables.ts +++ b/src/utils/gitlabVariables.ts @@ -165,16 +165,17 @@ export const GITLAB_PREDEFINED_VARIABLES: GitLabVariable[] = [ * Detects GitLab predefined variables in a string */ export function detectGitLabVariables(text: string): string[] { - const variablePattern = /\$([A-Z_][A-Z0-9_]*)/g; - const matches = text.match(variablePattern); + // Match both the bare `$VAR` and braced `${VAR}` forms GitLab CI accepts; the capture group + // yields just the variable name, excluding the `$` and any surrounding braces. + const variablePattern = /\$\{?([A-Z_][A-Z0-9_]*)\}?/g; + const predefinedVariableNames = GITLAB_PREDEFINED_VARIABLES.map(v => v.name); - if (!matches) { - return []; + const variables: string[] = []; + let match: RegExpExecArray | null; + while ((match = variablePattern.exec(text)) !== null) { + variables.push(match[1]); } - const variables = matches.map(match => match.substring(1)); // Remove the $ - const predefinedVariableNames = GITLAB_PREDEFINED_VARIABLES.map(v => v.name); - return variables.filter(variable => predefinedVariableNames.includes(variable)); } @@ -196,6 +197,10 @@ export function expandComponentUrl(componentUrl: string, context?: { }): string { let expanded = componentUrl; + // Normalize the braced `${VAR}` form to bare `$VAR` up front, so the expansions below — and the + // `$CI_SERVER_FQDN/` URL special-case — handle both spellings GitLab CI accepts. + expanded = expanded.replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (_match, name) => `$${name}`); + if (context) { // Handle URL expansion carefully to maintain proper URL structure if (context.gitlabInstance) { diff --git a/tests/unit/gitlabVariables.test.ts b/tests/unit/gitlabVariables.test.ts new file mode 100644 index 00000000..68f6bf45 --- /dev/null +++ b/tests/unit/gitlabVariables.test.ts @@ -0,0 +1,64 @@ +// @mocha +/** + * gitlabVariables tests — predefined-variable detection and component-URL expansion. + * + * The load-bearing case here is the braced `${VAR}` form (issue #136): GitLab CI accepts both + * `$CI_SERVER_FQDN` and `${CI_SERVER_FQDN}`, but only the bare form used to be detected/expanded, + * so a braced component URL got no hover/completion/validation. + */ + +import * as assert from 'node:assert/strict'; +import { + detectGitLabVariables, + containsGitLabVariables, + expandComponentUrl, +} from '../../src/utils/gitlabVariables'; + +suite('detectGitLabVariables', () => { + test('detects the bare $VAR form', () => { + assert.deepStrictEqual(detectGitLabVariables('$CI_SERVER_FQDN/group/comp@1.0.0'), ['CI_SERVER_FQDN']); + }); + + test('detects the braced ${VAR} form', () => { + assert.deepStrictEqual(detectGitLabVariables('${CI_SERVER_FQDN}/group/comp@1.0.0'), ['CI_SERVER_FQDN']); + }); + + test('detects both forms in the same string', () => { + assert.deepStrictEqual( + detectGitLabVariables('${CI_SERVER_FQDN}/$CI_PROJECT_PATH/comp@1.0.0'), + ['CI_SERVER_FQDN', 'CI_PROJECT_PATH'], + ); + }); + + test('ignores non-predefined variables in either form', () => { + assert.deepStrictEqual(detectGitLabVariables('$NOT_A_GITLAB_VAR/${ALSO_NOT_ONE}'), []); + }); +}); + +suite('containsGitLabVariables', () => { + test('is true for both the bare and braced forms', () => { + assert.strictEqual(containsGitLabVariables('$CI_SERVER_FQDN/x'), true); + assert.strictEqual(containsGitLabVariables('${CI_SERVER_FQDN}/x'), true); + }); + + test('is false when no predefined variable is present', () => { + assert.strictEqual(containsGitLabVariables('gitlab.com/group/comp@1.0.0'), false); + }); +}); + +suite('expandComponentUrl — braced and bare forms expand identically', () => { + const context = { gitlabInstance: 'gitlab.example.com', projectPath: 'my-group/my-project' }; + + test('$CI_SERVER_FQDN expands to an https:// URL in both forms', () => { + const expected = 'https://gitlab.example.com/group/comp@1.0.0'; + assert.strictEqual(expandComponentUrl('$CI_SERVER_FQDN/group/comp@1.0.0', context), expected); + assert.strictEqual(expandComponentUrl('${CI_SERVER_FQDN}/group/comp@1.0.0', context), expected); + }); + + test('a mid-path braced variable is expanded', () => { + assert.strictEqual( + expandComponentUrl('$CI_SERVER_FQDN/${CI_PROJECT_PATH}/comp@1.0.0', context), + 'https://gitlab.example.com/my-group/my-project/comp@1.0.0', + ); + }); +}); From ea37aace9fa42d1e9a08a61675cced707ec3ef94 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 18 Jun 2026 10:45:15 +0000 Subject: [PATCH 08/24] chore(release): 0.13.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 0792791c..a25947bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.13.1", + "version": "0.13.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.13.1", + "version": "0.13.2", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.0.2", diff --git a/package.json b/package.json index 26718a23..51a604c7 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.13.1", + "version": "0.13.2", "icon": "images/icon.png", "engines": { "node": ">=22.0.0", From a6c7626801519f0388ee188c69008a4975ec8038 Mon Sep 17 00:00:00 2001 From: eFAILution Date: Mon, 22 Jun 2026 10:52:08 -0400 Subject: [PATCH 09/24] fix(deps): pin @types/vscode to ^1.120.0 to match engines.vscode vsce package fails when @types/vscode's declared floor exceeds engines.vscode (>=1.120.0). Dependabot #188 bumped it to ^1.125.0, breaking the beta release. Revert that one bump to keep the 1.120 support floor; the other #188/#190 dev-dep bumps are retained. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3a3c9934..5d8696ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@types/js-yaml": "^4.0.9", "@types/mocha": "^10.0.10", "@types/node": "^26.0.0", - "@types/vscode": "^1.125.0", + "@types/vscode": "^1.120.0", "@typescript-eslint/eslint-plugin": "^8.61.1", "@typescript-eslint/parser": "^8.61.1", "@vscode/test-electron": "^3.0.0", diff --git a/package.json b/package.json index 85a725dc..696acb03 100644 --- a/package.json +++ b/package.json @@ -313,7 +313,7 @@ "@types/js-yaml": "^4.0.9", "@types/mocha": "^10.0.10", "@types/node": "^26.0.0", - "@types/vscode": "^1.125.0", + "@types/vscode": "^1.120.0", "@typescript-eslint/eslint-plugin": "^8.61.1", "@typescript-eslint/parser": "^8.61.1", "@vscode/test-electron": "^3.0.0", From 8b3fda8133ec52d81facd9ca5cfc877c2bf805a2 Mon Sep 17 00:00:00 2001 From: eFAILution Date: Mon, 22 Jun 2026 10:52:08 -0400 Subject: [PATCH 10/24] ci(dependabot): stop @types/vscode bumps past the engines.vscode floor Ignore semver-minor/major version-updates for @types/vscode so it cannot exceed engines.vscode again; only patch bumps within the floor are allowed. A minor/major raise must accompany a deliberate engines bump. Dependabot reads this config from the default branch, so the rule takes effect once it reaches main. --- .github/dependabot.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 369a5ca1..540dbc9b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -39,10 +39,15 @@ updates: update-types: - "minor" - "patch" - # Ignore specific packages if needed - # ignore: - # - dependency-name: "package-name" - # versions: ["x.x.x"] + # @types/vscode is pinned to the engines.vscode floor (>=1.120.0): `vsce package` + # fails when @types/vscode > engines.vscode. Allow only patch bumps (which stay + # within the floor); a minor/major bump must be a deliberate engines.vscode raise, + # not an automatic dependency update. This stops the bump, not any security alert. + ignore: + - dependency-name: "@types/vscode" + update-types: + - "version-update:semver-minor" + - "version-update:semver-major" # Maintain GitHub Actions - package-ecosystem: "github-actions" From aab907d98b9f20544b8826d52d20dcf2b867d19b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 22 Jun 2026 14:54:55 +0000 Subject: [PATCH 11/24] chore(release): 0.13.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 5d8696ff..bbbdc57d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.13.2", + "version": "0.13.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.13.2", + "version": "0.13.3", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.0.2", diff --git a/package.json b/package.json index 696acb03..048588aa 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.13.2", + "version": "0.13.3", "icon": "images/icon.png", "engines": { "node": ">=22.0.0", From f922edb01d6b34d6e4a927a469ccb0f1d34018b6 Mon Sep 17 00:00:00 2001 From: Simon Heather <32168619+X-Guardian@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:55:33 +0100 Subject: [PATCH 12/24] fix: input completions offered outside the input-name slot (#185) Co-authored-by: Simon Heather --- src/providers/completionInputContext.ts | 55 ++++++++++++++--- src/providers/completionProvider.ts | 2 +- tests/unit/completionInputContext.test.ts | 73 +++++++++++++++++++++++ 3 files changed, 121 insertions(+), 9 deletions(-) diff --git a/src/providers/completionInputContext.ts b/src/providers/completionInputContext.ts index 5c64a711..4bdf84d8 100644 --- a/src/providers/completionInputContext.ts +++ b/src/providers/completionInputContext.ts @@ -37,10 +37,17 @@ function indentOf(line: string): number { * * @param text - The full YAML document text. * @param lineIndex - 0-based line index where the cursor sits. + * @param column - 0-based cursor column. When given, the cursor must actually sit at the slot's indent column — + * typing the right indentation then moving the cursor left leaves the spaces in place but no longer offers + * completions. Omit to check the line's text alone (the cursor-agnostic form used by tests). * @returns A populated {@link CompletionInputContext} when the cursor is in a parameter-name slot inside the * `inputs:` block of the closest preceding `component:`/`local:` include; `null` otherwise. */ -export function findCompletionInputContextAtLine(text: string, lineIndex: number): CompletionInputContext | null { +export function findCompletionInputContextAtLine( + text: string, + lineIndex: number, + column?: number +): CompletionInputContext | null { const parsed = parseYaml(text); if (!isYamlNode(parsed) || !parsed.include) return null; @@ -55,12 +62,26 @@ export function findCompletionInputContextAtLine(text: string, lineIndex: number if (!section) return null; // Containment in the inputs block is already established above; here we only check the line looks like a - // parameter-name slot: indented deeper than the `inputs:` key (i.e. a child of it, not a sibling) and not - // already a complete `key: value`. + // parameter-name slot. A name slot must line up at the same column as the existing input keys + // (`section.childIndent`) — one column shallower is a sibling of `inputs:`, deeper is a value nested under + // another input. When the block has no keys yet, fall back to "deeper than `inputs:`". It must also not be an + // array-item line (`- value`, part of a parameter value) nor an already-complete `key: value`. const currentLine = lines[lineIndex]; const currentLineText = currentLine.trim(); + const currentIndent = indentOf(currentLine); + const indentMatches = + section.childIndent !== null ? currentIndent === section.childIndent : currentIndent > section.inputsIndent; + // When a cursor column is supplied, the cursor must sit at the slot's indent column or within the name being + // typed after it — never left of the indent. Typing the right indentation then moving the cursor left leaves + // the line's whitespace intact, so the indent check above still matches; but the cursor is no longer in the + // name slot, so we must not offer completions there. + const cursorAtSlot = column === undefined || column >= currentIndent; const isParameterContext = - indentOf(currentLine) > section.inputsIndent && (!currentLineText.includes(':') || currentLineText.endsWith(':')); + indentMatches && + cursorAtSlot && + !currentLineText.startsWith('- ') && + currentLineText !== '-' && + (!currentLineText.includes(':') || currentLineText.endsWith(':')); if (!isParameterContext) return null; return { @@ -126,15 +147,21 @@ function findClosestInclude(includes: unknown[], lines: string[], lineIndex: num * boundary test, so the mapping form (where `inputs:` sits at the same indent as the include's `component:` key) * isn't mistaken for the section end. * - * @returns the `inputs:` line's indentation when `lineIndex` is inside the block, so the caller can decide what - * counts as a parameter-name slot relative to it; `null` when the cursor isn't inside an inputs block. + * @returns when `lineIndex` is inside the block: the `inputs:` line's indentation, and `childIndent` — the + * indentation of the first parameter key under it (the column input names line up at), or `null` when the block + * has no keys yet. `null` (the whole result) when the cursor isn't inside an inputs block. */ -function findInputsSection(lines: string[], componentLineIndex: number, lineIndex: number): { inputsIndent: number } | null { +function findInputsSection( + lines: string[], + componentLineIndex: number, + lineIndex: number +): { inputsIndent: number; childIndent: number | null } | null { const componentIndent = indentOf(lines[componentLineIndex]); let inputsSectionStart = -1; let inputsIndent = -1; let inputsSectionEnd = -1; + let childIndent: number | null = null; for (let i = componentLineIndex + 1; i < lines.length; i++) { const trimmedLine = lines[i].trim(); @@ -152,13 +179,25 @@ function findInputsSection(lines: string[], componentLineIndex: number, lineInde inputsSectionEnd = i; break; } + + // The first key directly under `inputs:` (not an array item, deeper than `inputs:` itself) fixes the column + // that input names line up at. Lines nested deeper than this belong to a parameter's value, not a name slot. + if ( + inputsSectionStart !== -1 && + childIndent === null && + indentOf(lines[i]) > inputsIndent && + !trimmedLine.startsWith('- ') && + trimmedLine.includes(':') + ) { + childIndent = indentOf(lines[i]); + } } if (inputsSectionStart === -1) return null; if (inputsSectionEnd === -1) inputsSectionEnd = lines.length; if (lineIndex > inputsSectionStart && lineIndex < inputsSectionEnd) { - return { inputsIndent }; + return { inputsIndent, childIndent }; } return null; } diff --git a/src/providers/completionProvider.ts b/src/providers/completionProvider.ts index c58d9d26..c2516b59 100755 --- a/src/providers/completionProvider.ts +++ b/src/providers/completionProvider.ts @@ -363,7 +363,7 @@ export class CompletionProvider implements vscode.CompletionItemProvider { try { const text = document.getText(); - const context = findCompletionInputContextAtLine(text, position.line); + const context = findCompletionInputContextAtLine(text, position.line, position.character); if (!context) { this.logger.debug(`[CompletionProvider] No inputs parameter-name slot at cursor`, 'CompletionProvider'); return null; diff --git a/tests/unit/completionInputContext.test.ts b/tests/unit/completionInputContext.test.ts index 1bfe4c8f..094f2675 100644 --- a/tests/unit/completionInputContext.test.ts +++ b/tests/unit/completionInputContext.test.ts @@ -50,6 +50,79 @@ suite('findCompletionInputContextAtLine', () => { }); }); + test('returns null when the cursor sits left of the slot indent, even though the line is indented correctly', () => { + // The slot line has the right 6-space indent, but the cursor has been moved left to column 3 — it is no + // longer in the name slot, so no completions should be offered. + const text = `include: + - component: ${FULL_PIPELINE_URL} + inputs: + environment: "dev" + `; + assert.strictEqual(findCompletionInputContextAtLine(text, 4, 3), null); + }); + + test('detects the slot when the cursor sits at the slot indent', () => { + const text = `include: + - component: ${FULL_PIPELINE_URL} + inputs: + environment: "dev" + `; + assert.deepStrictEqual(findCompletionInputContextAtLine(text, 4, 6)?.existingInputNames, ['environment']); + }); + + test('returns null inside a multi-line array input, where a `- item` line is a value not a name slot', () => { + // An array item nested under an input is deeper-indented than `inputs:` and has no `:`, but it is part of a + // parameter value — completing input names there would interrupt the array. + const text = `include: + - component: ${FULL_PIPELINE_URL} + inputs: + tags: + - one + - two + environment: "dev"`; + const ctx = findCompletionInputContextAtLine(text, 5); // the `- two` array item line + assert.strictEqual(ctx, null); + }); + + test('returns null when the slot is one column shallower than the existing input keys', () => { + // Existing keys sit at 6 spaces; a 5-space slot is misaligned (a sibling of `inputs:`), not a name slot. + const text = + 'include:\n' + + ` - component: ${FULL_PIPELINE_URL}\n` + + ' inputs:\n' + + ' environment: "dev"\n' + + ' '; + const ctx = findCompletionInputContextAtLine(text, 4); + assert.strictEqual(ctx, null); + }); + + test('returns null when the slot is deeper than the existing input keys (a nested value position)', () => { + // Existing keys sit at 6 spaces; an 8-space slot is nested under a value, not a name slot. + const text = + 'include:\n' + + ` - component: ${FULL_PIPELINE_URL}\n` + + ' inputs:\n' + + ' environment: "dev"\n' + + ' '; + const ctx = findCompletionInputContextAtLine(text, 4); + assert.strictEqual(ctx, null); + }); + + test('detects a slot aligned with the existing input keys', () => { + const text = + 'include:\n' + + ` - component: ${FULL_PIPELINE_URL}\n` + + ' inputs:\n' + + ' environment: "dev"\n' + + ' '; + const ctx = findCompletionInputContextAtLine(text, 4); + assert.deepStrictEqual(ctx, { + componentUrl: FULL_PIPELINE_URL, + includeKind: 'component', + existingInputNames: ['environment'], + }); + }); + test('reports existing inputs so the caller can filter them out', () => { const text = `include: - component: ${FULL_PIPELINE_URL} From 71ab902603e5b3238eb6aa9b40cbcb419498a674 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 22 Jun 2026 16:58:15 +0000 Subject: [PATCH 13/24] chore(release): 0.13.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 bbbdc57d..7acb45b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.13.3", + "version": "0.13.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.13.3", + "version": "0.13.4", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.0.2", diff --git a/package.json b/package.json index 048588aa..d37d79df 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.13.3", + "version": "0.13.4", "icon": "images/icon.png", "engines": { "node": ">=22.0.0", From a7c4026b463a6e549439a8769bedc7fe9d5c196c Mon Sep 17 00:00:00 2001 From: Simon Heather <32168619+X-Guardian@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:45:38 +0100 Subject: [PATCH 14/24] fix: re-requesting input suggestions after typing part of a new input name returns nothing (#192) Co-authored-by: Simon Heather --- src/providers/completionInputContext.ts | 40 ++++++++++++++++++--- src/providers/componentBrowserEdit.ts | 2 +- src/providers/hoverInputContext.ts | 3 +- src/providers/validationProvider.ts | 4 ++- src/utils/yamlParser.ts | 15 ++++++-- tests/unit/completionInputContext.test.ts | 42 +++++++++++++++++++++++ 6 files changed, 97 insertions(+), 9 deletions(-) diff --git a/src/providers/completionInputContext.ts b/src/providers/completionInputContext.ts index 4bdf84d8..212ffe20 100644 --- a/src/providers/completionInputContext.ts +++ b/src/providers/completionInputContext.ts @@ -48,12 +48,17 @@ export function findCompletionInputContextAtLine( lineIndex: number, column?: number ): CompletionInputContext | null { - const parsed = parseYaml(text); + const lines = text.split('\n'); + if (lines[lineIndex] === undefined) return null; + + // A half-typed input name on the cursor line — `env` with no `:` yet — is a bare scalar where its sibling input + // keys are mappings, which makes the whole document invalid YAML. That would parse to `null` and offer nothing + // exactly while the user is typing the name. Blank the cursor line before parsing: it contributes nothing to the + // existing-input set (it *is* the slot being typed), and the positional scans below read `lines`, not the parse. + const parsed = parseInputDocument(text, lines, lineIndex); if (!isYamlNode(parsed) || !parsed.include) return null; const includes = Array.isArray(parsed.include) ? parsed.include : [parsed.include]; - const lines = text.split('\n'); - if (lines[lineIndex] === undefined) return null; const closest = findClosestInclude(includes, lines, lineIndex); if (!closest) return null; @@ -91,6 +96,33 @@ export function findCompletionInputContextAtLine( }; } +/** + * Parse `text` as a YAML mapping, tolerating an in-progress input name on the cursor line. + * + * The document parses normally first. If that fails (or yields a non-mapping), and the cursor line looks like a + * partially-typed name — leading whitespace then a bare token with no `:` — the line is blanked and the document + * re-parsed. That token is the slot being typed; as a bare scalar beside its mapping siblings it makes the whole + * document invalid, so blanking it lets the surrounding structure parse while the user types. + * + * @param text - The full YAML document text to parse. + * @param lines - `text` split into lines, so the cursor line can be blanked without re-splitting. + * @param lineIndex - 0-based index of the cursor line, the one blanked on the retry. + * @returns the parsed value (from the original text where valid, otherwise the cursor-line-blanked retry), or the + * original parse result when the cursor line isn't an in-progress name. + */ +function parseInputDocument(text: string, lines: string[], lineIndex: number): unknown { + const parsed = parseYaml(text, true); + if (isYamlNode(parsed) && parsed.include) return parsed; + + const cursorLine = lines[lineIndex]; + const isInProgressName = /^\s*[^\s:#-][^:]*$/.test(cursorLine); + if (!isInProgressName) return parsed; + + const blanked = [...lines]; + blanked[lineIndex] = ''; + return parseYaml(blanked.join('\n'), true); +} + /** * Find the include entry whose source line is the closest one above `lineIndex`, matching the parsed includes * back to their position in the text. @@ -230,7 +262,7 @@ function quoteYamlIfUnsafe(value: string, flow = false): string { return asDoubleQuoted(value); } try { - const parsed = parseYaml(`probe: ${value}`); + const parsed = parseYaml(`probe: ${value}`, true); if (isYamlNode(parsed) && parsed.probe === value) { return value; } diff --git a/src/providers/componentBrowserEdit.ts b/src/providers/componentBrowserEdit.ts index 83660293..9c677459 100644 --- a/src/providers/componentBrowserEdit.ts +++ b/src/providers/componentBrowserEdit.ts @@ -121,7 +121,7 @@ export function findComponentLineRange( export function parseExistingComponentText(componentText: string): unknown { try { const wrappedYaml = `include:\n${componentText}`; - const parsed = parseYaml(wrappedYaml) as { include?: unknown } | null; + const parsed = parseYaml(wrappedYaml, true) as { include?: unknown } | null; if (!parsed || parsed.include === undefined) return null; if (Array.isArray(parsed.include)) { return parsed.include[0] ?? null; diff --git a/src/providers/hoverInputContext.ts b/src/providers/hoverInputContext.ts index 8acc3282..89145d79 100644 --- a/src/providers/hoverInputContext.ts +++ b/src/providers/hoverInputContext.ts @@ -45,7 +45,8 @@ export function findInputContextAtLine(text: string, lineIndex: number): InputCo const inputName = inputMatch[2]; // Need a parsed `include:` array to know which includes are in scope; if YAML doesn't parse, bail. - const parsed = parseYaml(text); + // Silent: hover runs against the live, often mid-edit document, where a parse failure is expected and handled. + const parsed = parseYaml(text, true); if (!isYamlNode(parsed) || !parsed.include) return null; const includes = Array.isArray(parsed.include) ? parsed.include : [parsed.include]; diff --git a/src/providers/validationProvider.ts b/src/providers/validationProvider.ts index d12fb634..fd02e0e9 100644 --- a/src/providers/validationProvider.ts +++ b/src/providers/validationProvider.ts @@ -131,7 +131,9 @@ export class ValidationProvider implements vscode.CodeActionProvider { const diagnostics: vscode.Diagnostic[] = []; const diagnosticKeys = new Set(); // Track unique diagnostics to prevent duplicates - const parsedYaml = parseYaml(text); + // Silent: validation runs on every edit against the live, often mid-edit document; a parse failure is + // expected and handled below (diagnostics cleared), so it should not log to the debug console. + const parsedYaml = parseYaml(text, true); if (!isYamlNode(parsedYaml) || !parsedYaml.include) { this.diagnosticCollection.set(document.uri, diagnostics); diff --git a/src/utils/yamlParser.ts b/src/utils/yamlParser.ts index b39e63bd..53c6e6f4 100755 --- a/src/utils/yamlParser.ts +++ b/src/utils/yamlParser.ts @@ -12,7 +12,16 @@ export function isYamlNode(value: unknown): value is YamlNode { const parseCache = new Map(); const CACHE_TTL = 5000; // 5 seconds TTL for parse cache -export function parseYaml(text: string): unknown { +/** + * Parse a YAML document. + * + * @param text - The YAML source to parse. + * @param silent - Suppress the `console.error` on a parse failure. Use when a parse failure is expected and + * handled by the caller (e.g. probing a document that is invalid mid-edit), to avoid log noise on a hot path. + * @returns the parsed YAML value (a mapping, sequence, scalar, or `undefined` for empty input), or `null` if the + * text fails to parse. Callers narrow object results with {@link isYamlNode} before reading fields. + */ +export function parseYaml(text: string, silent = false): unknown { try { // Generate a simple hash of the content for caching const contentHash = text.length + text.substring(0, 100) + text.substring(text.length - 100); @@ -35,7 +44,9 @@ export function parseYaml(text: string): unknown { return parsed; } catch (e) { - console.error('Error parsing YAML:', e); + if (!silent) { + console.error('Error parsing YAML:', e); + } return null; } } diff --git a/tests/unit/completionInputContext.test.ts b/tests/unit/completionInputContext.test.ts index 094f2675..1f0b54e2 100644 --- a/tests/unit/completionInputContext.test.ts +++ b/tests/unit/completionInputContext.test.ts @@ -70,6 +70,48 @@ suite('findCompletionInputContextAtLine', () => { assert.deepStrictEqual(findCompletionInputContextAtLine(text, 4, 6)?.existingInputNames, ['environment']); }); + test('detects the slot while a new input name is being typed (bare token would otherwise break the parse)', () => { + // `env` with no `:` yet is a bare scalar beside the `environment` mapping, so the raw document is invalid YAML. + // Slot detection tolerates the in-progress name and still reports the already-present inputs. + const text = `include: + - component: ${FULL_PIPELINE_URL} + inputs: + environment: "dev" + env`; + const ctx = findCompletionInputContextAtLine(text, 4, 9); + assert.deepStrictEqual(ctx, { + componentUrl: FULL_PIPELINE_URL, + includeKind: 'component', + existingInputNames: ['environment'], + }); + }); + + test('detects the slot for the first input while its name is being typed', () => { + // The in-progress name is the only thing under `inputs:`, so there are no existing inputs yet. + const text = `include: + - component: ${FULL_PIPELINE_URL} + inputs: + env`; + const ctx = findCompletionInputContextAtLine(text, 3, 9); + assert.deepStrictEqual(ctx, { + componentUrl: FULL_PIPELINE_URL, + includeKind: 'component', + existingInputNames: [], + }); + }); + + test('detects the in-progress name slot when it is not at end of document', () => { + const text = `include: + - component: ${FULL_PIPELINE_URL} + inputs: + environment: "dev" + env +stages: + - build`; + const ctx = findCompletionInputContextAtLine(text, 4, 9); + assert.deepStrictEqual(ctx?.existingInputNames, ['environment']); + }); + test('returns null inside a multi-line array input, where a `- item` line is a value not a name slot', () => { // An array item nested under an input is deeper-indented than `inputs:` and has no `:`, but it is part of a // parameter value — completing input names there would interrupt the array. From 4953f468fa8f19a212044daf995b7a3820e6a8cf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 24 Jun 2026 12:48:33 +0000 Subject: [PATCH 15/24] chore(release): 0.13.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 7acb45b6..1387e333 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.13.4", + "version": "0.13.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.13.4", + "version": "0.13.5", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.0.2", diff --git a/package.json b/package.json index d37d79df..e9eee00a 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.13.4", + "version": "0.13.5", "icon": "images/icon.png", "engines": { "node": ">=22.0.0", From a5641fc907385fc0d51c9afaac76ba202ce2daa5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 24 Jun 2026 12:54:16 +0000 Subject: [PATCH 16/24] chore(release): 0.13.6 [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 b585c764..30a7a317 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.13.5", + "version": "0.13.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.13.5", + "version": "0.13.6", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.0.2", diff --git a/package.json b/package.json index 20655ec7..c28121b8 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.13.5", + "version": "0.13.6", "icon": "images/icon.png", "engines": { "node": ">=22.0.0", From 60af4ec9bfc429b83893950a4d376718960a169d Mon Sep 17 00:00:00 2001 From: eFAILution <128437814+eFAILution@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:35:45 -0400 Subject: [PATCH 17/24] feat(version-check): detect outdated component versions (#194) Flags GitLab CI component includes pinned to a semver ref that has a newer stable release available, across three surfaces: - hover shows the latest available version next to the current one - an on-save warning squiggle on the outdated ref, with an "Update to X" quick-fix - a "Update All Component Versions to Latest" command for the active file Only clean X.Y.Z refs (optional v prefix) are eligible; floating, branch, and SHA refs are left untouched and pre-releases are never suggested. Version comparison is in-house (no new dependency). Version lookups reuse the per-project tag cache and run on open/save, in a dedicated diagnostic collection so they never clobber the input/structure diagnostics. New, pure, unit-tested modules: src/utils/semver.ts and src/providers/componentVersionCheck.ts. Wired into ValidationProvider, HoverProvider, package.json contributions, and a new command. Closes #193 Co-authored-by: eFAILution --- package.json | 23 +++ src/extension.ts | 25 ++++ src/providers/componentVersionCheck.ts | 124 ++++++++++++++++ src/providers/hoverContentBuilder.ts | 26 +++- src/providers/hoverProvider.ts | 51 ++++++- src/providers/validationMetadata.ts | 14 +- src/providers/validationProvider.ts | 171 ++++++++++++++++++++++- src/utils/semver.ts | 91 ++++++++++++ tests/unit/componentVersionCheck.test.ts | 94 +++++++++++++ tests/unit/semver.test.ts | 85 +++++++++++ 10 files changed, 696 insertions(+), 8 deletions(-) create mode 100644 src/providers/componentVersionCheck.ts create mode 100644 src/utils/semver.ts create mode 100644 tests/unit/componentVersionCheck.test.ts create mode 100644 tests/unit/semver.test.ts diff --git a/package.json b/package.json index c28121b8..84007d21 100644 --- a/package.json +++ b/package.json @@ -220,6 +220,20 @@ }, "default": [], "description": "Extra GitLab CI file globs, merged with the built-in defaults. Patterns match at any directory depth (e.g. 'ci/*.yml' is treated as '**/ci/*.yml')." + }, + "gitlabComponentHelper.versionCheck.enabled": { + "type": "boolean", + "default": true, + "description": "Warn when a component pinned to a semantic version (X.Y.Z) has a newer stable release available. Checked when a GitLab CI file is opened or saved." + }, + "gitlabComponentHelper.versionCheck.severity": { + "type": "string", + "enum": [ + "warning", + "information" + ], + "default": "warning", + "description": "Severity of the 'newer component version available' diagnostic." } } }, @@ -259,6 +273,10 @@ { "command": "gitlab-component-helper.showPerformanceStats", "title": "GitLab CI: Show Performance Statistics" + }, + { + "command": "gitlab-component-helper.updateAllComponentVersions", + "title": "GitLab CI: Update All Component Versions to Latest" } ], "menus": { @@ -267,6 +285,11 @@ "when": "gitlabComponentHelper.isCiFile", "command": "gitlab-component-helper.browseComponents", "group": "navigation" + }, + { + "when": "gitlabComponentHelper.isCiFile", + "command": "gitlab-component-helper.updateAllComponentVersions", + "group": "navigation" } ] } diff --git a/src/extension.ts b/src/extension.ts index 3fff5bd5..1c340d11 100755 --- a/src/extension.ts +++ b/src/extension.ts @@ -618,6 +618,31 @@ export function activate(context: vscode.ExtensionContext) { // Initialize the validation provider const validationProvider = new ValidationProvider(context); + // Register command to update every outdated component in the active file to its latest stable version. + logger.debug('[Extension] Registering updateAllComponentVersions command...', 'Extension'); + context.subscriptions.push( + vscode.commands.registerCommand('gitlab-component-helper.updateAllComponentVersions', async () => { + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showErrorMessage('Open a GitLab CI file to update component versions.'); + return; + } + try { + const updated = await validationProvider.updateAllComponentVersions(editor.document); + if (updated === 0) { + vscode.window.showInformationMessage('All components are already on their latest version.'); + } else { + vscode.window.showInformationMessage( + `Updated ${updated} component${updated === 1 ? '' : 's'} to the latest version.` + ); + } + } catch (error) { + logger.error(`[Extension] Failed to update component versions: ${error}`, 'Extension'); + vscode.window.showErrorMessage(`Failed to update component versions: ${error}`); + } + }) + ); + // Keep the `gitlabComponentHelper.isCiFile` context key in sync with `isGitLabCIFile` so the editor context menu // shows the Browse Components command on exactly the same files the providers activate on. const updateCiFileContext = (editor: vscode.TextEditor | undefined): void => { diff --git a/src/providers/componentVersionCheck.ts b/src/providers/componentVersionCheck.ts new file mode 100644 index 00000000..80018beb --- /dev/null +++ b/src/providers/componentVersionCheck.ts @@ -0,0 +1,124 @@ +/** + * Pure detection of out-of-date component version refs in a `.gitlab-ci.yml`. + * + * Given the document text and a resolver that yields the available versions for a component's project, this finds + * every `component:` include pinned to a clean semver ref that has a newer stable release available, and returns the + * ref's location (line + column span) so the provider can place a precise diagnostic squiggle and the "update" flows + * can rewrite exactly the version ref. + * + * Only clean stable semver refs are considered (see {@link isCleanSemver}); floating refs and monorepo + * tag-pattern refs (e.g. `my-comp-1.2.3`) are not clean semver and are skipped. Kept free of `vscode` imports for + * unit-testability — the provider supplies the (async) version resolver and converts findings into vscode types. + */ + +import { isCleanSemver, getLatestStableSemver, isOutdated } from '../utils/semver'; + +/** A component include whose pinned semver ref is behind the latest available stable release. */ +export interface OutdatedComponentRef { + /** Full component URL as written, including the `@version` suffix. */ + componentUrl: string; + /** Project + component base URL (the `@version` stripped) — the key version lookups are cached under. */ + baseUrl: string; + /** Short component name (last path segment of the base URL), for diagnostic messages. */ + componentName: string; + /** 0-based document line of the `component:` entry. */ + line: number; + /** Column (0-based) where the version ref starts — just past the `@`. */ + refStart: number; + /** Column (0-based, exclusive) where the version ref ends. */ + refEnd: number; + /** The pinned ref currently in the document. */ + currentVersion: string; + /** The latest stable semver available for the component. */ + latestVersion: string; +} + +/** + * A `component:` include line: leading indent, an optional `- ` list marker, the `component:` key, and the URL value + * (optionally quoted). Group 1 is everything up to and including the opening quote, so its length is the column the + * URL value starts at. + */ +const COMPONENT_LINE = /^(\s*(?:-\s*)?component:\s*['"]?)(\S+?)['"]?\s*$/; + +/** + * Split a component URL into its base and `@version` ref. + * + * @param url The full component URL (e.g. `https://gitlab.com/g/p/c@1.2.3`). + * @returns The base URL and the version ref, or `version: undefined` when the URL carries no `@ref`. + */ +export function splitComponentRef(url: string): { baseUrl: string; version: string | undefined } { + const at = url.lastIndexOf('@'); + if (at === -1) return { baseUrl: url, version: undefined }; + return { baseUrl: url.slice(0, at), version: url.slice(at + 1) }; +} + +/** + * Collect the base URLs of every component include pinned to a clean semver ref. + * + * The provider uses this to fetch each project's versions once (deduplicated) before running the synchronous + * {@link findOutdatedComponentRefs} pass. + * + * @param text The full document text. + * @returns Unique base URLs, in first-seen document order. + */ +export function collectSemverComponentBases(text: string): string[] { + const bases = new Set(); + for (const lineText of text.split('\n')) { + const match = COMPONENT_LINE.exec(lineText); + if (!match) continue; + const { baseUrl, version } = splitComponentRef(match[2]); + if (version && isCleanSemver(version)) { + bases.add(baseUrl); + } + } + return [...bases]; +} + +/** + * Find every component include whose pinned semver ref is behind the latest stable release. + * + * @param text The full document text. + * @param availableVersionsFor Resolver returning the known refs (tags/branches) for a component's base URL, or + * `undefined` when they couldn't be determined (offline, unknown project) — those components are skipped. + * @returns One {@link OutdatedComponentRef} per outdated include, in document order. + */ +export function findOutdatedComponentRefs( + text: string, + availableVersionsFor: (baseUrl: string) => readonly string[] | undefined, +): OutdatedComponentRef[] { + const findings: OutdatedComponentRef[] = []; + const lines = text.split('\n'); + + for (let line = 0; line < lines.length; line++) { + const match = COMPONENT_LINE.exec(lines[line]); + if (!match) continue; + + const valueStart = match[1].length; // column the URL value begins at (past any opening quote) + const componentUrl = match[2]; + const { baseUrl, version } = splitComponentRef(componentUrl); + + // Only clean stable semver refs are eligible; everything else is left untouched. + if (!version || !isCleanSemver(version)) continue; + + const versions = availableVersionsFor(baseUrl); + if (!versions || versions.length === 0) continue; + + const latestVersion = getLatestStableSemver(versions); + if (!latestVersion || !isOutdated(version, latestVersion)) continue; + + // The ref sits at the tail of the URL value: + + '@'. + const refStart = valueStart + baseUrl.length + 1; + findings.push({ + componentUrl, + baseUrl, + componentName: baseUrl.split('/').filter(Boolean).pop() ?? baseUrl, + line, + refStart, + refEnd: refStart + version.length, + currentVersion: version, + latestVersion, + }); + } + + return findings; +} diff --git a/src/providers/hoverContentBuilder.ts b/src/providers/hoverContentBuilder.ts index 3b34bee8..8b39ff62 100644 --- a/src/providers/hoverContentBuilder.ts +++ b/src/providers/hoverContentBuilder.ts @@ -6,6 +6,7 @@ import type { Component } from './componentDetector'; import { templateFileUrlForResolved } from '../utils/templateFileUrl'; +import { isOutdated } from '../utils/semver'; /** * The position context the "Open in Detailed View" command needs to round-trip the cursor location back to the @@ -28,12 +29,21 @@ export interface HoverContext { * `/` plain text, otherwise the bare `component.source` string. Skipped entirely if * none of those are present. * 5. `**Version:** ` when set. - * 6. Parameters table (header + separator + one row per `parameters[]`), or omitted when there are no parameters. + * 6. `**Latest:** ` when `latestVersion` is provided — annotated with whether an update is + * available (current ref behind latest) or the pin is already current. Omitted when `latestVersion` is absent + * (non-semver refs, or versions couldn't be resolved). + * 7. Parameters table (header + separator + one row per `parameters[]`), or omitted when there are no parameters. * - * @param component The resolved component to render. - * @param context Cursor + document location used to build the detach-command URL. + * @param component The resolved component to render. + * @param context Cursor + document location used to build the detach-command URL. + * @param latestVersion The latest stable semver available for the component, when known. Compared against + * `component.version` to label the line as an available update or as up-to-date. */ -export function buildComponentHoverMarkdown(component: Component, context: HoverContext): string { +export function buildComponentHoverMarkdown( + component: Component, + context: HoverContext, + latestVersion?: string, +): string { let md = ''; md += `## ${component.name}\n\n`; @@ -69,6 +79,14 @@ export function buildComponentHoverMarkdown(component: Component, context: Hover md += `**Version:** ${component.version}\n\n`; } + if (latestVersion) { + const annotation = + component.version && isOutdated(component.version, latestVersion) + ? ' — ⚠️ update available' + : ' — ✓ up to date'; + md += `**Latest:** ${latestVersion}${annotation}\n\n`; + } + if (component.parameters && component.parameters.length > 0) { md += `### Parameters\n\n`; md += `| Name | Description | Required | Default |\n`; diff --git a/src/providers/hoverProvider.ts b/src/providers/hoverProvider.ts index e2bc824c..1a9630ba 100755 --- a/src/providers/hoverProvider.ts +++ b/src/providers/hoverProvider.ts @@ -1,10 +1,13 @@ import * as vscode from 'vscode'; -import { detectIncludeComponent } from './componentDetector'; +import { detectIncludeComponent, Component } from './componentDetector'; import { Logger } from '../utils/logger'; import { getVariableInfo } from '../utils/gitlabVariables'; import { isGitLabCIFile } from '../utils/gitlabCiFileMatcher'; import { findInputContextAtLine } from './hoverInputContext'; import { buildComponentHoverMarkdown } from './hoverContentBuilder'; +import { getComponentCacheManager } from '../services/cache/componentCacheManager'; +import { isCleanSemver, getLatestStableSemver } from '../utils/semver'; +import type { CachedComponent } from '../types/cache'; export class HoverProvider implements vscode.HoverProvider { private logger = Logger.getInstance(); @@ -75,11 +78,13 @@ export class HoverProvider implements vscode.HoverProvider { this.logger.debug(`[HoverProvider] Component source: ${component.context?.gitlabInstance || component.source || 'unknown'}`, 'HoverProvider'); this.logger.debug(`[HoverProvider] Component has ${component.parameters?.length || 0} parameters`, 'HoverProvider'); + const latestVersion = await this.getLatestStableVersion(component); + const hoverContent = new vscode.MarkdownString( buildComponentHoverMarkdown(component, { documentUri: document.uri.toString(), position: { line: position.line, character: position.character }, - }), + }, latestVersion), ); hoverContent.isTrusted = true; hoverContent.supportThemeIcons = true; @@ -91,6 +96,48 @@ export class HoverProvider implements vscode.HoverProvider { return null; } + /** + * Resolve the latest stable semver available for a hovered component, for the hover's "Latest" line. + * + * Only runs for components pinned to a clean semver ref — floating refs (`main`, `latest`, …) are deliberately + * left without an upgrade hint. Reuses the per-project version cache (so repeated hovers are cheap) and fails + * soft to `undefined` on any lookup error, so a hover never breaks because versions couldn't be fetched. + * + * @param component The resolved component under the cursor. + * @returns The latest stable semver ref, or `undefined` when the ref isn't semver or versions can't be resolved. + */ + private async getLatestStableVersion(component: Component): Promise { + if (!component.version || !isCleanSemver(component.version) || !component.context) { + return undefined; + } + // Hoist the narrowed context into a local so the closure below sees it as non-null without a `!` assertion. + const { context } = component; + try { + const cacheManager = getComponentCacheManager(); + const cached = (await cacheManager.getComponents()).find( + c => + c.gitlabInstance === context.gitlabInstance && + c.sourcePath === context.path && + c.name === component.name, + ); + const lookup: CachedComponent = cached ?? { + name: component.name, + description: component.description ?? '', + parameters: [], + source: component.source ?? `${context.gitlabInstance}/${context.path}`, + sourcePath: context.path, + gitlabInstance: context.gitlabInstance, + version: component.version, + url: '', + }; + const versions = await cacheManager.fetchComponentVersions(lookup); + return getLatestStableSemver(versions) ?? undefined; + } catch (error) { + this.logger.debug(`[HoverProvider] Could not resolve latest version for ${component.name}: ${error}`, 'HoverProvider'); + return undefined; + } + } + /** * Check if we're hovering over a component input parameter and provide input-specific hover info */ diff --git a/src/providers/validationMetadata.ts b/src/providers/validationMetadata.ts index 12dd41f5..06b4be4e 100644 --- a/src/providers/validationMetadata.ts +++ b/src/providers/validationMetadata.ts @@ -72,12 +72,24 @@ export interface MissingRequiredInputMetadata { providedInputs: string[]; } +/** Metadata attached to `code: 'outdated-component-version'` diagnostics (a newer stable semver is available). */ +export interface OutdatedComponentVersionMetadata { + code: 'outdated-component-version'; + /** Full component URL as it appears in the document, including the `@version` suffix. */ + componentUrl: string; + /** The semver ref currently pinned in the document. */ + currentVersion: string; + /** The latest stable semver available — the quick-fix replaces the ref with this. */ + latestVersion: string; +} + /** All diagnostic metadata variants the validation provider attaches. */ export type DiagnosticMetadata = | UnresolvedVariablesMetadata | ComponentFetchFailedMetadata | UnknownInputMetadata - | MissingRequiredInputMetadata; + | MissingRequiredInputMetadata + | OutdatedComponentVersionMetadata; /** * `vscode.Diagnostic` extended with the (optional) typed metadata payload. Used at write sites so diff --git a/src/providers/validationProvider.ts b/src/providers/validationProvider.ts index fd02e0e9..76f87fb9 100644 --- a/src/providers/validationProvider.ts +++ b/src/providers/validationProvider.ts @@ -11,6 +11,12 @@ import { resolveLocalComponent, isUnsupportedLocalPath } from './localComponentR import { attachDiagnosticMetadata, readDiagnosticMetadata } from './validationMetadata'; import type { MissingRequiredInputMetadata } from './validationMetadata'; import type { GitApi, GitRepository } from '../types/vscode-git'; +import type { CachedComponent } from '../types/cache'; +import { + collectSemverComponentBases, + findOutdatedComponentRefs, + type OutdatedComponentRef, +} from './componentVersionCheck'; import { type IncludeEntry, type LocalInclude, @@ -22,6 +28,10 @@ import { export class ValidationProvider implements vscode.CodeActionProvider { private diagnosticCollection: vscode.DiagnosticCollection; + // Separate collection for "newer component version available" warnings. Kept apart from input/structure + // diagnostics so the on-save version check and the on-edit input validation never clobber each other's + // squiggles (VS Code merges diagnostics across collections natively). + private versionDiagnostics: vscode.DiagnosticCollection; private logger = Logger.getInstance(); private cacheManager = getComponentCacheManager(); private validationTimeouts = new Map(); // Throttle validation per document @@ -38,6 +48,9 @@ export class ValidationProvider implements vscode.CodeActionProvider { this.diagnosticCollection = vscode.languages.createDiagnosticCollection('gitlab-component-helper'); context.subscriptions.push(this.diagnosticCollection); + this.versionDiagnostics = vscode.languages.createDiagnosticCollection('gitlab-component-versions'); + context.subscriptions.push(this.versionDiagnostics); + // Register code action provider for the languages the providers run against. this.logger.debug('[ValidationProvider] Registering code action provider for yaml, gitlab-ci, and shellscript', 'ValidationProvider'); context.subscriptions.push( @@ -56,10 +69,15 @@ export class ValidationProvider implements vscode.CodeActionProvider { this.logger.debug('[ValidationProvider] Registering document event listeners', 'ValidationProvider'); context.subscriptions.push( - vscode.workspace.onDidOpenTextDocument(doc => this.validate(doc)), + vscode.workspace.onDidOpenTextDocument(doc => { + this.validate(doc); + // Version checks hit the GitLab tags API, so they run on open/save rather than on every keystroke. + this.checkComponentVersions(doc); + }), vscode.workspace.onDidChangeTextDocument(e => { this.scheduleValidation(e.document); }), + vscode.workspace.onDidSaveTextDocument(doc => this.checkComponentVersions(doc)), vscode.workspace.onDidCloseTextDocument(doc => { const documentId = doc.uri.toString(); // Clear any pending validation timeouts @@ -69,6 +87,7 @@ export class ValidationProvider implements vscode.CodeActionProvider { this.validationTimeouts.delete(documentId); } this.diagnosticCollection.delete(doc.uri); + this.versionDiagnostics.delete(doc.uri); }) ); @@ -89,6 +108,7 @@ export class ValidationProvider implements vscode.CodeActionProvider { vscode.workspace.textDocuments.forEach(doc => { this.logger.debug(`[ValidationProvider] Found open document: ${doc.fileName} (${doc.languageId})`, 'ValidationProvider'); this.validate(doc); + this.checkComponentVersions(doc); }); } @@ -778,6 +798,21 @@ export class ValidationProvider implements vscode.CodeActionProvider { } } } + else if (diagnosticMetadata?.code === 'outdated-component-version') { + const metadata = diagnosticMetadata; + const updateTitle = `Update to ${metadata.latestVersion}`; + if (!actionTitles.has(updateTitle)) { + actionTitles.add(updateTitle); + + const updateAction = new vscode.CodeAction(updateTitle, vscode.CodeActionKind.QuickFix); + updateAction.edit = new vscode.WorkspaceEdit(); + // diagnostic.range covers exactly the version ref, so replacing it bumps only the pinned version. + updateAction.edit.replace(document.uri, diagnostic.range, metadata.latestVersion); + updateAction.diagnostics = [diagnostic]; + updateAction.isPreferred = true; + actions.push(updateAction); + } + } } this.logger.debug(`[ValidationProvider] Returning ${actions.length} unique code actions`, 'ValidationProvider'); @@ -1763,4 +1798,138 @@ export class ValidationProvider implements vscode.CodeActionProvider { return null; } } + + /** + * Recompute the "newer version available" diagnostics for a document and publish them to the dedicated version + * diagnostic collection. Runs on open/save (not on every keystroke) since it queries the GitLab tags API; the + * per-project tag cache keeps repeated runs cheap. No-op — and clears existing markers — when the feature is + * disabled or the document isn't a GitLab CI file. + * + * @param document The document to check. + */ + private async checkComponentVersions(document: vscode.TextDocument): Promise { + if (!this.isDiagnosableDocument(document) || !isGitLabCIFile(document)) { + return; + } + + const config = vscode.workspace.getConfiguration('gitlabComponentHelper'); + if (!config.get('versionCheck.enabled', true)) { + this.versionDiagnostics.delete(document.uri); + return; + } + + const findings = await this.computeOutdatedRefs(document); + + const severity = config.get('versionCheck.severity', 'warning') === 'information' + ? vscode.DiagnosticSeverity.Information + : vscode.DiagnosticSeverity.Warning; + + const diagnostics = findings.map(finding => { + const range = new vscode.Range(finding.line, finding.refStart, finding.line, finding.refEnd); + const diagnostic = new vscode.Diagnostic( + range, + `A newer version of '${finding.componentName}' is available: ${finding.latestVersion} (current: ${finding.currentVersion}).`, + severity, + ); + diagnostic.code = 'outdated-component-version'; + diagnostic.source = 'gitlab-component-helper'; + attachDiagnosticMetadata(diagnostic, { + code: 'outdated-component-version', + componentUrl: finding.componentUrl, + currentVersion: finding.currentVersion, + latestVersion: finding.latestVersion, + }); + return diagnostic; + }); + + this.versionDiagnostics.set(document.uri, diagnostics); + this.logger.debug(`[ValidationProvider] ${diagnostics.length} outdated-version diagnostics for ${document.fileName}`, 'ValidationProvider'); + } + + /** + * Resolve the outdated component refs in a document: collect each clean-semver component's project, fetch its + * available versions once (deduped, via the shared per-project cache), then run the pure detection pass. + * + * @param document The document to scan. + * @returns The outdated refs with their precise document locations; empty when nothing is behind. + */ + private async computeOutdatedRefs(document: vscode.TextDocument): Promise { + const text = document.getText(); + const bases = collectSemverComponentBases(text); + if (bases.length === 0) { + return []; + } + + const versionsByBase = new Map(); + await Promise.all( + bases.map(async baseUrl => { + versionsByBase.set(baseUrl, await this.fetchVersionsForBaseUrl(baseUrl)); + }), + ); + + return findOutdatedComponentRefs(text, baseUrl => versionsByBase.get(baseUrl)); + } + + /** + * Fetch the available version refs (tags + branches) for a component's base URL via the shared per-project + * version cache. Reuses a cached component when one is known (so monorepo tag-pattern scoping applies), + * otherwise builds a minimal lookup from the parsed URL. + * + * @param baseUrl The component base URL (no `@version`). + * @returns The available refs, or `undefined` when the URL can't be parsed or the fetch fails. + */ + private async fetchVersionsForBaseUrl(baseUrl: string): Promise { + const parsed = this.parseComponentUrl(baseUrl); + if (!parsed) { + return undefined; + } + + try { + const cached = (await this.cacheManager.getComponents()).find( + component => + component.gitlabInstance === parsed.gitlabInstance && + component.sourcePath === parsed.projectPath && + component.name === parsed.componentName, + ); + const lookup: CachedComponent = cached ?? { + name: parsed.componentName, + description: '', + parameters: [], + source: `${parsed.gitlabInstance}/${parsed.projectPath}`, + sourcePath: parsed.projectPath, + gitlabInstance: parsed.gitlabInstance, + version: parsed.version, + url: baseUrl, + }; + return await this.cacheManager.fetchComponentVersions(lookup); + } catch (error) { + this.logger.debug(`[ValidationProvider] Could not fetch versions for ${baseUrl}: ${error}`, 'ValidationProvider'); + return undefined; + } + } + + /** + * Bump every outdated component in `document` to its latest stable version in a single edit. Backs the + * "Update all component versions to latest" command. + * + * @param document The document to update. + * @returns The number of component refs updated (0 when everything is already current). + */ + public async updateAllComponentVersions(document: vscode.TextDocument): Promise { + const findings = await this.computeOutdatedRefs(document); + if (findings.length === 0) { + return 0; + } + + const edit = new vscode.WorkspaceEdit(); + for (const finding of findings) { + const range = new vscode.Range(finding.line, finding.refStart, finding.line, finding.refEnd); + edit.replace(document.uri, range, finding.latestVersion); + } + await vscode.workspace.applyEdit(edit); + + // Refresh markers so the just-fixed squiggles clear immediately. + await this.checkComponentVersions(document); + return findings.length; + } } diff --git a/src/utils/semver.ts b/src/utils/semver.ts new file mode 100644 index 00000000..eb5260d7 --- /dev/null +++ b/src/utils/semver.ts @@ -0,0 +1,91 @@ +/** + * Minimal semantic-version helpers for the component version-check feature. + * + * Scope is deliberately narrow: only clean, stable `MAJOR.MINOR.PATCH` refs are recognised, with an optional + * leading `v` tolerated. Everything else is intentionally *not* clean semver here, so the version check leaves it + * untouched rather than guessing at an upgrade: + * - floating refs (`main`, `latest`, `~latest`), + * - partial refs (`1`, `1.2`), + * - pre-release / build-metadata versions (`1.2.3-rc.1`, `1.2.3+build`), + * - commit SHAs. + * + * Kept free of `vscode` imports so the unit suite can exercise it directly. + */ + +/** A parsed clean semantic version. */ +export interface SemVer { + major: number; + minor: number; + patch: number; +} + +/** A clean, stable `X.Y.Z` ref with an optional `v` prefix and nothing else. */ +const CLEAN_SEMVER = /^v?(\d+)\.(\d+)\.(\d+)$/; + +/** + * Parse a ref into its major/minor/patch parts. + * + * @param ref The ref string (surrounding whitespace is tolerated). + * @returns The parsed version, or `null` when `ref` isn't a clean stable semver. + */ +export function parseSemver(ref: string): SemVer | null { + const match = CLEAN_SEMVER.exec(ref.trim()); + if (!match) return null; + return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) }; +} + +/** + * Is `ref` a clean, stable semver (`X.Y.Z`, optional `v` prefix)? + * + * @param ref The ref to test. + */ +export function isCleanSemver(ref: string): boolean { + return parseSemver(ref) !== null; +} + +/** + * Compare two clean semver refs numerically. + * + * @param a First ref. + * @param b Second ref. + * @returns A positive number when `a` > `b`, negative when `a` < `b`, `0` when equal, or `null` when either ref + * isn't clean semver (so callers never treat an unknown comparison as ordered). + */ +export function compareSemver(a: string, b: string): number | null { + const pa = parseSemver(a); + const pb = parseSemver(b); + if (!pa || !pb) return null; + return pa.major - pb.major || pa.minor - pb.minor || pa.patch - pb.patch; +} + +/** + * Pick the highest clean stable semver from a list of refs (e.g. a project's tags + branches), ignoring every entry + * that isn't clean semver. + * + * @param refs Candidate refs. + * @returns The winning ref string verbatim (preserving any `v` prefix as it appeared), or `null` when none of the + * refs are clean semver. + */ +export function getLatestStableSemver(refs: readonly string[]): string | null { + let best: string | null = null; + for (const ref of refs) { + if (!isCleanSemver(ref)) continue; + if (best === null || (compareSemver(ref, best) ?? 0) > 0) { + best = ref; + } + } + return best; +} + +/** + * Is `latest` strictly newer than the pinned `current` ref? + * + * @param current The currently pinned ref. + * @param latest The candidate latest ref. + * @returns `true` only when both are clean semver and `current` < `latest`; `false` otherwise (non-semver or + * equal refs never report as outdated). + */ +export function isOutdated(current: string, latest: string): boolean { + const comparison = compareSemver(current, latest); + return comparison !== null && comparison < 0; +} diff --git a/tests/unit/componentVersionCheck.test.ts b/tests/unit/componentVersionCheck.test.ts new file mode 100644 index 00000000..a7796ca2 --- /dev/null +++ b/tests/unit/componentVersionCheck.test.ts @@ -0,0 +1,94 @@ +// @mocha +/** + * Unit tests for the pure version-check detection in src/providers/componentVersionCheck.ts. A map-based resolver + * stands in for the provider's async GitLab version lookup, so these exercise ref splitting, base collection, the + * clean-semver gate, latest-stable comparison, and the exact column span placed on the version ref. + */ + +import * as assert from 'node:assert/strict'; +import { + splitComponentRef, + collectSemverComponentBases, + findOutdatedComponentRefs, +} from '../../src/providers/componentVersionCheck'; + +const BASE = 'https://gitlab.com/components/opentofu/full-pipeline'; + +/** Build a resolver from a base-URL → versions map for the pure finder. */ +const resolver = (map: Record) => (baseUrl: string) => map[baseUrl]; + +suite('splitComponentRef', () => { + test('splits the trailing @version off the base URL', () => { + assert.deepEqual(splitComponentRef(`${BASE}@1.2.3`), { baseUrl: BASE, version: '1.2.3' }); + }); + + test('reports no version when there is no @ref', () => { + assert.deepEqual(splitComponentRef(BASE), { baseUrl: BASE, version: undefined }); + }); +}); + +suite('collectSemverComponentBases', () => { + test('returns the bases of clean-semver component refs only, deduped', () => { + const text = [ + 'include:', + ` - component: ${BASE}@1.2.3`, + ` - component: ${BASE}@1.2.3`, // duplicate base -> collapsed + ` - component: https://gitlab.com/g/p/other@main`, // floating -> skipped + ` - component: https://gitlab.com/g/p/third@2.0.0`, + ].join('\n'); + assert.deepEqual(collectSemverComponentBases(text), [BASE, 'https://gitlab.com/g/p/third']); + }); +}); + +suite('findOutdatedComponentRefs', () => { + test('flags a component behind the latest stable release', () => { + const text = `include:\n - component: ${BASE}@1.2.3`; + const findings = findOutdatedComponentRefs(text, resolver({ [BASE]: ['main', '1.2.3', '1.5.0', '2.0.0-rc.1'] })); + + assert.equal(findings.length, 1); + const f = findings[0]; + assert.equal(f.currentVersion, '1.2.3'); + assert.equal(f.latestVersion, '1.5.0'); // rc ignored + assert.equal(f.componentName, 'full-pipeline'); + assert.equal(f.line, 1); + }); + + test('places the range on exactly the version ref', () => { + const lineText = ` - component: ${BASE}@1.2.3`; + const text = `include:\n${lineText}`; + const [f] = findOutdatedComponentRefs(text, resolver({ [BASE]: ['1.5.0'] })); + + const expectedStart = lineText.indexOf('@1.2.3') + 1; // just past the '@' + assert.equal(f.refStart, expectedStart); + assert.equal(f.refEnd, expectedStart + '1.2.3'.length); + // The exact slice the squiggle/quick-fix targets is the version only. + assert.equal(lineText.slice(f.refStart, f.refEnd), '1.2.3'); + }); + + test('handles a quoted URL value', () => { + const lineText = ` - component: "${BASE}@1.2.3"`; + const text = `include:\n${lineText}`; + const [f] = findOutdatedComponentRefs(text, resolver({ [BASE]: ['1.5.0'] })); + assert.equal(lineText.slice(f.refStart, f.refEnd), '1.2.3'); + }); + + test('does not flag when already on the latest stable', () => { + const text = `include:\n - component: ${BASE}@1.5.0`; + assert.deepEqual(findOutdatedComponentRefs(text, resolver({ [BASE]: ['1.2.0', '1.5.0'] })), []); + }); + + test('skips floating and non-semver refs', () => { + const text = [ + 'include:', + ` - component: ${BASE}@main`, + ` - component: ${BASE}@~latest`, + ` - component: ${BASE}@1.2`, + ].join('\n'); + assert.deepEqual(findOutdatedComponentRefs(text, resolver({ [BASE]: ['1.5.0'] })), []); + }); + + test('skips components whose versions could not be resolved', () => { + const text = `include:\n - component: ${BASE}@1.2.3`; + assert.deepEqual(findOutdatedComponentRefs(text, resolver({})), []); + }); +}); diff --git a/tests/unit/semver.test.ts b/tests/unit/semver.test.ts new file mode 100644 index 00000000..0be32c1c --- /dev/null +++ b/tests/unit/semver.test.ts @@ -0,0 +1,85 @@ +// @mocha +/** + * Unit tests for the clean-semver helpers in src/utils/semver.ts — the comparison core of the component + * version-check feature. Covers what counts as clean semver (and the many things that deliberately don't), + * numeric ordering, latest-stable selection, and the outdated predicate. + */ + +import * as assert from 'node:assert/strict'; +import { + parseSemver, + isCleanSemver, + compareSemver, + getLatestStableSemver, + isOutdated, +} from '../../src/utils/semver'; + +suite('isCleanSemver', () => { + test('accepts X.Y.Z with and without a v prefix', () => { + assert.equal(isCleanSemver('1.2.3'), true); + assert.equal(isCleanSemver('v1.2.3'), true); + assert.equal(isCleanSemver('0.0.0'), true); + assert.equal(isCleanSemver(' 1.2.3 '), true); // surrounding whitespace tolerated + }); + + test('rejects floating refs, partials, pre-releases, and SHAs', () => { + for (const ref of ['main', 'latest', '~latest', '1', '1.2', 'v1', '1.2.3-rc.1', '1.2.3+build', 'a1b2c3d', '']) { + assert.equal(isCleanSemver(ref), false, `${ref} should not be clean semver`); + } + }); +}); + +suite('parseSemver', () => { + test('splits into numeric parts', () => { + assert.deepEqual(parseSemver('v2.10.4'), { major: 2, minor: 10, patch: 4 }); + }); + + test('returns null for non-semver', () => { + assert.equal(parseSemver('1.2'), null); + }); +}); + +suite('compareSemver', () => { + test('orders by major, then minor, then patch', () => { + assert.ok((compareSemver('2.0.0', '1.9.9') ?? 0) > 0); + assert.ok((compareSemver('1.2.0', '1.10.0') ?? 0) < 0); // numeric, not lexical + assert.ok((compareSemver('1.2.3', '1.2.4') ?? 0) < 0); + }); + + test('treats equal versions as equal regardless of v prefix', () => { + assert.equal(compareSemver('1.2.3', 'v1.2.3'), 0); + }); + + test('returns null when either side is not clean semver', () => { + assert.equal(compareSemver('1.2.3', 'main'), null); + assert.equal(compareSemver('latest', '1.2.3'), null); + }); +}); + +suite('getLatestStableSemver', () => { + test('picks the highest clean semver, ignoring branches and pre-releases', () => { + const refs = ['main', '1.0.0', 'v1.4.2', '1.2.0', '2.0.0-rc.1', 'develop']; + assert.equal(getLatestStableSemver(refs), 'v1.4.2'); + }); + + test('returns null when no clean semver is present', () => { + assert.equal(getLatestStableSemver(['main', 'latest', '1.2']), null); + }); + + test('returns the ref verbatim (preserving prefix style)', () => { + assert.equal(getLatestStableSemver(['1.0.0', 'v2.0.0']), 'v2.0.0'); + }); +}); + +suite('isOutdated', () => { + test('true only when current is strictly behind latest', () => { + assert.equal(isOutdated('1.2.3', '1.5.0'), true); + assert.equal(isOutdated('1.2.3', '1.2.3'), false); + assert.equal(isOutdated('2.0.0', '1.9.9'), false); + }); + + test('false when either ref is not clean semver', () => { + assert.equal(isOutdated('main', '1.5.0'), false); + assert.equal(isOutdated('1.2.3', 'latest'), false); + }); +}); From cdea22ddb9d5c0906110d7b706ad9a92c6a4d84f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 24 Jun 2026 14:38:17 +0000 Subject: [PATCH 18/24] chore(release): 0.13.7 [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 30a7a317..0c49349f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.13.6", + "version": "0.13.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.13.6", + "version": "0.13.7", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.0.2", diff --git a/package.json b/package.json index 84007d21..f01476e0 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.13.6", + "version": "0.13.7", "icon": "images/icon.png", "engines": { "node": ">=22.0.0", From 54aa5fd9beb18894fe8b8de05f7d61d59d3905e8 Mon Sep 17 00:00:00 2001 From: eFAILution <128437814+eFAILution@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:11:39 -0400 Subject: [PATCH 19/24] fix(version-check): expand GitLab variables before fetching versions (#195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Component URLs that use variables (e.g. `$CI_SERVER_FQDN/group/comp@1.2.3`) were passed raw into the version lookup, where URL parsing threw — so no outdated-version squiggle appeared and "Update All Component Versions to Latest" silently skipped them. computeOutdatedRefs now expands variables via the file's repo context (the same getWorkspaceContext + expandComponentUrl path validate() already uses) before fetching versions. The version map stays keyed by the raw base URL — only the lookup uses the expanded URL — so diagnostic ranges, the quick-fix, and the bulk command still land on the document text. Bases whose instance can't be resolved, or that expand incompletely, are left unchecked rather than guessed at. Refs #193 Co-authored-by: eFAILution --- src/providers/validationProvider.ts | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/providers/validationProvider.ts b/src/providers/validationProvider.ts index 76f87fb9..ec5acee1 100644 --- a/src/providers/validationProvider.ts +++ b/src/providers/validationProvider.ts @@ -1850,6 +1850,12 @@ export class ValidationProvider implements vscode.CodeActionProvider { * Resolve the outdated component refs in a document: collect each clean-semver component's project, fetch its * available versions once (deduped, via the shared per-project cache), then run the pure detection pass. * + * Component URLs that use GitLab variables (e.g. `$CI_SERVER_FQDN/group/comp@1.2.3`) are expanded the same way + * {@link validate} expands them — resolving the instance/project from the file's repo context — before their + * versions are fetched. The version map is keyed by the *raw* base URL (as written in the document), because + * the pure finder matches against the document text and places ranges there; only the lookup uses the expanded + * URL. + * * @param document The document to scan. * @returns The outdated refs with their precise document locations; empty when nothing is behind. */ @@ -1860,10 +1866,26 @@ export class ValidationProvider implements vscode.CodeActionProvider { return []; } + // Resolve workspace context once (git remote of the file's repo, or configured sources) only when some base + // URL actually uses variables — it can involve git lookups we don't want to pay for otherwise. + const workspaceContext = bases.some(base => containsGitLabVariables(base)) + ? await this.getWorkspaceContext(document.uri) + : undefined; + const versionsByBase = new Map(); await Promise.all( - bases.map(async baseUrl => { - versionsByBase.set(baseUrl, await this.fetchVersionsForBaseUrl(baseUrl)); + bases.map(async rawBase => { + let lookupUrl = rawBase; + if (containsGitLabVariables(rawBase)) { + if (!workspaceContext?.gitlabInstance) { + return; // can't resolve the instance for this file — leave the component unchecked + } + lookupUrl = expandComponentUrl(rawBase, workspaceContext); + if (containsGitLabVariables(lookupUrl) || lookupUrl.includes('undefined')) { + return; // expansion was incomplete — don't guess + } + } + versionsByBase.set(rawBase, await this.fetchVersionsForBaseUrl(lookupUrl)); }), ); From 14bce5f2ffbbc9bb25e27c10e57b9541f60198b1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 25 Jun 2026 20:14:45 +0000 Subject: [PATCH 20/24] chore(release): 0.13.8 [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 0c49349f..a59cc288 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.13.7", + "version": "0.13.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.13.7", + "version": "0.13.8", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.0.2", diff --git a/package.json b/package.json index f01476e0..07d539c5 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.13.7", + "version": "0.13.8", "icon": "images/icon.png", "engines": { "node": ">=22.0.0", From 8ce9d9bb77efac1855de26142e3f225161fd0fbd Mon Sep 17 00:00:00 2001 From: eFAILution <128437814+eFAILution@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:16:05 -0400 Subject: [PATCH 21/24] docs: refresh, slim, and split the README (#196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(readme): refresh stale content and document version-check - Document the version-check feature: hover "Latest" line, outdated-pin squiggle + quick-fix, "Update All Component Versions to Latest" command, semver-only scope, on-save cadence, and variable resolution - Add the missing settings to the reference table: additionalFileGlobs, versionCheck.enabled, versionCheck.severity - Expand the Commands list (new update command + Update/Reset Cache) - Fix the example include to use `inputs:` (GitLab) instead of `with:` - Correct dev prerequisites: VS Code 1.120.0 floor, npm (not yarn) - Replace `username/...` placeholders with the real eFAILution repo and extension id Refs #193 * docs(readme): slim the listing and move advanced guides to docs/ Trims the README to a focused Marketplace listing (~430 -> ~200 lines): - de-duplicate settings — one reference table; drop the repeated prose and the thrice-repeated componentSources example - tone down the marketing copy and merge overlapping Quick Start / Features - move the deep, technical sections out to user guides under docs/ (not .ai/, which is for machine-readable AICaC): - docs/discovery.md — discovery tuning for non-spec repos - docs/monorepo-tags.md — tag-per-component monorepo conventions - docs/api.md — intended extension API, flagged not-yet-exposed - link to the guides with absolute URLs so they resolve on the Marketplace (relative links don't), and keep all sections expanded (the Marketplace renderer doesn't honor collapsible
) Refs #193 --------- Co-authored-by: eFAILution --- README.md | 389 ++++++++++-------------------------------- docs/api.md | 45 +++++ docs/discovery.md | 53 ++++++ docs/monorepo-tags.md | 39 +++++ 4 files changed, 227 insertions(+), 299 deletions(-) create mode 100644 docs/api.md create mode 100644 docs/discovery.md create mode 100644 docs/monorepo-tags.md diff --git a/README.md b/README.md index f4149453..4069990c 100644 --- a/README.md +++ b/README.md @@ -2,68 +2,60 @@ [![AICaC](https://img.shields.io/badge/AICaC-Comprehensive-success.svg)](https://github.com/eFAILution/AICaC) +> Browse, insert, and manage reusable GitLab CI/CD components in VS Code — from any GitLab instance, public or private. -> Turbocharge your GitLab CI/CD workflow in VS Code! Instantly browse, insert, and manage reusable components from any GitLab instance—public or private. - -### 🎬 See it in Action: Component Browser +### Component Browser ![componentBrowser](https://github.com/user-attachments/assets/6e4ad12e-d3f5-4165-8b72-c59bda51ae38) --- -## ✨ Key Features +## ✨ Features -- **Component Browser**: Explore and insert components from any GitLab project or group -- **Smart Completion**: Context-aware suggestions for components and versions as you type -- **Hover Docs**: See full documentation and parameter hints instantly -- **Input Validation**: Real-time validation of component inputs with intelligent Quick Fix suggestions -- **Local Includes**: Same hover, completion, and validation for `include: - local:` entries that declare a `spec.inputs` block -- **Version/Tag Picker**: Always use the right version—no more guessing -- **Variable Expansion**: Full support for GitLab CI/CD variables in URLs and parameters -- **Lightning Fast**: Caching, batch API calls, and performance optimizations for huge catalogs -- **Private Access**: 🔑 Add private projects/groups with a token (per GitLab instance) +- **Component Browser** — explore and insert components from any GitLab project or group +- **Smart Completion** — context-aware suggestions for components and versions as you type +- **Hover Docs** — full documentation and parameter hints inline +- **Input Validation** — real-time checking of component inputs, with Quick Fix suggestions +- **Local Includes** — the same hover, completion, and validation for `include: - local:` entries that declare a `spec.inputs` block +- **Version Picker & Upgrade Hints** — pick the right tag, and get flagged when a pinned semver falls behind (with one-click updates) +- **Variable Expansion** — resolves GitLab CI/CD variables (`$CI_SERVER_FQDN`, `$CI_PROJECT_PATH`, …) in component URLs +- **Private Access** — add private projects/groups with a token, stored encrypted per instance +- **Fast** — caching and batched API calls keep large catalogs responsive -### 🎬 Smart Autocomplete in Action ![componentAutofill](https://github.com/user-attachments/assets/a76ba19a-240b-4799-a08f-88a78a5cf004) --- ## 🛠️ Quick Start -1. **Install**: Search "GitLab Component Helper" in VS Code Extensions and click Install -2. **Browse Components**: `Ctrl+Shift+P` → "GitLab: Browse Components" -3. **Add Project/Group**: `Ctrl+Shift+P` → "GitLab CI: Add Component Project/Group" (add public or private sources, with or without a token) -4. **Insert & Complete**: Type `component:` in `.gitlab-ci.yml` and get instant, real versioned suggestions -5. **Hover for Docs**: Hover any component URL for instant documentation +1. **Install** "GitLab Component Helper" from the VS Code Extensions view. +2. **Browse** — `Ctrl+Shift+P` → **GitLab CI: Browse Components**. +3. **Add a source** — **GitLab CI: Add Component Project/Group** (public or private; token optional). +4. **Author** — type `component:` in a `.gitlab-ci.yml` and accept the versioned suggestions. +5. **Hover** any component URL for instant documentation. -### 🎬 Hover Documentation Demo ![hoverContext](https://github.com/user-attachments/assets/3c92f336-db04-4a68-80cf-43732d96b6f1) --- -## 🔒 Private Components? No Problem! - -Add any private project or group with a personal access token—just once per GitLab instance! The extension will use your token for all future requests to that instance. +## 🔒 Private Components -**Your security matters:** -- Tokens are stored securely using VS Code's built-in SecretStorage—never in plain text or files. -- Tokens are only used for authenticated API calls to your specified GitLab instance and are never sent to third parties. +Add a private project or group with a personal access token — once per GitLab instance, then it's reused for that instance. Tokens are stored with VS Code **SecretStorage** (encrypted, never in plain text or files) and used only for API calls to the instance you added. -> ⚠️ The legacy `gitlabComponentHelper.gitlabToken` setting stores tokens in plain text in `settings.json` and is **deprecated**. Use the **GitLab CI: Add Component Project/Group** command instead — it stores tokens encrypted via SecretStorage. If you still have a token in that setting, copy it through the command and clear the field. +> ⚠️ The legacy `gitlabComponentHelper.gitlabToken` setting stores tokens in plain text in `settings.json` and is **deprecated**. Use **GitLab CI: Add Component Project/Group** instead, then clear the field. --- -## ⚡ Example Usage +## ⚡ Example ```yaml include: - component: https://gitlab.com/components/terraform@v1.0.0 - with: + inputs: terraform_version: "1.5.0" workspace: "default" - apply: true ``` -Local templates work too — point a `- local:` entry at any workspace YAML that declares a `spec.inputs` block and you get the same hover, completion, and validation as a catalog component: +Local templates work the same way — point a `- local:` entry at a workspace YAML that declares a `spec.inputs` block and you get the same hover, completion, and validation: ```yaml include: @@ -73,340 +65,139 @@ include: job_type: nightly ``` -### 🎬 Adding Component Inputs ![insertInputs](https://github.com/user-attachments/assets/098f4eaf-3c4a-45a8-9caf-9a1351730b93) - -### 🎬 Input Validation & Quick Fixes ![inputsValidation](https://github.com/user-attachments/assets/54d4b2ce-ad84-4bbc-8cd7-911a01565536) --- -## 📝 Template Header Spec (Optional Context) - -To provide consistent context in the Component Browser, you can add **spec-compliant header comments** at the top of a template file. Only these keys are displayed; all other comments are ignored. - -**Supported keys (must be at top of file):** -- `summary` -- `usage` -- `note` - -**Full format:** -```yaml -# @gitlab-component-helper: summary: Push a Helm chart to Sonic -# @gitlab-component-helper: usage: include + set SONIC_TARGET_* variables -# @gitlab-component-helper: note: Requires a protected ref for publish -``` - -**Short format:** -```yaml -# @gch: summary: Push a Helm chart to Sonic -# @gch: usage: include + set SONIC_TARGET_* variables -# @gch: note: Requires a protected ref for publish -``` - -Notes: -- Header comments must appear **before any non-comment content**. -- Multiple `note` entries are supported. -- If no header is present, the Context section stays hidden. +## 🆙 Stay on the Latest Version ---- +When a component is pinned to a semantic version (`X.Y.Z`), the extension checks whether a newer **stable** release exists and helps you upgrade: -## 📄 Raw YAML Toggle +- **Hover** shows the latest available version next to the one you're on — `✓ up to date` or `⚠️ update available`. +- An outdated pin gets a **warning squiggle** on the version ref, with an **Update to `X.Y.Z`** quick fix (`Ctrl+.`). +- **GitLab CI: Update All Component Versions to Latest** rewrites every outdated pin in the active file at once. -Component details include a **Raw YAML** toggle so you can inspect the original template when needed. This is available regardless of whether header comments are present. +Only clean `X.Y.Z` pins (optionally `v`-prefixed) are checked — floating refs (`main`, `latest`, `~latest`), partial pins (`1`, `1.2`), and commit SHAs are left untouched, and pre-release tags are never suggested. The check runs when a CI file is opened or saved (not on every keystroke) and reuses the version cache. Toggle it with `gitlabComponentHelper.versionCheck.enabled`; soften the squiggle to an informational underline with `gitlabComponentHelper.versionCheck.severity`. --- ## ⚙️ Configuration -Add your favorite sources in VS Code settings: +Point the extension at your component sources in VS Code settings: ```json "gitlabComponentHelper.componentSources": [ - { - "name": "OpenTofu Components", - "path": "components/opentofu", - "gitlabInstance": "gitlab.com" - }, - { - "name": "Internal CI Components", - "path": "devops/ci-components", - "gitlabInstance": "gitlab.company.com" - } -] -``` - -### 📁 Recognising non-canonical CI files - -Out of the box the extension activates on `.gitlab-ci.yml`, `.gitlab-ci.yaml`, and anything under a `.gitlab/` directory. If your project keeps included CI configs elsewhere, add their globs to `gitlabComponentHelper.additionalFileGlobs` — these are merged with the built-in defaults: - -```jsonc -"gitlabComponentHelper.additionalFileGlobs": [ - "**/ci/*.yml", - "**/pipelines/**/*.yaml" + { "name": "OpenTofu Components", "path": "components/opentofu", "gitlabInstance": "gitlab.com" }, + { "name": "Internal CI Components", "path": "devops/ci-components", "gitlabInstance": "gitlab.company.com" } ] ``` -Patterns use VS Code's GlobPattern syntax. - ---- - -## 🗂️ Component Discovery - -By default the extension follows the [GitLab CI Components spec](https://docs.gitlab.com/ci/components/#directory-structure) when scanning a source repository — it looks for templates in `templates/` and one subdirectory level deep, matching `*.yml` and `*.yaml`. **No configuration is required for spec-compliant repos.** - -For repositories that pre-date the spec, use a custom directory layout, or store templates outside `templates/`, you can override discovery behavior either globally or per source. - -### Global defaults - -```jsonc -"gitlabComponentHelper.discovery.templateRoots": ["templates", "ci/components"], -"gitlabComponentHelper.discovery.maxDepth": 2, -"gitlabComponentHelper.discovery.filePatterns": ["*.yml", "*.yaml"], -"gitlabComponentHelper.discovery.templateFileNames": ["template.yml", "template.yaml"] -``` - -These four settings are also editable from the **VS Code Settings UI** (search for "GitLab Component Helper Discovery"). - -### Per-source override - -Need different rules for one repository? Add a `discovery` block to that source — its values override the global defaults for that source only. +To recognise CI files kept outside the defaults (`.gitlab-ci.yml`, `.gitlab-ci.yaml`, and anything under `.gitlab/`), add globs — they're merged with the built-in defaults and match at any depth: ```jsonc -"gitlabComponentHelper.componentSources": [ - { - "name": "Standard CI Components", - "path": "components/opentofu", - "gitlabInstance": "gitlab.com" - // uses global discovery defaults - }, - { - "name": "Legacy Internal Components", - "path": "infra/legacy-ci", - "gitlabInstance": "gitlab.company.com", - "discovery": { - "templateRoots": ["ci/components", "shared/pipelines"], - "maxDepth": 2 - } - } -] +"gitlabComponentHelper.additionalFileGlobs": ["**/ci/*.yml", "**/pipelines/**/*.yaml"] ``` -### Limits - -To keep the extension fast and predictable: +**Advanced setups** have their own guides: +- [Component discovery tuning](https://github.com/eFAILution/gitlab-component-helper/blob/main/docs/discovery.md) — scan custom directories or depths for repos that pre-date the [GitLab Components spec](https://docs.gitlab.com/ci/components/#directory-structure). +- [Monorepo tag conventions](https://github.com/eFAILution/gitlab-component-helper/blob/main/docs/monorepo-tags.md) — scope per-component tags in a tag-per-component monorepo. -| Field | Limit | -|---|---| -| `templateRoots` | Up to 5 roots per source | -| `maxDepth` | 0–3 (0 = root only, 1 = one subdirectory level, the spec default) | -| `filePatterns` | Filename globs only — no path globs (e.g. `*.yml` ✅, `foo/*.yml` ❌) | -| `templateFileNames` | Filenames only — no slashes | +Every setting is listed in the [Settings Reference](#-settings-reference) below and editable from the VS Code Settings UI. --- -## 🏷️ Monorepo tag conventions +## 📝 Template Header Spec (Optional) -When a single repository holds **many components**, each component is usually released under its own tags that embed the component name — e.g. `deploy-app-1.1.0`, `deploy-app-2`, `build-image-4.0.0`. Without any hint, the version dropdown for *every* component would list *every* tag in the repo. +Add spec-compliant header comments to the top of a template to surface consistent context in the Component Browser. Supported keys: `summary`, `usage`, `note`. -Set a **tag pattern** on the source to tell the extension how tags map to components. Each component's dropdown is then scoped to its own tags, and the labels are shown without the prefix (e.g. `1.1.0`, not `deploy-app-1.1.0`). The full tag is still what gets inserted, so the GitLab include resolves correctly. - -```jsonc -"gitlabComponentHelper.componentSources": [ - { - "name": "Shared CI Monorepo", - "path": "infrastructure/shared-ci", - "gitlabInstance": "gitlab.com", - "tagPattern": "{name}-{version}" - } -] +```yaml +# @gitlab-component-helper: summary: Push a Helm chart to Sonic +# @gitlab-component-helper: usage: include + set SONIC_TARGET_* variables +# @gitlab-component-helper: note: Requires a protected ref for publish ``` -The template uses two tokens: +The short prefix `# @gch:` works too. Headers must appear before any non-comment content; multiple `note` lines are allowed, and the section stays hidden if no header is present. Component details also include a **Raw YAML** toggle for inspecting the original template. -| Token | Meaning | -|---|---| -| `{name}` | The component (= `templates/` directory) name. | -| `{version}` | The version shown in the dropdown. Matches anything starting with a digit. | +--- -Everything else in the pattern is literal text, so other conventions work too: +## 🧩 Commands -| Tag style | Pattern | -|---|---| -| `deploy-app-1.1.0` | `{name}-{version}` | -| `apps/web/v2.0.0` | `apps/{name}/v{version}` | -| `web_1.0.0` | `{name}_{version}` | +Run from the Command Palette (`Ctrl+Shift+P`): -> **Sibling names:** because `{version}` must start with a digit, a component named `build-image` won't pick up a sibling's `build-image-extra-1.0.0` tags. If you need pre-release-only tags with no leading digit (e.g. `web-rc1`), write a stricter custom pattern for that source. +- **GitLab CI: Browse Components** — explore and insert from your sources +- **GitLab CI: Add Component Project/Group** — add a project/group (optional token for private access) +- **GitLab CI: Update All Component Versions to Latest** — bump outdated semver pins in the active file +- **GitLab CI: Refresh Components Cache** / **Update Cache** / **Reset Cache** — refresh, force a full re-fetch, or clear cached data +- **GitLab CI: Show Cache Status** — cache info and stats -Leave `tagPattern` unset for ordinary single-component repos — their tags are listed as-is. +Debugging commands are also available: **Debug Cache (Detailed)**, **Show Performance Statistics**, and **Test Providers**. --- -## 🧩 Commands -#### Use the Command Palette (`Ctrl+Shift+P`) to access: -- **GitLab CI: Browse Components** — Explore and insert from all your sources -- **GitLab CI: Add Component Project/Group** — Add any project/group (with optional token for private access) -- **GitLab CI: Refresh Component Cache** — Refreshes cached data -- **GitLab CI: Show Cache Status** — See cache info and stats +## 🆘 Troubleshooting ---- +- **No components showing?** Confirm the file's language mode is YAML and that component sources are configured. +- **Version dropdown not loading?** Check connectivity to the GitLab instance, verify token/permissions, and refresh the cache. +- **Still stuck?** Set `gitlabComponentHelper.logLevel` to `DEBUG`, reproduce, and open an issue with the output and your configuration. -## 🆘 Troubleshooting +--- -**Component browser not showing components?** -- Check file language mode is set to YAML -- Verify component sources are configured +## ⚙️ Settings Reference -**Version dropdown not loading?** -- Check network connectivity to GitLab instance -- Verify project permissions and access tokens -- Review cache status and refresh if needed +Add these to `settings.json` or configure them via the Settings UI. -If you encounter issues: -1. Enable debug output and check for error messages -2. Verify your configuration matches the examples above -3. Test with a simple, known-working component source -4. Submit an issue with debug output and configuration details +| Setting | Type | Default | Description | +|--------|------|---------|-------------| +| `gitlabComponentHelper.componentSources` | array | _see [Configuration](#-configuration)_ | GitLab repositories with reusable components. Each item takes `name`, `path`, `gitlabInstance`, and optionally a `discovery` block or a `tagPattern` (see the advanced guides). | +| `gitlabComponentHelper.additionalFileGlobs` | array | `[]` | Extra GitLab CI file globs, merged with the built-in defaults. Patterns match at any depth (e.g. `ci/*.yml` → `**/ci/*.yml`). | +| `gitlabComponentHelper.versionCheck.enabled` | boolean | `true` | Warn when a component pinned to a semantic version has a newer stable release. Checked on open/save. | +| `gitlabComponentHelper.versionCheck.severity` | string | `warning` | Severity of the "newer version available" diagnostic. One of `warning`, `information`. | +| `gitlabComponentHelper.cacheTime` | number | `3600` | Component cache lifetime, in seconds. | +| `gitlabComponentHelper.logLevel` | string | `ERROR` | Logging level. One of `DEBUG`, `INFO`, `WARN`, `ERROR`. | +| `gitlabComponentHelper.autoShowOutput` | boolean | `false` | Show the output channel automatically when the log level changes. | +| `gitlabComponentHelper.httpTimeout` | number | `10000` | HTTP request timeout, in milliseconds. | +| `gitlabComponentHelper.retryAttempts` | number | `3` | Retry attempts for failed HTTP requests. | +| `gitlabComponentHelper.batchSize` | number | `5` | Components processed in parallel per batch. | +| `gitlabComponentHelper.discovery.templateRoots` | array | `["templates"]` | Directories scanned for components (up to 5). See [discovery tuning](https://github.com/eFAILution/gitlab-component-helper/blob/main/docs/discovery.md). | +| `gitlabComponentHelper.discovery.maxDepth` | number | `1` | Subdirectory depth to recurse under each root. Range `0`–`3`. | +| `gitlabComponentHelper.discovery.filePatterns` | array | `["*.yml", "*.yaml"]` | Filename globs for template files (filename only — no path globs). | +| `gitlabComponentHelper.discovery.templateFileNames` | array | `["template.yml", "template.yaml"]` | Filenames recognised inside per-component subfolders. | +| `gitlabComponentHelper.gitlabToken` | string | `""` | ⚠️ **Deprecated** — stores tokens in plain text. Use **GitLab CI: Add Component Project/Group** instead. | --- -## 🔌 API Reference - -The extension exposes the following API for other extensions to consume: - -```typescript -interface GitLabComponentAPI { - getComponentList(): Promise; - getComponentDetails(name: string, version?: string): Promise; - validateComponent(component: Component): ValidationResult; - expandGitLabVariables(text: string, context?: VariableContext): string; - openComponentBrowser(context?: ComponentContext): Promise; -} - -interface Component { - name: string; - description: string; - parameters: ComponentParameter[]; - version?: string; - source?: string; - gitlabInstance?: string; - sourcePath?: string; - availableVersions?: string[]; - originalUrl?: string; -} - -interface ComponentParameter { - name: string; - description?: string; - required: boolean; - type?: string; - default?: any; -} -``` +## 🔌 API -Access through: - -```typescript -const api = await vscode.extensions.getExtension('username.gitlab-component-helper')?.activate(); -if (api) { - const components = await api.getComponentList(); - // Use components... -} -``` +The extension is designed to expose a programmatic API for other extensions to consume — see [docs/api.md](https://github.com/eFAILution/gitlab-component-helper/blob/main/docs/api.md). **Status: not yet exposed** (`activate()` does not return the API today); the doc describes the intended contract. --- ## 🧑‍💻 Development -**Prerequisites:** -- VSCode 1.102.0 or higher -- Node.js 22.x or higher -- Yarn or npm +**Prerequisites:** VS Code 1.120.0+, Node.js 22.x+, npm. -**Setup:** ```bash -git clone https://github.com/username/gitlab-component-helper.git +git clone https://github.com/eFAILution/gitlab-component-helper.git cd gitlab-component-helper -yarn install # or npm install +npm install +npm run compile ``` -**Build:** -```bash -yarn compile # or npm run compile -``` - -**Debug:** -1. Open the project in VSCode -2. Press F5 to start debugging -3. A new VSCode window will open with the extension loaded +Press `F5` to launch an Extension Development Host with the extension loaded. --- ## 🤝 Contributing -1. Fork the repository -2. Create your feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes using [conventional commits](https://www.conventionalcommits.org/): - - `feat:` for new features - - `fix:` for bug fixes - - `docs:` for documentation changes - - `chore:` for maintenance tasks -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request - -### Automated Releases - -This project uses [semantic-release](https://semantic-release.gitbook.io/) for automated versioning and releases. When your PR is merged to `main`: - -- Version is automatically bumped based on commit messages -- CHANGELOG.md is updated -- GitHub release is created with packaged extension -- No manual versioning needed! +1. Fork and branch (`git checkout -b feat/your-feature`). +2. Commit using [conventional commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `docs:`, `chore:`, …). +3. Open a Pull Request. -See [SEMANTIC_RELEASE.md](./SEMANTIC_RELEASE.md) for more details. +Releases are automated with [semantic-release](https://semantic-release.gitbook.io/) — version, `CHANGELOG.md`, and the GitHub release are derived from commit messages on merge. See [SEMANTIC_RELEASE.md](./SEMANTIC_RELEASE.md). --- ## 📄 License -Distributed under the MIT License. See `LICENSE` for more information. - ---- - -## ⚙️ User Settings Reference - -The following settings are available for the GitLab Component Helper extension. Add these to your VS Code `settings.json` or configure via the Settings UI: - -| Setting | Type | Default | Description | -|--------|------|---------|-------------| -| `gitlabComponentHelper.gitlabToken` | string | `""` | ⚠️ **Deprecated** — stores tokens in plain text. Use the **GitLab CI: Add Component Project/Group** command instead, which encrypts via SecretStorage. | -| `gitlabComponentHelper.cacheTime` | number | `3600` | Cache time for components in seconds | -| `gitlabComponentHelper.logLevel` | string | `ERROR` | Logging level for component service. One of: `DEBUG`, `INFO`, `WARN`, `ERROR` | -| `gitlabComponentHelper.autoShowOutput` | boolean | `false` | Automatically show output channel when log level changes | -| `gitlabComponentHelper.httpTimeout` | number | `10000` | HTTP request timeout in milliseconds | -| `gitlabComponentHelper.retryAttempts` | number | `3` | Number of retry attempts for failed HTTP requests | -| `gitlabComponentHelper.batchSize` | number | `5` | Number of components to process in parallel batches | -| `gitlabComponentHelper.componentSources` | array | See below | GitLab repositories containing reusable CI/CD components. Each item supports an optional `discovery` block (override discovery defaults) and, for [monorepos](#-monorepo-tag-conventions), a `tagPattern` string. | -| `gitlabComponentHelper.discovery.templateRoots` | array | `["templates"]` | Repository directories scanned for components. Up to 5 entries. | -| `gitlabComponentHelper.discovery.maxDepth` | number | `1` | Subdirectory depth to recurse under each root. Range `0`–`3`. | -| `gitlabComponentHelper.discovery.filePatterns` | array | `["*.yml", "*.yaml"]` | Filename globs identifying component template files. Filename only — no path globs. | -| `gitlabComponentHelper.discovery.templateFileNames` | array | `["template.yml", "template.yaml"]` | Filenames recognised inside per-component subfolders (e.g. `templates/foo/template.yml`). | - -### Example `componentSources` value: -```json -"gitlabComponentHelper.componentSources": [ - { - "name": "GitLab CI Examples", - "path": "gitlab-org/gitlab-foss", - "gitlabInstance": "gitlab.com" - }, - { - "name": "OpenTofu Components", - "path": "components/opentofu", - "gitlabInstance": "gitlab.com" - } -] -``` - -> For more details on each setting, see the extension's package.json or the VS Code Settings UI. +MIT — see [`LICENSE`](./LICENSE). diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 00000000..ad1284f2 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,45 @@ +# Extension API + +> **Status: not yet exposed.** `activate()` does not currently return this API, so `getExtension(...).activate()` resolves to `undefined`. This documents the *intended* contract for other extensions to consume; track its implementation before depending on it. See the [README](../README.md) for user-facing features. + +## Intended interface + +```typescript +interface GitLabComponentAPI { + getComponentList(): Promise; + getComponentDetails(name: string, version?: string): Promise; + validateComponent(component: Component): ValidationResult; + expandGitLabVariables(text: string, context?: VariableContext): string; + openComponentBrowser(context?: ComponentContext): Promise; +} + +interface Component { + name: string; + description: string; + parameters: ComponentParameter[]; + version?: string; + source?: string; + gitlabInstance?: string; + sourcePath?: string; + availableVersions?: string[]; + originalUrl?: string; +} + +interface ComponentParameter { + name: string; + description?: string; + required: boolean; + type?: string; + default?: unknown; +} +``` + +## Intended usage + +```typescript +const api = await vscode.extensions.getExtension('eFAILution.gitlab-component-helper')?.activate(); +if (api) { + const components = await api.getComponentList(); + // Use components... +} +``` diff --git a/docs/discovery.md b/docs/discovery.md new file mode 100644 index 00000000..5dac3d96 --- /dev/null +++ b/docs/discovery.md @@ -0,0 +1,53 @@ +# Component Discovery + +> Advanced configuration for how the extension scans a source repository for components. Most users need none of this — see the [README](../README.md) for the basics. + +By default the extension follows the [GitLab CI Components spec](https://docs.gitlab.com/ci/components/#directory-structure) when scanning a source repository: it looks for templates in `templates/` and one subdirectory level deep, matching `*.yml` and `*.yaml`. **No configuration is required for spec-compliant repos.** + +For repositories that pre-date the spec, use a custom layout, or store templates outside `templates/`, override discovery either globally or per source. + +## Global defaults + +```jsonc +"gitlabComponentHelper.discovery.templateRoots": ["templates", "ci/components"], +"gitlabComponentHelper.discovery.maxDepth": 2, +"gitlabComponentHelper.discovery.filePatterns": ["*.yml", "*.yaml"], +"gitlabComponentHelper.discovery.templateFileNames": ["template.yml", "template.yaml"] +``` + +These four settings are also editable from the **VS Code Settings UI** (search for "GitLab Component Helper Discovery"). + +## Per-source override + +Need different rules for one repository? Add a `discovery` block to that source — its values override the global defaults for that source only. + +```jsonc +"gitlabComponentHelper.componentSources": [ + { + "name": "Standard CI Components", + "path": "components/opentofu", + "gitlabInstance": "gitlab.com" + // uses global discovery defaults + }, + { + "name": "Legacy Internal Components", + "path": "infra/legacy-ci", + "gitlabInstance": "gitlab.company.com", + "discovery": { + "templateRoots": ["ci/components", "shared/pipelines"], + "maxDepth": 2 + } + } +] +``` + +## Limits + +To keep the extension fast and predictable: + +| Field | Limit | +|---|---| +| `templateRoots` | Up to 5 roots per source | +| `maxDepth` | 0–3 (0 = root only, 1 = one subdirectory level, the spec default) | +| `filePatterns` | Filename globs only — no path globs (e.g. `*.yml` ✅, `foo/*.yml` ❌) | +| `templateFileNames` | Filenames only — no slashes | diff --git a/docs/monorepo-tags.md b/docs/monorepo-tags.md new file mode 100644 index 00000000..b4d356a4 --- /dev/null +++ b/docs/monorepo-tags.md @@ -0,0 +1,39 @@ +# Monorepo Tag Conventions + +> How to scope per-component versions in a tag-per-component monorepo. Skip this for ordinary single-component repos — their tags are listed as-is. See the [README](../README.md) for the basics. + +When a single repository holds **many components**, each component is usually released under its own tags that embed the component name — e.g. `deploy-app-1.1.0`, `deploy-app-2`, `build-image-4.0.0`. Without any hint, the version dropdown for *every* component would list *every* tag in the repo. + +Set a **tag pattern** on the source to tell the extension how tags map to components. Each component's dropdown is then scoped to its own tags, and labels are shown without the prefix (e.g. `1.1.0`, not `deploy-app-1.1.0`). The full tag is still what gets inserted, so the GitLab include resolves correctly. + +```jsonc +"gitlabComponentHelper.componentSources": [ + { + "name": "Shared CI Monorepo", + "path": "infrastructure/shared-ci", + "gitlabInstance": "gitlab.com", + "tagPattern": "{name}-{version}" + } +] +``` + +The template uses two tokens: + +| Token | Meaning | +|---|---| +| `{name}` | The component (= `templates/` directory) name. | +| `{version}` | The version shown in the dropdown. Matches anything starting with a digit. | + +Everything else in the pattern is literal text, so other conventions work too: + +| Tag style | Pattern | +|---|---| +| `deploy-app-1.1.0` | `{name}-{version}` | +| `apps/web/v2.0.0` | `apps/{name}/v{version}` | +| `web_1.0.0` | `{name}_{version}` | + +> **Sibling names:** because `{version}` must start with a digit, a component named `build-image` won't pick up a sibling's `build-image-extra-1.0.0` tags. If you need pre-release-only tags with no leading digit (e.g. `web-rc1`), write a stricter custom pattern for that source. + +Leave `tagPattern` unset for ordinary single-component repos. + +> **Note:** the [version-check feature](../README.md#-stay-on-the-latest-version) only compares clean `X.Y.Z` refs, so components pinned to a full monorepo tag (e.g. `deploy-app@deploy-app-1.1.0`) are not currently flagged as outdated. Scoped monorepo comparison is planned. From ed0f0345379d49d04a45f2699d75aeefcea9f7bb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 26 Jun 2026 14:20:00 +0000 Subject: [PATCH 22/24] chore(release): 0.13.9 [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 a59cc288..bb4c9826 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.13.8", + "version": "0.13.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.13.8", + "version": "0.13.9", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.0.2", diff --git a/package.json b/package.json index 07d539c5..4af11e57 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.13.8", + "version": "0.13.9", "icon": "images/icon.png", "engines": { "node": ">=22.0.0", From 2cad09fb6900244327193b18ca33b076558512af Mon Sep 17 00:00:00 2001 From: Simon Heather <32168619+X-Guardian@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:26:10 +0100 Subject: [PATCH 23/24] fix: Improve expired token user experience (#198) * fix: Improve expired token user experience * Refine as per review comments --------- Co-authored-by: Simon Heather --- README.md | 3 + src/errors/guards.ts | 48 ++++++ src/errors/index.ts | 5 + src/extension.ts | 1 + src/providers/componentBrowserProvider.ts | 184 +++++++++++++++++++-- src/providers/validationMetadata.ts | 6 + src/providers/validationProvider.ts | 50 ++++-- src/services/component/commands.ts | 4 +- src/services/component/componentFetcher.ts | 87 +++++----- src/services/component/componentService.ts | 11 ++ src/services/component/tokenManager.ts | 14 +- src/utils/httpClient.ts | 24 +-- tests/unit/authError.test.ts | 58 +++++++ 13 files changed, 409 insertions(+), 86 deletions(-) create mode 100644 src/errors/guards.ts create mode 100644 tests/unit/authError.test.ts diff --git a/README.md b/README.md index 4069990c..f3ed0749 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ Add a private project or group with a personal access token — once per GitLab instance, then it's reused for that instance. Tokens are stored with VS Code **SecretStorage** (encrypted, never in plain text or files) and used only for API calls to the instance you added. +Create the token with the **`read_api`** scope, and ensure its user has at least **Reporter** access to the project. + > ⚠️ The legacy `gitlabComponentHelper.gitlabToken` setting stores tokens in plain text in `settings.json` and is **deprecated**. Use **GitLab CI: Add Component Project/Group** instead, then clear the field. --- @@ -138,6 +140,7 @@ Debugging commands are also available: **Debug Cache (Detailed)**, **Show Perfor ## 🆘 Troubleshooting - **No components showing?** Confirm the file's language mode is YAML and that component sources are configured. +- **HTTP 401 / "token expired" errors?** The token is expired or invalid — re-add it via **GitLab CI: Add Component Project/Group**, or the **Update Token** action in the error view. Confirm it has the **`read_api`** scope and at least **Reporter** access. - **Version dropdown not loading?** Check connectivity to the GitLab instance, verify token/permissions, and refresh the cache. - **Still stuck?** Set `gitlabComponentHelper.logLevel` to `DEBUG`, reproduce, and open an issue with the output and your configuration. diff --git a/src/errors/guards.ts b/src/errors/guards.ts new file mode 100644 index 00000000..51af56d3 --- /dev/null +++ b/src/errors/guards.ts @@ -0,0 +1,48 @@ +/** + * Runtime predicates over caught error values. + * + * These narrow the error classes declared in `./types` — kept separate so `types.ts` stays purely + * declarative (enum, classes, type aliases) and behaviour lives here. + */ + +import { ErrorCode, GitLabComponentError, NetworkError } from './types'; + +/** + * Extract an HTTP status code from an unknown thrown value. + * + * Prefers the typed `NetworkError.details.statusCode`, then falls back to a `statusCode` property on + * any error-shaped object so non-`NetworkError` throws (e.g. raw fetch errors) are still recognised. + * + * @param error The caught value (typed `unknown` at catch sites). + * @returns The HTTP status code if one can be safely extracted, otherwise `undefined`. + */ +export function extractStatusCode(error: unknown): number | undefined { + if (error instanceof NetworkError && error.details?.statusCode) { + return error.details.statusCode; + } + if (typeof error === 'object' && error !== null && 'statusCode' in error) { + const candidate = (error as { statusCode: unknown }).statusCode; + if (typeof candidate === 'number') { + return candidate; + } + } + return undefined; +} + +/** + * Whether a caught value represents a GitLab authentication failure (expired/invalid/missing token). + * + * Recognises both the typed `UNAUTHORIZED` error code and a raw 401/403 status, so callers don't have + * to special-case how deep in the stack the error was constructed. + * + * @param error The caught value (typed `unknown` at catch sites). + * @returns `true` if the error is an `UNAUTHORIZED` GitLab error or carries a 401/403 status, + * otherwise `false`. + */ +export function isAuthError(error: unknown): boolean { + if (error instanceof GitLabComponentError && error.code === ErrorCode.UNAUTHORIZED) { + return true; + } + const status = extractStatusCode(error); + return status === 401 || status === 403; +} diff --git a/src/errors/index.ts b/src/errors/index.ts index 27c58703..f97ee1c8 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -12,6 +12,11 @@ export { ConfigurationError } from './types'; +export { + extractStatusCode, + isAuthError +} from './guards'; + export { ErrorHandler, ErrorHandlerOptions, diff --git a/src/extension.ts b/src/extension.ts index 1c340d11..f90eab36 100755 --- a/src/extension.ts +++ b/src/extension.ts @@ -132,6 +132,7 @@ export function activate(context: vscode.ExtensionContext) { logger.debug('[Extension] Registering addProjectToken command...', 'Extension'); const service = getComponentService(); service.setSecretStorage(context.secrets); + context.subscriptions.push(service); registerAddProjectTokenCommand(context, service); // Register component browser command diff --git a/src/providers/componentBrowserProvider.ts b/src/providers/componentBrowserProvider.ts index 314680bb..06554757 100644 --- a/src/providers/componentBrowserProvider.ts +++ b/src/providers/componentBrowserProvider.ts @@ -130,6 +130,10 @@ export class ComponentBrowserProvider { 'gitlabComponentHelper.componentSources' ); return; + case 'updateToken': + await vscode.commands.executeCommand('gitlabComponentHelper.addProjectToken'); + await this.loadComponents(true); + return; case 'fetchVersion': await this.fetchAndCacheVersion(message.componentName, message.sourcePath, message.gitlabInstance, message.version); return; @@ -320,10 +324,7 @@ export class ComponentBrowserProvider { // If no components found but we have cache errors, show errors if (allComponents.length === 0 && Object.keys(cacheErrors).length > 0) { - const errorMessages = Object.entries(cacheErrors).map(([source, error]) => - `${source}: ${error}` - ); - this.panel.webview.html = this.getErrorsHtml(errorMessages); + this.panel.webview.html = this.getErrorsHtml(cacheErrors); return; } @@ -706,16 +707,22 @@ export class ComponentBrowserProvider { const versionDataJson = JSON.stringify(versionData); // Build error section HTML + const hasAuthError = Object.values(cacheErrors).some(error => this.classifySourceError(error).isAuth); const errorSectionHtml = hasErrors ? `
⚠️ Cache Errors
- ${Object.entries(cacheErrors).map(([source, error], index) => ` + ${Object.entries(cacheErrors).map(([source, error], index) => { + const { summary } = this.classifySourceError(error); + return `
-
${source}
+
${this.escapeHtml(source)}
+
${this.escapeHtml(summary)}
- +
- `).join('')} + `; + }).join('')} + ${hasAuthError ? '' : ''}
` : ''; @@ -882,6 +889,19 @@ export class ComponentBrowserProvider { font-weight: bold; color: var(--vscode-errorForeground); } + .error-summary { + color: var(--vscode-errorForeground); + margin: 4px 0; + } + .update-token-btn { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: none; + padding: 6px 14px; + border-radius: 2px; + cursor: pointer; + margin-top: 6px; + } .error-toggle { background: none; border: none; @@ -1130,6 +1150,10 @@ export class ComponentBrowserProvider { } } + function updateToken() { + vscode.postMessage({ command: 'updateToken' }); + } + function toggleSource(sourceId) { const content = document.getElementById('source-content-' + sourceId); const icon = document.getElementById('source-icon-' + sourceId); @@ -2127,6 +2151,35 @@ export class ComponentBrowserProvider { `; } + /** + * Classify a per-source error message so the error views can tell an expired/invalid token apart + * from a generic failure. Auth errors get a plain-language summary and an "Update Token" action; + * everything else falls back to the raw message. The raw text is always preserved for the details + * disclosure so we never hide what GitLab actually returned. + * + * @param rawError The error message stored for a source (e.g. `HTTP 401: {"error":"invalid_token",…}`). + * @returns `isAuth` — whether the message looks like a 401/403/token failure; `summary` — a + * plain-language message for auth errors, or the unchanged `rawError` otherwise. + */ + private classifySourceError(rawError: string): { isAuth: boolean; summary: string } { + const lower = rawError.toLowerCase(); + const isAuth = + /\bhttp\s*40[13]\b/.test(lower) || + lower.includes('invalid_token') || + lower.includes('token is expired') || + lower.includes('unauthorized') || + lower.includes('forbidden'); + + if (!isAuth) { + return { isAuth: false, summary: rawError }; + } + + const summary = lower.includes('expired') + ? 'Your GitLab access token has expired. Update it to reload these components.' + : 'GitLab rejected the access token for this source. Update it to reload these components.'; + return { isAuth: true, summary }; + } + private escapeHtml(value: string): string { return value .replace(/&/g, '&') @@ -2229,7 +2282,32 @@ export class ComponentBrowserProvider { `; } - private getErrorsHtml(errors: string[]): string { + /** + * Build the full-screen error view shown when no components could be loaded but sources reported + * errors. Each source is rendered with a plain-language summary (auth errors are humanised via + * {@link classifySourceError}) and its raw message behind a "Show details" toggle. An "Update Token" + * button is added when any source failed with an auth error. + * + * @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 { + const entries = Object.entries(errors); + const hasAuthError = entries.some(([, error]) => this.classifySourceError(error).isAuth); + + const errorItemsHtml = entries.map(([source, error], index) => { + const { summary } = this.classifySourceError(error); + const detailsId = `error-details-${index}`; + return ` +
+
${this.escapeHtml(source)}
+
${this.escapeHtml(summary)}
+ + +
+ `; + }).join(''); + return ` @@ -2245,7 +2323,6 @@ export class ComponentBrowserProvider { background-color: var(--vscode-editor-background); } .errors { - color: var(--vscode-errorForeground); background-color: var(--vscode-inputValidation-errorBackground); border: 1px solid var(--vscode-inputValidation-errorBorder); padding: 10px; @@ -2253,7 +2330,27 @@ export class ComponentBrowserProvider { margin: 20px 0; } .error-item { - margin: 8px 0; + 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; + word-break: break-word; + font-size: 0.85em; + background-color: var(--vscode-textCodeBlock-background); + border-radius: 3px; } button { background-color: var(--vscode-button-background); @@ -2264,6 +2361,13 @@ export class ComponentBrowserProvider { cursor: pointer; margin-right: 8px; } + .link-button { + background: none; + color: var(--vscode-textLink-foreground); + padding: 0; + margin: 4px 0 0; + text-decoration: underline; + } @@ -2272,10 +2376,11 @@ export class ComponentBrowserProvider {

There were errors loading components from the configured sources:

- ${errors.map((error: string) => `
• ${error}
`).join('')} + ${errorItemsHtml}
+ ${hasAuthError ? '' : ''}
@@ -2290,6 +2395,17 @@ export class ComponentBrowserProvider { function openSettings() { vscode.postMessage({ command: 'openSettings' }); } + + function updateToken() { + vscode.postMessage({ command: 'updateToken' }); + } + + function toggleDetails(id, btn) { + const el = document.getElementById(id); + const showing = el.style.display !== 'none'; + el.style.display = showing ? 'none' : 'block'; + btn.textContent = showing ? 'Show details' : 'Hide details'; + } @@ -2398,8 +2514,18 @@ ${sourceErrors.size > 0 ? '\nErrors:\n' + Array.from(sourceErrors.entries()).map } + /** + * Build the catch-all error view shown when loading the component browser throws (as opposed to a + * per-source failure). Auth errors are humanised via {@link classifySourceError} and get an "Update + * Token" button with the raw message behind a "Show details" toggle; other errors show the message + * directly. Both keep "Try Again" and "Open Settings". + * + * @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 { const message = error instanceof Error ? error.message : String(error); + const { isAuth, summary } = this.classifySourceError(message); return ` @@ -2422,6 +2548,15 @@ ${sourceErrors.size > 0 ? '\nErrors:\n' + Array.from(sourceErrors.entries()).map border-radius: 5px; margin: 20px 0; } + .error-raw { + margin: 8px 0 0; + padding: 8px; + white-space: pre-wrap; + word-break: 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); @@ -2431,16 +2566,28 @@ ${sourceErrors.size > 0 ? '\nErrors:\n' + Array.from(sourceErrors.entries()).map cursor: pointer; margin-right: 8px; } + .link-button { + background: none; + color: var(--vscode-textLink-foreground); + padding: 0; + margin: 4px 0 0; + text-decoration: underline; + }

Component Loading Error

- Error: ${message} + ${isAuth + ? `${this.escapeHtml(summary)} + + ` + : `Error: ${this.escapeHtml(message)}`}
+ ${isAuth ? '' : ''}
@@ -2455,6 +2602,17 @@ ${sourceErrors.size > 0 ? '\nErrors:\n' + Array.from(sourceErrors.entries()).map function openSettings() { vscode.postMessage({ command: 'openSettings' }); } + + function updateToken() { + vscode.postMessage({ command: 'updateToken' }); + } + + function toggleDetails(id, btn) { + const el = document.getElementById(id); + const showing = el.style.display !== 'none'; + el.style.display = showing ? 'none' : 'block'; + btn.textContent = showing ? 'Show details' : 'Hide details'; + } diff --git a/src/providers/validationMetadata.ts b/src/providers/validationMetadata.ts index 06b4be4e..d2f280b3 100644 --- a/src/providers/validationMetadata.ts +++ b/src/providers/validationMetadata.ts @@ -40,6 +40,11 @@ export interface ComponentFetchFailedMetadata extends ComponentRefBase { code: 'component-fetch-failed'; } +/** Metadata attached to `code: 'component-auth-failed'` diagnostics (401/403 — token expired/invalid). */ +export interface ComponentAuthFailedMetadata extends ComponentRefBase { + code: 'component-auth-failed'; +} + /** Metadata attached to `code: 'unknown-input'` diagnostics (input name not in the component's spec). */ export interface UnknownInputMetadata { code: 'unknown-input'; @@ -87,6 +92,7 @@ export interface OutdatedComponentVersionMetadata { export type DiagnosticMetadata = | UnresolvedVariablesMetadata | ComponentFetchFailedMetadata + | ComponentAuthFailedMetadata | UnknownInputMetadata | MissingRequiredInputMetadata | OutdatedComponentVersionMetadata; diff --git a/src/providers/validationProvider.ts b/src/providers/validationProvider.ts index ec5acee1..6ffd622e 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 { resolveLocalComponent, isUnsupportedLocalPath } from './localComponentResolver'; import { attachDiagnosticMetadata, readDiagnosticMetadata } from './validationMetadata'; +import { isAuthError } from '../errors'; import type { MissingRequiredInputMetadata } from './validationMetadata'; import type { GitApi, GitRepository } from '../types/vscode-git'; import type { CachedComponent } from '../types/cache'; @@ -91,6 +92,16 @@ export class ValidationProvider implements vscode.CodeActionProvider { }) ); + // A newly-saved token can turn a `component-auth-failed` (or generic fetch failure) into a + // successful fetch, so re-run validation on open documents to clear the stale diagnostics. + this.logger.debug('[ValidationProvider] Subscribing to token changes', 'ValidationProvider'); + context.subscriptions.push( + getComponentService().onDidChangeToken(() => { + this.logger.debug('[ValidationProvider] Token changed - revalidating open documents', 'ValidationProvider'); + this.revalidateOpenDocuments(); + }) + ); + // Register command for showing input suggestions this.logger.debug('[ValidationProvider] Registering input suggestion commands', 'ValidationProvider'); context.subscriptions.push( @@ -266,8 +277,10 @@ export class ValidationProvider implements vscode.CodeActionProvider { // First try to find the component in cache (using expanded URL) let component = await this.findComponentInCache(expandedUrl); - // Track if component fetch failed + // Track if component fetch failed, and whether the failure was an auth/token error + // (which gets a distinct diagnostic + "update token" quick fix). let componentFetchFailed = false; + let componentAuthFailed = false; // If not found in cache, fetch from API and cache it (using expanded URL) if (!component) { @@ -307,33 +320,35 @@ export class ValidationProvider implements vscode.CodeActionProvider { } catch (error) { this.logger.debug(`[ValidationProvider] Error fetching component ${expandedUrl}: ${error}`, 'ValidationProvider'); componentFetchFailed = true; + componentAuthFailed = isAuthError(error); } } else { this.logger.debug(`[ValidationProvider] Using cached component: ${component.name}`, 'ValidationProvider'); } // If component fetch failed, add a single diagnostic about the fetch failure - // instead of showing "unknown input" warnings for all inputs + // instead of showing "unknown input" warnings for all inputs. An auth failure gets a + // distinct message + "update token" quick fix; everything else stays generic. if (componentFetchFailed) { const line = this.findLineForComponent(document, includes, includeIndex); const range = new vscode.Range(line, 0, line, document.lineAt(line).text.length); - const diagnostic = new vscode.Diagnostic( - range, - `Unable to fetch component '${componentUrl}'. Component may not exist, be inaccessible, or the URL may be incorrect.`, - vscode.DiagnosticSeverity.Warning - ); + const code = componentAuthFailed ? 'component-auth-failed' : 'component-fetch-failed'; + const message = componentAuthFailed + ? `GitLab token for '${componentUrl}' is missing, invalid, or expired. Update it to validate this component.` + : `Unable to fetch component '${componentUrl}'. Component may not exist, be inaccessible, or the URL may be incorrect.`; - diagnostic.code = 'component-fetch-failed'; + const diagnostic = new vscode.Diagnostic(range, message, vscode.DiagnosticSeverity.Warning); + diagnostic.code = code; diagnostic.source = 'gitlab-component-helper'; attachDiagnosticMetadata(diagnostic, { - code: 'component-fetch-failed', + code, componentUrl: componentUrl, expandedUrl: expandedUrl, includeInputs: include.inputs || {} }); - this.logger.debug(`[ValidationProvider] Created component fetch failure diagnostic for ${componentUrl}`, 'ValidationProvider'); + this.logger.debug(`[ValidationProvider] Created component ${componentAuthFailed ? 'auth' : 'fetch'} failure diagnostic for ${componentUrl}`, 'ValidationProvider'); diagnostics.push(diagnostic); // Skip input validation for failed component fetches @@ -700,6 +715,21 @@ export class ValidationProvider implements vscode.CodeActionProvider { actions.push(validateUrlAction); } } + else if (diagnosticMetadata?.code === 'component-auth-failed') { + const updateTokenTitle = `Update GitLab token`; + if (!actionTitles.has(updateTokenTitle)) { + actionTitles.add(updateTokenTitle); + + const updateTokenAction = new vscode.CodeAction(updateTokenTitle, vscode.CodeActionKind.QuickFix); + updateTokenAction.command = { + title: updateTokenTitle, + command: 'gitlabComponentHelper.addProjectToken' + }; + updateTokenAction.diagnostics = [diagnostic]; + updateTokenAction.isPreferred = true; + actions.push(updateTokenAction); + } + } else if (diagnosticMetadata?.code === 'missing-required-input') { const metadata = diagnosticMetadata; const missingInput = metadata.missingInput; diff --git a/src/services/component/commands.ts b/src/services/component/commands.ts index 142d92f5..35b57ed7 100644 --- a/src/services/component/commands.ts +++ b/src/services/component/commands.ts @@ -14,6 +14,7 @@ export function registerAddProjectTokenCommand( vscode.commands.registerCommand('gitlabComponentHelper.addProjectToken', async () => { // Prompt for the full GitLab URL const url = await vscode.window.showInputBox({ + title: 'GitLab Component Helper', prompt: 'Enter the full GitLab project or group URL (e.g. https://gitlab.com/mygroup/myproject)', ignoreFocusOut: true, placeHolder: 'https://gitlab.com/mygroup/myproject' @@ -35,7 +36,8 @@ export function registerAddProjectTokenCommand( // Prompt for token (optional) const token = await vscode.window.showInputBox({ - prompt: `Enter GitLab personal access token for ${gitlabInstance} (leave blank for public access)`, + title: 'GitLab Component Helper', + prompt: `Enter GitLab personal access token for ${gitlabInstance} (needs the read_api scope; leave blank for public access)`, password: true, ignoreFocusOut: true }); diff --git a/src/services/component/componentFetcher.ts b/src/services/component/componentFetcher.ts index 79324e41..7e6e2271 100644 --- a/src/services/component/componentFetcher.ts +++ b/src/services/component/componentFetcher.ts @@ -11,6 +11,7 @@ import type { GitLabProjectInfo, GitLabTreeItem } from '../../types/api'; import { HttpClient } from '../../utils/httpClient'; import { Logger } from '../../utils/logger'; import { GitLabSpecParser, ComponentVariable } from '../../parsers/specParser'; +import { isAuthError } from '../../errors'; import { TokenManager } from './tokenManager'; import { UrlParser } from './urlParser'; import { @@ -20,25 +21,41 @@ import { } from './componentFetcherTemplates'; /** - * Helper function to prompt user for token if needed + * Prompt the user for a GitLab personal access token for `gitlabInstance`. + * + * The two reasons we reach a 401 need different wording: a stored token that GitLab rejected + * (expired/invalid — *replace* it) versus no token at all (private source needs *first-time* auth). + * + * @param context Extension context (currently unused; reserved for context-scoped secrets). + * @param tokenManager Stores the entered token, keyed by `gitlabInstance`. + * @param gitlabInstance The GitLab host the token is for (e.g. `gitlab.com`). + * @param hadToken Whether a token was already stored for this instance — selects which of the + * two messages above is shown. + * @returns The trimmed token if the user entered one (also persisted), or `undefined` + * if they left it blank (public access) or dismissed the prompt. */ async function promptForTokenIfNeeded( context: vscode.ExtensionContext | undefined, tokenManager: TokenManager, - gitlabInstance: string + gitlabInstance: string, + hadToken: boolean ): Promise { - const tokenPrompt = `This project/group requires a GitLab personal access token for ${gitlabInstance}. Please enter one to continue.`; + const tokenPrompt = hadToken + ? `Your GitLab token for ${gitlabInstance} is invalid or has expired. Enter a new personal access token (needs the read_api scope) to continue.` + : `This project/group requires a GitLab personal access token for ${gitlabInstance} (needs the read_api scope). Enter one to continue, or leave blank for public access.`; const token = await vscode.window.showInputBox({ + title: 'GitLab Component Helper', prompt: tokenPrompt, + placeHolder: hadToken ? 'New personal access token' : 'Personal access token (blank for public access)', password: true, ignoreFocusOut: true }); if (token && token.trim()) { await tokenManager.setTokenForProject(gitlabInstance, token.trim()); - vscode.window.showInformationMessage(`Token saved for ${gitlabInstance}`); + vscode.window.showInformationMessage(`GitLab Component Helper: token saved for ${gitlabInstance}`); return token.trim(); } else if (token === '') { - vscode.window.showInformationMessage('No token entered. Public access will be used.'); + vscode.window.showInformationMessage('GitLab Component Helper: no token entered. Public access will be used.'); return undefined; } return undefined; @@ -217,43 +234,31 @@ export class ComponentFetcher { this.logger.debug(`Using token for ${gitlabInstance}: ${token ? 'YES' : 'NO'}`); - // Fetch project info and template in parallel - let projectInfo: PromiseSettledResult; - let templateResult: PromiseSettledResult>>; - try { + // Fetch project info and template in parallel. `allSettled` never rejects, so an auth failure + // surfaces as a rejected `projectInfo` rather than a thrown error — handle it explicitly below. + let [projectInfo, templateResult] = await Promise.allSettled([ + this.httpClient.fetchJson(projectApiUrl, fetchOptions), + this.fetchTemplate(apiBaseUrl, encodedProjectPath, componentName, version, fetchOptions) + ]); + + // On a 401/403 fetching project info, prompt for a token once and retry. If the user declines + // (or it still fails), rethrow the original auth error so callers can surface a clear prompt + // instead of degrading into an empty-parameter component. + if (projectInfo.status === 'rejected' && isAuthError(projectInfo.reason)) { + token = await promptForTokenIfNeeded(context, this.tokenManager, gitlabInstance, !!token); + if (!token) { + throw projectInfo.reason; + } + fetchOptions = { headers: { 'PRIVATE-TOKEN': token } }; [projectInfo, templateResult] = await Promise.allSettled([ this.httpClient.fetchJson(projectApiUrl, fetchOptions), this.fetchTemplate(apiBaseUrl, encodedProjectPath, componentName, version, fetchOptions) ]); - } catch (err: unknown) { - const status = (err && typeof err === 'object' && 'status' in err) - ? (err as { status?: number }).status - : undefined; - if (status === 401 || status === 403) { - // Prompt for token and retry - token = await promptForTokenIfNeeded(context, this.tokenManager, gitlabInstance); - if (token) { - fetchOptions = { headers: { 'PRIVATE-TOKEN': token } }; - [projectInfo, templateResult] = await Promise.allSettled([ - this.httpClient.fetchJson(projectApiUrl, fetchOptions), - this.fetchTemplate( - apiBaseUrl, - encodedProjectPath, - componentName, - version, - fetchOptions - ) - ]); - } else { - throw err; - } - } else { - throw err; - } } if (projectInfo.status === 'rejected') { - throw new Error(`Failed to fetch project info: ${projectInfo.reason}`); + // Preserve the original error (and its status) so auth failures stay recognisable upstream. + throw projectInfo.reason; } const project = projectInfo.value; @@ -296,7 +301,13 @@ export class ComponentFetcher { } catch (error) { this.logger.error(`Error fetching component metadata: ${error}`); - // Still provide a minimal component rather than failing + // Auth failures must propagate: a minimal empty-parameter component would make every provided + // input look "unknown" during validation. Let callers surface a token prompt instead. + if (isAuthError(error)) { + throw error; + } + + // For other failures, still provide a minimal component rather than failing outright. const urlParts = url.split('/'); const lastPart = urlParts[urlParts.length - 1]; const componentName = lastPart.includes('@') ? lastPart.split('@')[0] : lastPart; @@ -432,9 +443,9 @@ export class ComponentFetcher { // Handle authentication errors and retry if needed if (projectInfoResult.status === 'rejected') { const err = projectInfoResult.reason; - if (err && (err.status === 401 || err.status === 403)) { + if (isAuthError(err)) { // Prompt for token and retry - token = await promptForTokenIfNeeded(context, this.tokenManager, cleanGitlabInstance); + token = await promptForTokenIfNeeded(context, this.tokenManager, cleanGitlabInstance, !!token); if (token) { fetchOptions = { headers: { 'PRIVATE-TOKEN': token } }; const [retryProjectInfo] = await Promise.allSettled([ diff --git a/src/services/component/componentService.ts b/src/services/component/componentService.ts index e9d7a259..b212b99b 100644 --- a/src/services/component/componentService.ts +++ b/src/services/component/componentService.ts @@ -50,10 +50,21 @@ export class ComponentService implements ComponentSource { } // Token management delegation + + /** Fires (with the instance hostname) after a token is stored, so consumers can revalidate. */ + public get onDidChangeToken(): vscode.Event { + return this.tokenManager.onDidChangeToken; + } + public setSecretStorage(secretStorage: vscode.SecretStorage): void { this.tokenManager.setSecretStorage(secretStorage); } + /** Release the token manager's resources (its change-event emitter). */ + public dispose(): void { + this.tokenManager.dispose(); + } + public async getTokenForProject(gitlabInstance: string): Promise { return this.tokenManager.getTokenForProject(gitlabInstance); } diff --git a/src/services/component/tokenManager.ts b/src/services/component/tokenManager.ts index faa31b8a..7ebf5d96 100644 --- a/src/services/component/tokenManager.ts +++ b/src/services/component/tokenManager.ts @@ -4,16 +4,27 @@ import { Logger } from '../../utils/logger'; /** * Manages GitLab personal access tokens using VS Code's SecretStorage */ -export class TokenManager { +export class TokenManager implements vscode.Disposable { private logger = Logger.getInstance(); private secretStorage: vscode.SecretStorage | undefined; + private readonly onDidChangeTokenEmitter = new vscode.EventEmitter(); + /** + * Fires after a token is stored for a GitLab instance, with the instance hostname. Lets consumers + * (e.g. the validation provider) re-run work that previously failed for lack of a valid token. + */ + public readonly onDidChangeToken = this.onDidChangeTokenEmitter.event; + constructor() {} public setSecretStorage(secretStorage: vscode.SecretStorage): void { this.secretStorage = secretStorage; } + public dispose(): void { + this.onDidChangeTokenEmitter.dispose(); + } + /** * Get token for a specific GitLab instance * @param gitlabInstance The GitLab instance hostname (e.g., 'gitlab.com') @@ -43,6 +54,7 @@ export class TokenManager { this.logger.debug(`Storing token with key: ${key}`); await this.secretStorage.store(key, token); this.logger.debug(`Token stored successfully for ${gitlabInstance}`); + this.onDidChangeTokenEmitter.fire(gitlabInstance); } /** diff --git a/src/utils/httpClient.ts b/src/utils/httpClient.ts index 128e3098..1d02e3f6 100644 --- a/src/utils/httpClient.ts +++ b/src/utils/httpClient.ts @@ -4,7 +4,7 @@ import * as vscode from 'vscode'; import { Logger } from './logger'; import { getRequestDeduplicator, RequestDeduplicator } from './requestDeduplicator'; import { getPerformanceMonitor } from './performanceMonitor'; -import { NetworkError, getErrorHandler } from '../errors'; +import { NetworkError, getErrorHandler, extractStatusCode } from '../errors'; import { API_PER_PAGE_LIMIT, MAX_PAGINATION_PAGES } from '../constants/timing'; interface RequestOptions { @@ -14,28 +14,6 @@ interface RequestOptions { retryDelay?: number; } -/** - * Extract an HTTP status code from an unknown thrown value. - * - * Prefers the typed `NetworkError.details.statusCode`, then falls back to a `statusCode` property on - * the value itself (some Node networking errors expose one ad hoc). Anything else returns `undefined` - * so callers can treat the failure as a non-HTTP error and route through the retry/backoff path. - * - * @param error The value caught in a `try`/`catch` block. Accepted as `unknown` so callers don't - * need to narrow before passing it in. - * @returns The HTTP status code if one can be safely extracted, otherwise `undefined`. - */ -function extractStatusCode(error: unknown): number | undefined { - if (error instanceof NetworkError && error.details?.statusCode) { - return error.details.statusCode; - } - if (typeof error === 'object' && error !== null && 'statusCode' in error) { - const candidate = (error as { statusCode: unknown }).statusCode; - return typeof candidate === 'number' ? candidate : undefined; - } - return undefined; -} - /** * Extract a log-friendly message from an unknown thrown value. * diff --git a/tests/unit/authError.test.ts b/tests/unit/authError.test.ts new file mode 100644 index 00000000..9733d3ab --- /dev/null +++ b/tests/unit/authError.test.ts @@ -0,0 +1,58 @@ +// @mocha +/** + * Tests for the auth-error helpers used to keep an expired/invalid GitLab token from degrading into + * spurious "unknown input" diagnostics. `isAuthError`/`extractStatusCode` are the single source of + * truth that the HTTP client, component fetcher, and validation provider all branch on. + */ + +import * as assert from 'node:assert/strict'; +import { extractStatusCode, isAuthError } from '../../src/errors/guards'; +import { NetworkError, GitLabComponentError, ErrorCode } from '../../src/errors/types'; + +suite('extractStatusCode', () => { + test('reads statusCode from a NetworkError', () => { + assert.equal(extractStatusCode(new NetworkError('nope', { statusCode: 401 })), 401); + }); + + test('reads a statusCode property off a plain error-shaped object', () => { + assert.equal(extractStatusCode({ statusCode: 403 }), 403); + }); + + test('returns undefined when no status is present', () => { + assert.equal(extractStatusCode(new Error('boom')), undefined); + assert.equal(extractStatusCode('just a string'), undefined); + assert.equal(extractStatusCode(undefined), undefined); + }); +}); + +suite('isAuthError', () => { + test('true for a 401 NetworkError', () => { + assert.equal(isAuthError(new NetworkError('expired', { statusCode: 401 })), true); + }); + + test('true for a 403 NetworkError', () => { + assert.equal(isAuthError(new NetworkError('forbidden', { statusCode: 403 })), true); + }); + + test('true for an UNAUTHORIZED GitLabComponentError without a status', () => { + assert.equal(isAuthError(new GitLabComponentError(ErrorCode.UNAUTHORIZED, 'no token')), true); + }); + + test('true for a raw object carrying a 401 statusCode', () => { + assert.equal(isAuthError({ statusCode: 401 }), true); + }); + + test('false for a 404 NetworkError', () => { + assert.equal(isAuthError(new NetworkError('missing', { statusCode: 404 })), false); + }); + + test('false for a 500 NetworkError', () => { + assert.equal(isAuthError(new NetworkError('server', { statusCode: 500 })), false); + }); + + test('false for a generic error and non-error values', () => { + assert.equal(isAuthError(new Error('boom')), false); + assert.equal(isAuthError('401'), false); + assert.equal(isAuthError(undefined), false); + }); +}); From 6ab563b15688898162f13ea7e59587d1b83014ca Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 26 Jun 2026 15:29:01 +0000 Subject: [PATCH 24/24] chore(release): 0.13.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 bb4c9826..a6213c48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.13.9", + "version": "0.13.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.13.9", + "version": "0.13.10", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.0.2", diff --git a/package.json b/package.json index 4af11e57..25d25cf3 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.13.9", + "version": "0.13.10", "icon": "images/icon.png", "engines": { "node": ">=22.0.0",