From b34e0b9688a59e605385057ebd8401fac8c79617 Mon Sep 17 00:00:00 2001 From: Simon Heather Date: Fri, 11 Sep 2026 12:04:27 +0100 Subject: [PATCH 1/2] fix(completion): treat boolean inputs as booleans, not quoted strings --- src/parsers/parameterDefaultShape.ts | 31 +++ src/parsers/specParser.ts | 176 +++++++++++++++--- src/providers/completionInputContext.ts | 66 +++++-- src/providers/completionProvider.ts | 15 +- src/providers/localComponentResolver.ts | 16 +- src/providers/validationProvider.ts | 8 +- src/services/cache/componentCacheManager.ts | 2 +- src/services/component/componentFetcher.ts | 9 +- .../component/componentFetcherTemplates.ts | 36 ++-- tests/unit/GitLabSpecParser.test.ts | 170 ++++++++++++++++- tests/unit/completionInputContext.test.ts | 41 +++- tests/unit/componentFetcherTemplates.test.ts | 55 +++++- 12 files changed, 536 insertions(+), 89 deletions(-) create mode 100644 src/parsers/parameterDefaultShape.ts diff --git a/src/parsers/parameterDefaultShape.ts b/src/parsers/parameterDefaultShape.ts new file mode 100644 index 00000000..7d151164 --- /dev/null +++ b/src/parsers/parameterDefaultShape.ts @@ -0,0 +1,31 @@ +/** + * Shared type-guard for "is this value usable as an input's `default:`?". + * + * `vscode`-free and pure so the Mocha unit suite (and `specParser`, which must load under plain Node) can import it + * directly. Both spec parsers narrow the same union, so they share one definition rather than a copy each. + */ + +import type { ParameterDefault } from '../types/git-component'; + +/** + * Narrow an unknown value to {@link ParameterDefault}, the union accepted by an input's `default:` field. + * + * Guards the boundary where a parsed spec becomes a typed parameter: YAML and the catalog API both hand back + * `unknown`, and anything outside the union (a mapping, a nested array) is rejected rather than carried forward as + * a default no consumer can render. + * + * @param value - A value of unknown shape, typically straight from a YAML parse or an API response. + * @returns `true` when `value` is a string, number, boolean, `null`, or an array of those primitives. + */ +export function isParameterDefault(value: unknown): value is ParameterDefault { + if (value === null) return true; + const t = typeof value; + if (t === 'string' || t === 'number' || t === 'boolean') return true; + if (Array.isArray(value)) { + return value.every(v => { + const vt = typeof v; + return vt === 'string' || vt === 'number' || vt === 'boolean'; + }); + } + return false; +} diff --git a/src/parsers/specParser.ts b/src/parsers/specParser.ts index 2386a19a..1f270b50 100644 --- a/src/parsers/specParser.ts +++ b/src/parsers/specParser.ts @@ -3,17 +3,116 @@ import { Logger } from '../utils/logger'; // Import direct from errors/types (not the barrel) so this module can load from plain Node — the barrel re-exports // from errors/handler.ts which imports `vscode` at module load. Required for the Mocha unit suite. import { ParseError } from '../errors/types'; +import type { ParameterDefault } from '../types/git-component'; +import { isParameterDefault } from './parameterDefaultShape'; +import { parseYaml, isYamlNode } from '../utils/yamlParser'; const logger = Logger.getInstance(); +/** + * Parse the text after `default:` into the value YAML says it is. + * + * This parser reads the spec line-by-line rather than as a document, so a `default:` value arrives as raw text. Left + * as text, `default: false` becomes the string `"false"`, which downstream renders back as a quoted `"false"` — a + * string where GitLab expects a boolean. Round-tripping the scalar through the YAML parser keeps booleans, numbers + * and `null` as themselves, while quoted forms (`default: "false"`) stay strings. + * + * @param rawValue - The text following `default:` on the line, e.g. `false`, `"0"`, `[a, b]`. + * @returns The parsed value, or the trimmed raw text when it doesn't parse as a YAML scalar. + */ +function parseScalar(trimmed: string): ParameterDefault { + try { + const parsed = parseYaml(`probe: ${trimmed}`, true); + if (isYamlNode(parsed) && isParameterDefault(parsed.probe)) { + return parsed.probe; + } + } catch { + // Not valid YAML on its own (an unquoted `*`, a stray `{`, …) — fall back to the literal text. + } + return trimmed.replace(/^["']|["']$/g, '').trim(); +} + +/** + * Parse the text after `default:` into the value YAML says it is. + * + * This parser reads the spec line-by-line rather than as a document, so a `default:` value arrives as raw text. Left + * as text, `default: false` becomes the string `"false"`, which downstream renders back as a quoted `"false"` — a + * string where GitLab expects a boolean. Round-tripping the scalar through the YAML parser keeps booleans, numbers + * and `null` as themselves, while quoted forms (`default: "false"`) stay strings. + * + * @param rawValue - The text following `default:` on the line, e.g. `false`, `"0"`, `[a, b]`. + * @param declaredType - The input's `type:` as the spec states it, or `null` when the spec omits it. + * @returns The parsed value, or the trimmed raw text when it doesn't parse as a YAML scalar. + */ +function parseDefaultValue(rawValue: string, declaredType: string | null): ParameterDefault { + const trimmed = rawValue.trim(); + // An empty `default:` is an explicit empty string, not an absent default — absent inputs are marked required. + if (trimmed.length === 0) { + return ''; + } + // An explicit `type: string` makes the default text by declaration, so don't let YAML renumber it: `default: 1.0` + // must stay "1.0" and `default: 0755` must keep its leading zero. Strip surrounding quotes only. + // + // This applies only when the spec actually says `type: string`. GitLab infers an omitted type from the default, so + // an untyped `default: false` is a boolean input and must parse as one — treating the fallback type as a + // declaration would strand every untyped boolean back as the string "false". + if (declaredType === 'string') { + return trimmed.replace(/^["']|["']$/g, '').trim(); + } + return parseScalar(trimmed); +} + +/** + * Parse one `options:` entry into the value YAML says it is. + * + * Kept in step with {@link parseDefaultValue}: an entry and a default that read the same in the spec must produce + * the same value, or a `default: false` never matches the `false` in `options: [true, false]` and fails to + * pre-select. A declared `type: string` keeps entries literal for the same reason defaults do. + * + * @param entry - One option's raw text, quotes and surrounding whitespace included. + * @param declaredType - The input's `type:` as the spec states it, or `null` when the spec omits it. + * @returns The entry as a string, number, or boolean. + */ +function parseOptionEntry(entry: string, declaredType: string | null): string | number | boolean { + const trimmed = entry.trim(); + if (declaredType === 'string') { + return trimmed.replace(/^["']|["']$/g, '').trim(); + } + const parsed = parseScalar(trimmed); + // `options:` entries are scalars; a null or nested-array entry isn't meaningful, so keep those as their text. + return parsed === null || Array.isArray(parsed) ? trimmed.replace(/^["']|["']$/g, '').trim() : parsed; +} + +/** + * Infer an input's type from its default, for a spec that declares `default:` but no `type:`. + * + * Mirrors GitLab, which infers an omitted type from the default rather than assuming `string`. + * + * @param value - The input's parsed default. + * @returns The GitLab input type name the default implies; `'string'` for text and for `null`, which implies nothing. + */ +function inferTypeFromDefault(value: ParameterDefault): string { + if (typeof value === 'boolean') return 'boolean'; + if (typeof value === 'number') return 'number'; + if (Array.isArray(value)) return 'array'; + return 'string'; +} + export interface ComponentVariable { name: string; description: string; required: boolean; type: string; - default?: string; - /** Allowed values from the input's `options:` list, in declaration order; absent when no `options:` is given. */ - options?: string[]; + /** + * The input's `default:`, as the value YAML says it is — `default: false` is the boolean `false`, not `"false"`. + * Consumers render it back to YAML by type, so a string default here must be a genuine string. + */ + default?: ParameterDefault; + /** + * Allowed values from the input's `options:` list, in declaration order; absent when no `options:` is given. + * Entries carry their YAML types, so they compare equal to a `default` that names one of them. + */ + options?: Array; } export interface ParsedSpec { @@ -26,11 +125,12 @@ export interface ParsedSpec { * Parse the inline form of an `options:` value (`options: [a, "b", c]`). * * Returns an empty array for the expanded form (`options:` with the values on following `- item` lines), which the - * caller then fills in as it reads those lines. Surrounding brackets and per-entry quotes are stripped; blank entries - * (e.g. a trailing comma) are dropped. + * caller then fills in as it reads those lines. Surrounding brackets are stripped and blank entries (e.g. a trailing + * comma) are dropped, but each entry stays raw text — {@link parseOptionEntry} types it once the input's `type:` is + * known, which may be declared after `options:`. * * @param rawValue - The text after `options:` on the same line, e.g. `[a, "b", c]` (or empty for the expanded form). - * @returns The parsed option values, or an empty array when the value isn't an inline `[...]` list. + * @returns The raw option entries, or an empty array when the value isn't an inline `[...]` list. */ function parseInlineOptions(rawValue: string): string[] { if (!rawValue.startsWith('[')) { @@ -39,7 +139,7 @@ function parseInlineOptions(rawValue: string): string[] { return rawValue .replace(/^\[|\]$/g, '') .split(',') - .map(entry => entry.trim().replace(/^["']|["']$/g, '').trim()) + .map(entry => entry.trim()) .filter(entry => entry.length > 0); } @@ -146,6 +246,40 @@ export class GitLabSpecParser { .filter(line => line.trim() && !line.trim().startsWith('#')); let currentInput: ComponentVariable | null = null; + // The `default:` text for `currentInput`, held until the input ends so the declared `type:` can steer the parse. + let rawDefault: string | null = null; + // Whether the spec stated `type:` for `currentInput`. `ComponentVariable.type` falls back to 'string', which + // would otherwise be indistinguishable from a declared one. + let declaredType: string | null = null; + // Raw `options:` entries for `currentInput`, typed by `finalizeInput` for the same reason as `rawDefault`. + let rawOptions: string[] | null = null; + + /** + * Resolve the pending `default:` and `options:` against the now-known type, infer an omitted type, and mark a + * defaultless input required. Deferred to here because `type:` may be declared after either of them. + */ + const finalizeInput = (input: ComponentVariable): ComponentVariable => { + if (rawDefault !== null) { + input.default = parseDefaultValue(rawDefault, declaredType); + // GitLab infers an omitted `type:` from the default's type, so mirror that rather than leaving the + // 'string' fallback in place — consumers key off `type`, and an untyped `default: false` is a boolean + // input that should be offered as a true/false choice. + if (declaredType === null) { + input.type = inferTypeFromDefault(input.default); + } + } else { + // GitLab CI/CD component behavior: an input with no default is required. + input.required = true; + } + if (rawOptions !== null) { + // Typed against the input's resolved type so an entry matches a `default` naming the same value. + input.options = rawOptions.map(entry => parseOptionEntry(entry, declaredType ?? input.type)); + } + rawDefault = null; + rawOptions = null; + declaredType = null; + return input; + }; for (const line of inputLines) { const trimmedLine = line.trim(); @@ -165,11 +299,7 @@ export class GitLabSpecParser { if (line.match(/^\s{2,4}[a-zA-Z_][a-zA-Z0-9_-]*:\s*$/)) { // If we have a current input, finalize it before starting a new one if (currentInput) { - // Mark as required if no default was specified (GitLab CI/CD component behavior) - if (currentInput.default === undefined) { - currentInput.required = true; - } - extractedVariables.push(currentInput); + extractedVariables.push(finalizeInput(currentInput)); } const inputName = trimmedLine.split(':')[0]; currentInput = { @@ -186,27 +316,26 @@ export class GitLabSpecParser { if (trimmedLine.startsWith('description:')) { currentInput.description = trimmedLine.substring(12).replace(/^["']|["']$/g, '').trim(); } else if (trimmedLine.startsWith('default:')) { - currentInput.default = trimmedLine.substring(8).replace(/^["']|["']$/g, '').trim(); + // Kept as text until the input is complete: `type:` may follow `default:`, and the declared type decides + // whether the value is parsed as YAML or kept literal. Resolved by `finalizeInput`. + rawDefault = trimmedLine.substring(8); } else if (trimmedLine.startsWith('type:')) { - currentInput.type = trimmedLine.substring(5).replace(/^["']|["']$/g, '').trim(); + declaredType = trimmedLine.substring(5).replace(/^["']|["']$/g, '').trim(); + currentInput.type = declaredType; } else if (trimmedLine.startsWith('options:')) { // Open the options list. An inline form (`options: [a, b]`) carries its values on the same line; // the expanded form leaves them for the `- item` lines below. - currentInput.options = parseInlineOptions(trimmedLine.substring(8).trim()); - } else if (trimmedLine.startsWith('- ') && currentInput.options) { + rawOptions = parseInlineOptions(trimmedLine.substring(8).trim()); + } else if (trimmedLine.startsWith('- ') && rawOptions) { // Expanded list item belonging to the open `options:` block. - currentInput.options.push(trimmedLine.substring(2).replace(/^["']|["']$/g, '').trim()); + rawOptions.push(trimmedLine.substring(2).trim()); } } } // Add the last input if (currentInput) { - // Mark as required if no default was specified (GitLab CI/CD component behavior) - if (currentInput.default === undefined) { - currentInput.required = true; - } - extractedVariables.push(currentInput); + extractedVariables.push(finalizeInput(currentInput)); } logger.debug(`${logPrefix} Extracted ${extractedVariables.length} input parameters from spec`, 'SpecParser'); @@ -274,7 +403,8 @@ export class GitLabSpecParser { description: `Parameter: ${varName}`, required: false, type: 'string', - default: defaultValue || undefined + // The legacy format declares no per-variable type, so values stay the text they appear as. + default: defaultValue ? parseDefaultValue(defaultValue, 'string') : undefined }; }); diff --git a/src/providers/completionInputContext.ts b/src/providers/completionInputContext.ts index f1e089de..9ff7b962 100644 --- a/src/providers/completionInputContext.ts +++ b/src/providers/completionInputContext.ts @@ -7,7 +7,7 @@ import { parseYaml, parseYamlDocuments, findDocumentWith, isYamlNode, type YamlNode } from '../utils/yamlParser'; import { findIncludeLine } from '../utils/includeMatcher'; -import type { ComponentParameter } from '../types/git-component'; +import type { ComponentParameter, ParameterDefault } from '../types/git-component'; /** * The completion slot a YAML cursor position resolves to. @@ -324,6 +324,22 @@ export function renderOptionValue(value: string | number | boolean): string { return typeof value === 'string' ? quoteYamlIfUnsafe(value) : String(value); } +/** + * Render an input's `default` as the YAML text to insert for it. + * + * Arrays become flow sequences (`[a, b]`); strings are bare or double-quoted by round-trip safety; everything else + * (booleans, numbers, `null`) is its bare scalar form, so a `false` default inserts as `false` and not `"false"`. + * + * @param value - The declared default for the input. + * @returns The YAML text for that value. + */ +export function renderDefaultValue(value: ParameterDefault): string { + if (Array.isArray(value)) { + return `[${value.map((v) => (typeof v === 'string' ? quoteYamlIfUnsafe(v, true) : String(v))).join(', ')}]`; + } + return typeof value === 'string' ? quoteYamlIfUnsafe(value) : String(value); +} + /** * Narrow a parameter default to the scalar shapes an `options:` entry can take (string/number/boolean), excluding * the `null` and array forms a default may also hold. @@ -335,6 +351,28 @@ function isOptionScalar(value: unknown): value is string | number | boolean { return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'; } +/** + * The values an input is allowed to take, if that set is known and finite. + * + * An explicit `options:` list is used as declared. A `boolean` input has no `options:` in the spec but is still a + * closed two-value enum, so it yields `true, false` — conventional reading order, which is what a reader scanning + * the dropdown expects. A declared default is floated to the front by the caller, so it still pre-selects. + * + * Shared by both completion slots so the input-name snippet and the value-slot dropdown always agree. + * + * @param param - The input being completed. + * @returns The allowed values, or `undefined` when the input accepts free-form text. + */ +export function allowedValuesFor(param: ComponentParameter): Array | undefined { + if (param.options && param.options.length > 0) { + return param.options; + } + if (param.type === 'boolean') { + return [true, false]; + } + return undefined; +} + /** * Build the value portion of the snippet inserted after `param.name: ` when an input is accepted from completion. * @@ -343,20 +381,24 @@ function isOptionScalar(value: unknown): value is string | number | boolean { * Values are inserted bare where a bare YAML scalar round-trips to the same string — GitLab CI parses a bare scalar * by the input's declared type, so a string input is a bare scalar, not a quoted one. Strings that bare YAML would * reinterpret (indicators, embedded `: `/` #`, type-like tokens, etc.) are double-quoted; see {@link quoteYamlIfUnsafe}. - * Precedence: an `options:` enum becomes a `${1|...|}` choice (with the default, if any, pre-selected first) so the - * allowed values stay one keystroke away; otherwise an explicit `default` is rendered as the YAML it represents; - * otherwise a type-appropriate placeholder. + * Precedence: an `options:` enum — or a `boolean` input, which is a closed two-value enum — becomes a `${1|...|}` + * choice (with the default, if any, pre-selected first) so the allowed values stay one keystroke away; otherwise an + * explicit `default` is rendered as the YAML it represents; otherwise a type-appropriate placeholder. * * @param param - The input parameter spec (type, optional default, optional `options` enum, requiredness). * @returns The snippet body to insert after `param.name: ` — a `${1|...|}` choice, a rendered value, or a `${1:...}` placeholder. */ export function buildInputInsertValue(param: ComponentParameter): string { - if (param.options && param.options.length > 0) { - // Offer the allowed values (`options:`) as a choice. Entries stay unquoted so a number/boolean option isn't + // Includes a `boolean` input's implicit true/false. Falling through to the default-rendering branch below would + // pre-fill the default with no alternatives shown. + const options = allowedValuesFor(param); + + if (options && options.length > 0) { + // Offer the allowed values as a choice. Entries stay unquoted so a number/boolean option isn't // turned into a string; string entries are quoted only when bare YAML would reinterpret them. When the input // also has a default, float the matching option to the front — VS Code pre-selects the first choice entry, so // accepting the input keeps the default while leaving the alternatives one arrow-key away. - const rendered = param.options.map(renderOptionValue); + const rendered = options.map(renderOptionValue); // Only a scalar default can name one of the options; a null or array default has no matching entry. const defaultRendered = isOptionScalar(param.default) ? renderOptionValue(param.default) : undefined; const ordered = @@ -367,17 +409,11 @@ export function buildInputInsertValue(param: ComponentParameter): string { } if (param.default !== undefined) { - // Render the default as the YAML the input expects: arrays as flow sequences (`[a, b]`), - // strings bare-or-quoted by round-trip safety, everything else as its bare scalar form. - if (Array.isArray(param.default)) { - return `[${param.default.map((v) => (typeof v === 'string' ? quoteYamlIfUnsafe(v, true) : String(v))).join(', ')}]`; - } - return typeof param.default === 'string' ? quoteYamlIfUnsafe(param.default) : String(param.default); + return renderDefaultValue(param.default); } switch (param.type) { - case 'boolean': - return param.required ? '${1|true,false|}' : '${1|false,true|}'; + // `boolean` is handled above as a two-value choice, with or without a default. case 'number': return '${1:0}'; case 'integer': diff --git a/src/providers/completionProvider.ts b/src/providers/completionProvider.ts index 084d3f1a..6fe7953c 100755 --- a/src/providers/completionProvider.ts +++ b/src/providers/completionProvider.ts @@ -5,7 +5,7 @@ import { getVariableCompletions, containsGitLabVariables, expandComponentUrl } f import { Logger } from '../utils/logger'; import { isGitLabCIFile } from '../utils/gitlabCiFileMatcher'; import { resolveLocalComponent } from './localComponentResolver'; -import { findCompletionInputContextAtLine, buildInputInsertValue, renderOptionValue } from './completionInputContext'; +import { findCompletionInputContextAtLine, buildInputInsertValue, renderOptionValue, allowedValuesFor } from './completionInputContext'; import type { ComponentParameter } from '../types/git-component'; import type { CachedComponent } from '../types/cache'; @@ -389,16 +389,21 @@ export class CompletionProvider implements vscode.CompletionItemProvider { // it also declares a default — the default is just the pre-filled choice, not a reason to hide the others. if (context.slot === 'value') { const param = component.parameters.find((p: ComponentParameter) => p.name === context.inputName); - if (!param?.options?.length) { + // A `boolean` input is a closed two-value enum, so offer `true`/`false` even though it declares no `options:`. + const values = param ? allowedValuesFor(param) : undefined; + if (!values?.length) { this.logger.debug(`[CompletionProvider] Value slot for ${context.inputName} has no options to offer`, 'CompletionProvider'); return null; } - return param.options.map((value, index) => { + return values.map((value, index) => { const rendered = renderOptionValue(value); const item = new vscode.CompletionItem(String(value), vscode.CompletionItemKind.EnumMember); item.insertText = rendered; - item.detail = `${param.type || 'string'} option`; - // Preserve the declared order of `options:` in the dropdown. + // Flag the declared default rather than reordering around it — the list stays in a stable, scannable + // order (`true` before `false`, or the spec's own `options:` order) whatever the default happens to be. + const isDefault = param?.default !== undefined && param.default === value; + item.detail = `${param?.type || 'string'} option${isDefault ? ' (default)' : ''}`; + // Preserve the declared order of the allowed values in the dropdown. item.sortText = String(index).padStart(4, '0'); return item; }); diff --git a/src/providers/localComponentResolver.ts b/src/providers/localComponentResolver.ts index 7f916b60..d40468c9 100644 --- a/src/providers/localComponentResolver.ts +++ b/src/providers/localComponentResolver.ts @@ -4,7 +4,7 @@ import * as yaml from 'js-yaml'; import { Component, ComponentParameter } from './componentDetector'; import { Logger } from '../utils/logger'; import { isYamlNode, GITLAB_CI_SCHEMA } from '../utils/yamlParser'; -import type { ParameterDefault } from '../types/git-component'; +import { isParameterDefault } from '../parsers/parameterDefaultShape'; // Pure parser helpers live in their own module so the unit suite can exercise them under plain Node. Re-exported // here so existing callers (e.g. validationProvider) keep their import path. @@ -142,20 +142,6 @@ function isOptionsList(value: unknown): value is Array { - const vt = typeof v; - return vt === 'string' || vt === 'number' || vt === 'boolean'; - }); - } - return false; -} - /** * Read the text content of a local include target, preferring the open editor buffer over the on-disk file. * diff --git a/src/providers/validationProvider.ts b/src/providers/validationProvider.ts index f1783dcd..90b34113 100644 --- a/src/providers/validationProvider.ts +++ b/src/providers/validationProvider.ts @@ -9,6 +9,7 @@ import { isGitLabCIFile } from '../utils/gitlabCiFileMatcher'; import { spawn } from 'child_process'; import { resolveLocalIncludeOutcome, isUnsupportedLocalPath } from './localComponentResolver'; import { attachDiagnosticMetadata, readDiagnosticMetadata } from './validationMetadata'; +import { renderDefaultValue } from './completionInputContext'; import { isAuthError } from '../errors'; import type { MissingRequiredInputMetadata } from './validationMetadata'; import type { GitApi, GitRepository } from '../types/vscode-git'; @@ -1319,7 +1320,7 @@ export class ValidationProvider implements vscode.CodeActionProvider { quickPick.items = (args.missingInputs || []).map(input => ({ label: input.name, description: input.required ? 'Required' : 'Optional', - detail: `${input.description} (${input.type}${input.default !== undefined ? `, default: ${JSON.stringify(input.default)}` : ''})` + detail: `${input.description} (${input.type}${input.default !== undefined ? `, default: ${renderDefaultValue(input.default)}` : ''})` })); } @@ -1361,9 +1362,10 @@ export class ValidationProvider implements vscode.CodeActionProvider { insertText += `${indentation}${inputName}: `; - // Add appropriate default value + // Add appropriate default value, rendered as the YAML the input's type expects — `JSON.stringify` + // would quote every scalar, inserting `"false"`/`"production"` where a bare scalar belongs. if (inputInfo?.default !== undefined) { - insertText += `${JSON.stringify(inputInfo.default)}`; + insertText += renderDefaultValue(inputInfo.default); } else { switch (inputInfo?.type?.toLowerCase()) { case 'boolean': diff --git a/src/services/cache/componentCacheManager.ts b/src/services/cache/componentCacheManager.ts index 411361ed..93798fdb 100644 --- a/src/services/cache/componentCacheManager.ts +++ b/src/services/cache/componentCacheManager.ts @@ -18,7 +18,7 @@ import { // Bump when the on-disk CachedComponent shape changes. Mismatched caches are discarded on load so the new fields can be // populated by re-fetching. -const CURRENT_CACHE_VERSION = '1.3.0'; +const CURRENT_CACHE_VERSION = '1.4.0'; /** * ComponentCacheManager - Main orchestrator for component caching diff --git a/src/services/component/componentFetcher.ts b/src/services/component/componentFetcher.ts index 2bb36aa9..df67e4ba 100644 --- a/src/services/component/componentFetcher.ts +++ b/src/services/component/componentFetcher.ts @@ -15,7 +15,7 @@ import { isAuthError } from '../../errors'; import { TokenManager } from './tokenManager'; import { UrlParser } from './urlParser'; import { - backfillParameterOptions, + backfillParameterSpecDetail, buildCatalogComponents, fetchAllTemplateFiles, } from './componentFetcherTemplates'; @@ -195,9 +195,10 @@ export class ComponentFetcher { if (extractedParameters.length === 0 && templateResult?.parameters?.length) { extractedParameters = templateResult.parameters; } else if (templateResult?.parameters?.length) { - // The catalog API doesn't return per-input `options`, so backfill them from the parsed template - // (matched by name) onto the catalog-derived parameters we're keeping. - extractedParameters = backfillParameterOptions(extractedParameters, templateResult.parameters); + // The catalog API describes inputs more loosely than the spec does — no `options`, and often no + // `type` — so backfill that detail from the parsed template (matched by name) onto the + // catalog-derived parameters we're keeping. + extractedParameters = backfillParameterSpecDetail(extractedParameters, templateResult.parameters); } // If the catalog omits a description, fall back to the component's README. diff --git a/src/services/component/componentFetcherTemplates.ts b/src/services/component/componentFetcherTemplates.ts index aea339bb..64c36ccd 100644 --- a/src/services/component/componentFetcherTemplates.ts +++ b/src/services/component/componentFetcherTemplates.ts @@ -198,23 +198,33 @@ export async function buildCatalogComponents( } /** - * Backfill `options` from template-parsed parameters onto catalog-derived parameters, matched by name. + * Take an input's type signature (`options`, `type`, `default`) from the locally-parsed template, which the Catalog + * API reports less completely: it omits `options` entirely, often omits `type` (collapsing to the `'string'` + * fallback), and can return a default stringified — so a `default: false` input arrives as a `'string'` holding + * `"false"`, and completion offers no true/false choice. * - * The GitLab catalog API doesn't return per-input `options`, but the parsed template spec does. When we keep the - * catalog's parameters (e.g. for their descriptions) we still want their `options` so completion can offer the - * allowed values — so we graft `options` from the template parse onto the catalog parameter of the same name. + * Both sides read the same `spec.inputs`, so a disagreement is API loss rather than a real difference. `description` + * and `required` stay with the catalog, which resolves them where our parser only infers them. * - * Only template parameters that actually declare `options` contribute; catalog parameters without a matching - * template entry are returned unchanged. The input arrays are not mutated. + * @param catalogParams - Parameters built from the Catalog API response; the base each result is merged onto. + * @param templateParams - Parameters from the locally-parsed template, matched to the above by `name`. + * @returns A new array in `catalogParams` order, each entry carrying the template's type signature where it has + * one. Catalog parameters with no matching template entry are passed through unchanged; neither input is mutated. */ -export function backfillParameterOptions( +export function backfillParameterSpecDetail( catalogParams: readonly ComponentParameter[], templateParams: readonly ComponentParameter[] ): ComponentParameter[] { - const optionsByName = new Map( - templateParams.filter((p) => p.options?.length).map((p) => [p.name, p.options]) - ); - return catalogParams.map((p) => - optionsByName.has(p.name) ? { ...p, options: optionsByName.get(p.name) } : p - ); + const templateByName = new Map(templateParams.map((p) => [p.name, p])); + return catalogParams.map((p) => { + const fromTemplate = templateByName.get(p.name); + if (!fromTemplate) return p; + + return { + ...p, + ...(fromTemplate.options?.length ? { options: fromTemplate.options } : {}), + ...(fromTemplate.type ? { type: fromTemplate.type } : {}), + ...(fromTemplate.default !== undefined ? { default: fromTemplate.default } : {}), + }; + }); } diff --git a/tests/unit/GitLabSpecParser.test.ts b/tests/unit/GitLabSpecParser.test.ts index 5ec18bdc..941d51ee 100644 --- a/tests/unit/GitLabSpecParser.test.ts +++ b/tests/unit/GitLabSpecParser.test.ts @@ -51,7 +51,8 @@ deploy-job: const debug = parsed.variables.find((v) => v.name === 'debug'); assert.ok(debug, 'debug input missing'); assert.strictEqual(debug.type, 'boolean'); - assert.strictEqual(debug.default, 'false'); + // The boolean `false`, not the string "false" — a stringified default renders back as a quoted `"false"`. + assert.strictEqual(debug.default, false); const names = parsed.variables.map((v) => v.name); for (const unwanted of ['ENV_VAR', 'ANOTHER_VAR', 'script', 'after_script']) { @@ -107,6 +108,9 @@ $[[ inputs.job-name ]]: assert.strictEqual(byName['architecture'].description, 'Target CPU architecture'); assert.strictEqual(byName['architecture'].default, 'amd64'); assert.strictEqual(byName['skip-find-images'].description, 'Skip image discovery'); + assert.strictEqual(byName['skip-find-images'].default, false); + // Two dots isn't a number to YAML, so a version-like default stays the string it looks like. + assert.strictEqual(byName['package-version'].default, '0.0.1'); // A hyphenated input with no default is marked required, same as a non-hyphenated one. assert.strictEqual(byName['package-name'].default, undefined); @@ -234,6 +238,170 @@ suite('GitLabSpecParser.parse — options (enum) extraction', () => { }); }); +suite('GitLabSpecParser.parse — default value types', () => { + // The parser reads the spec line-by-line, so a `default:` arrives as raw text. Keeping it as text turned + // `default: false` into the string "false", which completion then inserted as a quoted `"false"` — a string + // where GitLab expects a boolean. + test('parses scalar defaults as their YAML types, not as strings', () => { + const template = `spec: + inputs: + flag_off: + type: boolean + default: false + flag_on: + type: boolean + default: true + port: + type: number + default: 8080 + ratio: + type: number + default: 0.5 + name: + type: string + default: production + empty: + type: string + default:`; + + const parsed = GitLabSpecParser.parse(template); + const byName = Object.fromEntries(parsed.variables.map((v) => [v.name, v])); + + assert.strictEqual(byName['flag_off'].default, false); + assert.strictEqual(byName['flag_on'].default, true); + assert.strictEqual(byName['port'].default, 8080); + assert.strictEqual(byName['ratio'].default, 0.5); + assert.strictEqual(byName['name'].default, 'production'); + // An explicit but empty `default:` is an empty string, so the input is not required. + assert.strictEqual(byName['empty'].default, ''); + assert.strictEqual(byName['empty'].required, false); + }); + + test('keeps a quoted "false" a string, so it round-trips as a quoted scalar', () => { + const template = `spec: + inputs: + literal: + type: string + default: "false" + numeric_string: + type: string + default: "8080"`; + + const parsed = GitLabSpecParser.parse(template); + const byName = Object.fromEntries(parsed.variables.map((v) => [v.name, v])); + + assert.strictEqual(byName['literal'].default, 'false'); + assert.strictEqual(byName['numeric_string'].default, '8080'); + }); + + test('infers an omitted type from the default, as GitLab does', () => { + // Without this, an untyped `default: false` keeps the 'string' type fallback, and completion offers no + // true/false choice because it has no way to tell the input is a boolean. + const template = `spec: + inputs: + untyped_bool: + description: no type declared + default: false + untyped_number: + default: 8080 + untyped_string: + default: production + untyped_array: + default: [a, b] + untyped_nothing: + description: no type and no default`; + + const parsed = GitLabSpecParser.parse(template); + const byName = Object.fromEntries(parsed.variables.map((v) => [v.name, v])); + + assert.strictEqual(byName['untyped_bool'].type, 'boolean'); + assert.strictEqual(byName['untyped_bool'].default, false); + assert.strictEqual(byName['untyped_number'].type, 'number'); + assert.strictEqual(byName['untyped_string'].type, 'string'); + assert.strictEqual(byName['untyped_array'].type, 'array'); + // Nothing to infer from, so the 'string' fallback stands. + assert.strictEqual(byName['untyped_nothing'].type, 'string'); + }); + + test('an explicit `type: string` keeps a numeric-looking default as text', () => { + // The declared type wins over what YAML would make of the text: `1.0` must not become the number 1, and + // `0755` must keep its leading zero. + const template = `spec: + inputs: + ratio: + type: string + default: 1.0 + mode: + type: string + default: 0755`; + + const parsed = GitLabSpecParser.parse(template); + const byName = Object.fromEntries(parsed.variables.map((v) => [v.name, v])); + + assert.strictEqual(byName['ratio'].default, '1.0'); + assert.strictEqual(byName['mode'].default, '0755'); + }); + + test('types options entries like defaults, so a default matches the option naming it', () => { + // Options parsed as strings while defaults are typed would leave `false` unable to match `'false'` — the + // default would not pre-select, and the choice would insert quoted values into a boolean/number input. + const template = `spec: + inputs: + mode: + type: boolean + default: false + options: [true, false] + size: + type: number + default: 2 + options: [1, 2, 3]`; + + const parsed = GitLabSpecParser.parse(template); + const byName = Object.fromEntries(parsed.variables.map((v) => [v.name, v])); + + assert.deepStrictEqual(byName['mode'].options, [true, false]); + assert.strictEqual(byName['mode'].default, false); + assert.deepStrictEqual(byName['size'].options, [1, 2, 3]); + assert.strictEqual(byName['size'].default, 2); + }); + + test('types options against a `type:` declared after the options block', () => { + const template = `spec: + inputs: + size: + options: [1, 2] + type: number`; + + const parsed = GitLabSpecParser.parse(template); + + assert.deepStrictEqual(parsed.variables[0].options, [1, 2]); + }); + + test('an explicit `type: string` keeps numeric-looking options as text', () => { + const template = `spec: + inputs: + version: + type: string + options: ["1.0", "2.0"]`; + + const parsed = GitLabSpecParser.parse(template); + + assert.deepStrictEqual(parsed.variables[0].options, ['1.0', '2.0']); + }); + + test('an input with a false default is optional, not required', () => { + const template = `spec: + inputs: + debug: + type: boolean + default: false`; + + const parsed = GitLabSpecParser.parse(template); + + assert.strictEqual(parsed.variables[0].required, false); + }); +}); + suite('GitLabSpecParser.parse — description extraction', () => { test('extracts description from a leading `#` comment', () => { const template = `# Deploys a service to the target environment diff --git a/tests/unit/completionInputContext.test.ts b/tests/unit/completionInputContext.test.ts index d1ca979a..313d1b5a 100644 --- a/tests/unit/completionInputContext.test.ts +++ b/tests/unit/completionInputContext.test.ts @@ -13,6 +13,7 @@ import * as assert from 'node:assert/strict'; import { findCompletionInputContextAtLine, buildInputInsertValue, + allowedValuesFor, } from '../../src/providers/completionInputContext'; import type { ComponentParameter } from '../../src/types/git-component'; @@ -410,7 +411,6 @@ suite('buildInputInsertValue', () => { test('renders a string default bare and stringifies a non-string default', () => { assert.strictEqual(buildInputInsertValue({ ...base, default: 'dev' }), 'dev'); assert.strictEqual(buildInputInsertValue({ ...base, type: 'number', default: 42 }), '42'); - assert.strictEqual(buildInputInsertValue({ ...base, type: 'boolean', default: true }), 'true'); }); test('quotes a string default only when a bare scalar would not round-trip', () => { @@ -437,9 +437,44 @@ suite('buildInputInsertValue', () => { assert.strictEqual(buildInputInsertValue({ ...base, type: 'array', default: ['a,b', 'c'] }), '["a,b", c]'); }); - test('offers both boolean values, leading with the safer one by requiredness', () => { + test('offers both boolean values in conventional order when there is no default', () => { + // `true, false` reads the way someone scanning the dropdown expects, regardless of requiredness. assert.strictEqual(buildInputInsertValue({ ...base, type: 'boolean', required: true }), '${1|true,false|}'); - assert.strictEqual(buildInputInsertValue({ ...base, type: 'boolean', required: false }), '${1|false,true|}'); + assert.strictEqual(buildInputInsertValue({ ...base, type: 'boolean', required: false }), '${1|true,false|}'); + }); + + test('offers both boolean values when the input has a default, pre-selecting the default', () => { + // A boolean is a closed two-value enum, so a default pre-fills the choice rather than replacing it. + assert.strictEqual(buildInputInsertValue({ ...base, type: 'boolean', default: false }), '${1|false,true|}'); + assert.strictEqual(buildInputInsertValue({ ...base, type: 'boolean', default: true }), '${1|true,false|}'); + // A default always leads, overriding the conventional true-first order. + assert.strictEqual( + buildInputInsertValue({ ...base, type: 'boolean', required: true, default: false }), + '${1|false,true|}' + ); + }); + + test('allowedValuesFor covers the value slot, where a boolean has no literal options list', () => { + // The value slot (cursor after `test:`) offers `allowedValuesFor`, so a boolean must yield true/false there + // even though the spec declares no `options:` — otherwise typing after `test:` suggests nothing. + assert.deepStrictEqual(allowedValuesFor({ ...base, type: 'boolean' }), [true, false]); + assert.deepStrictEqual(allowedValuesFor({ ...base, type: 'boolean', required: true }), [true, false]); + // An explicit options list is used as declared, and a free-text input offers nothing. + assert.deepStrictEqual(allowedValuesFor({ ...base, options: ['aws', 'gcp'] }), ['aws', 'gcp']); + assert.strictEqual(allowedValuesFor({ ...base, type: 'string' }), undefined); + }); + + test('offers the choice for a boolean input whose type was inferred from an untyped default', () => { + // A spec that declares `default: false` with no `type:` is a boolean input; the parser infers the type, so + // the snippet builder sees `type: 'boolean'` and offers the choice like any other boolean. + assert.strictEqual(buildInputInsertValue({ ...base, type: 'boolean', default: false }), '${1|false,true|}'); + }); + + test('inserts a boolean default unquoted, so GitLab reads it as a boolean', () => { + // Regression: a stringified `"false"` default (from the line-based spec parser) inserted `p: "false"` — a + // string where a boolean was declared. The choice list must carry bare `false`, never `"false"`. + const built = buildInputInsertValue({ ...base, type: 'boolean', default: false }); + assert.ok(!built.includes('"'), `boolean choice must not be quoted, got ${built}`); }); test('seeds empty literals for array and object inputs', () => { diff --git a/tests/unit/componentFetcherTemplates.test.ts b/tests/unit/componentFetcherTemplates.test.ts index 9768040c..55dea148 100644 --- a/tests/unit/componentFetcherTemplates.test.ts +++ b/tests/unit/componentFetcherTemplates.test.ts @@ -12,7 +12,7 @@ import * as assert from 'node:assert/strict'; import type { GitLabTreeItem } from '../../src/types/api'; import type { ComponentParameter } from '../../src/types/git-component'; import { - backfillParameterOptions, + backfillParameterSpecDetail, deriveComponentName, filterSubdirectories, filterYamlBlobs, @@ -129,7 +129,7 @@ suite('deriveComponentName — deeper nesting and edge cases', () => { }); }); -suite('backfillParameterOptions', () => { +suite('backfillParameterSpecDetail', () => { const param = (name: string, extra: Partial = {}): ComponentParameter => ({ name, description: '', @@ -142,7 +142,7 @@ suite('backfillParameterOptions', () => { const catalog = [param('registry_type'), param('region')]; const template = [param('registry_type', { options: ['aws', 'gcp'] }), param('region')]; - const merged = backfillParameterOptions(catalog, template); + const merged = backfillParameterSpecDetail(catalog, template); assert.deepStrictEqual(merged.find((p) => p.name === 'registry_type')?.options, ['aws', 'gcp']); assert.strictEqual(merged.find((p) => p.name === 'region')?.options, undefined); @@ -152,7 +152,7 @@ suite('backfillParameterOptions', () => { const catalog = [param('region')]; const template = [param('registry_type', { options: ['aws'] })]; // different name - const merged = backfillParameterOptions(catalog, template); + const merged = backfillParameterSpecDetail(catalog, template); assert.strictEqual(merged[0].options, undefined); }); @@ -161,7 +161,7 @@ suite('backfillParameterOptions', () => { const catalog = [param('a'), param('b')]; const template = [param('a', { options: [] }), param('b')]; // empty + missing - const merged = backfillParameterOptions(catalog, template); + const merged = backfillParameterSpecDetail(catalog, template); assert.strictEqual(merged.find((p) => p.name === 'a')?.options, undefined); assert.strictEqual(merged.find((p) => p.name === 'b')?.options, undefined); @@ -172,8 +172,51 @@ suite('backfillParameterOptions', () => { const catalog = [catalogParam]; const template = [param('registry_type', { options: ['aws'] })]; - backfillParameterOptions(catalog, template); + backfillParameterSpecDetail(catalog, template); assert.strictEqual(catalogParam.options, undefined, 'original catalog param was mutated'); }); + + test('recovers a boolean type the catalog reported as an untyped string', () => { + // The catalog can describe a boolean input without a type, leaving the 'string' fallback in place. Completion + // keys off `type`, so without this the input gets no true/false choice. + const catalog = [param('debug', { default: 'false' })]; + const template = [param('debug', { type: 'boolean', default: false })]; + + const merged = backfillParameterSpecDetail(catalog, template); + + assert.strictEqual(merged[0].type, 'boolean'); + assert.strictEqual(merged[0].default, false, 'a stringified catalog default should yield to the parsed one'); + }); + + test('the local parse wins on the type signature, since both sides describe the same spec', () => { + // The catalog reports what GitLab read from this same template, only lossily — so on `type`/`default`/`options` + // the local parse is preferred rather than arbitrated against. + const catalog = [param('env', { type: 'string', default: 'production' })]; + const template = [param('env', { type: 'string', default: 'staging' })]; + + const merged = backfillParameterSpecDetail(catalog, template); + + assert.strictEqual(merged[0].default, 'staging'); + }); + + test('keeps a catalog default the template parse does not have', () => { + const catalog = [param('env', { default: 'production' })]; + const template = [param('env')]; // no default parsed + + const merged = backfillParameterSpecDetail(catalog, template); + + assert.strictEqual(merged[0].default, 'production'); + }); + + test('leaves the fields the catalog is authoritative for untouched', () => { + const catalog = [param('debug', { description: 'from catalog', required: true })]; + const template = [param('debug', { description: 'from template', required: false, type: 'boolean' })]; + + const merged = backfillParameterSpecDetail(catalog, template); + + assert.strictEqual(merged[0].description, 'from catalog'); + assert.strictEqual(merged[0].required, true); + assert.strictEqual(merged[0].type, 'boolean', 'the type signature still comes from the local parse'); + }); }); From b17bfc0b2fa98a18ade3954285cf162da4c1e47e Mon Sep 17 00:00:00 2001 From: Simon Heather Date: Fri, 11 Sep 2026 14:15:15 +0100 Subject: [PATCH 2/2] Fix review comments --- src/parsers/specParser.ts | 96 +++++++------------ .../component/componentFetcherTemplates.ts | 8 +- tests/unit/GitLabSpecParser.test.ts | 59 ++++++++---- tests/unit/componentFetcherTemplates.test.ts | 11 +++ 4 files changed, 93 insertions(+), 81 deletions(-) diff --git a/src/parsers/specParser.ts b/src/parsers/specParser.ts index 1f270b50..7aa1508a 100644 --- a/src/parsers/specParser.ts +++ b/src/parsers/specParser.ts @@ -5,24 +5,25 @@ import { Logger } from '../utils/logger'; import { ParseError } from '../errors/types'; import type { ParameterDefault } from '../types/git-component'; import { isParameterDefault } from './parameterDefaultShape'; -import { parseYaml, isYamlNode } from '../utils/yamlParser'; +import * as yaml from 'js-yaml'; +import { isYamlNode, GITLAB_CI_SCHEMA } from '../utils/yamlParser'; const logger = Logger.getInstance(); +/** An input's own keys, which are never input names however the spec happens to be indented. */ +const INPUT_FIELD_KEYS = new Set(['description', 'default', 'type', 'options', 'regex']); + /** - * Parse the text after `default:` into the value YAML says it is. - * - * This parser reads the spec line-by-line rather than as a document, so a `default:` value arrives as raw text. Left - * as text, `default: false` becomes the string `"false"`, which downstream renders back as a quoted `"false"` — a - * string where GitLab expects a boolean. Round-tripping the scalar through the YAML parser keeps booleans, numbers - * and `null` as themselves, while quoted forms (`default: "false"`) stay strings. + * Resolve one already-trimmed scalar to the value YAML reads it as. * - * @param rawValue - The text following `default:` on the line, e.g. `false`, `"0"`, `[a, b]`. - * @returns The parsed value, or the trimmed raw text when it doesn't parse as a YAML scalar. + * @param trimmed - A single scalar's text, surrounding whitespace already removed. + * @returns The parsed value, or the text with surrounding quotes stripped when it isn't a parseable YAML scalar. */ function parseScalar(trimmed: string): ParameterDefault { try { - const parsed = parseYaml(`probe: ${trimmed}`, true); + // `yaml.load` directly rather than `parseYaml`: these probes are one-off strings, so memoising them only evicts + // the document parses sharing that cache. + const parsed = yaml.load(`probe: ${trimmed}`, { schema: GITLAB_CI_SCHEMA }); if (isYamlNode(parsed) && isParameterDefault(parsed.probe)) { return parsed.probe; } @@ -33,30 +34,28 @@ function parseScalar(trimmed: string): ParameterDefault { } /** - * Parse the text after `default:` into the value YAML says it is. + * Parse the text after `default:` into the value the input's type says it is. * * This parser reads the spec line-by-line rather than as a document, so a `default:` value arrives as raw text. Left - * as text, `default: false` becomes the string `"false"`, which downstream renders back as a quoted `"false"` — a - * string where GitLab expects a boolean. Round-tripping the scalar through the YAML parser keeps booleans, numbers - * and `null` as themselves, while quoted forms (`default: "false"`) stay strings. + * as text, `default: false` on a `type: boolean` input becomes the string `"false"`, which downstream renders back + * as a quoted `"false"` — a string where GitLab expects a boolean. + * + * A `string` input's default is text by declaration, so it is never sent through YAML: `default: 1.0` stays `"1.0"` + * and `0755` keeps its leading zero. That covers an omitted `type:` as well — GitLab resolves an untyped input as + * `StringInput` (its `matches?` accepts a spec with no `:type` key) and coerces the default with `to_s`, so an + * untyped default is a string no matter what it looks like. * * @param rawValue - The text following `default:` on the line, e.g. `false`, `"0"`, `[a, b]`. - * @param declaredType - The input's `type:` as the spec states it, or `null` when the spec omits it. + * @param inputType - The input's resolved `type:`; `'string'` when the spec omits it. * @returns The parsed value, or the trimmed raw text when it doesn't parse as a YAML scalar. */ -function parseDefaultValue(rawValue: string, declaredType: string | null): ParameterDefault { +function parseDefaultValue(rawValue: string, inputType: string): ParameterDefault { const trimmed = rawValue.trim(); // An empty `default:` is an explicit empty string, not an absent default — absent inputs are marked required. if (trimmed.length === 0) { return ''; } - // An explicit `type: string` makes the default text by declaration, so don't let YAML renumber it: `default: 1.0` - // must stay "1.0" and `default: 0755` must keep its leading zero. Strip surrounding quotes only. - // - // This applies only when the spec actually says `type: string`. GitLab infers an omitted type from the default, so - // an untyped `default: false` is a boolean input and must parse as one — treating the fallback type as a - // declaration would strand every untyped boolean back as the string "false". - if (declaredType === 'string') { + if (inputType === 'string') { return trimmed.replace(/^["']|["']$/g, '').trim(); } return parseScalar(trimmed); @@ -67,15 +66,15 @@ function parseDefaultValue(rawValue: string, declaredType: string | null): Param * * Kept in step with {@link parseDefaultValue}: an entry and a default that read the same in the spec must produce * the same value, or a `default: false` never matches the `false` in `options: [true, false]` and fails to - * pre-select. A declared `type: string` keeps entries literal for the same reason defaults do. + * pre-select. A `string` input — declared or by omission — keeps entries literal for the same reason defaults do. * * @param entry - One option's raw text, quotes and surrounding whitespace included. - * @param declaredType - The input's `type:` as the spec states it, or `null` when the spec omits it. + * @param inputType - The input's resolved `type:`; `'string'` when the spec omits it. * @returns The entry as a string, number, or boolean. */ -function parseOptionEntry(entry: string, declaredType: string | null): string | number | boolean { +function parseOptionEntry(entry: string, inputType: string): string | number | boolean { const trimmed = entry.trim(); - if (declaredType === 'string') { + if (inputType === 'string') { return trimmed.replace(/^["']|["']$/g, '').trim(); } const parsed = parseScalar(trimmed); @@ -83,21 +82,6 @@ function parseOptionEntry(entry: string, declaredType: string | null): string | return parsed === null || Array.isArray(parsed) ? trimmed.replace(/^["']|["']$/g, '').trim() : parsed; } -/** - * Infer an input's type from its default, for a spec that declares `default:` but no `type:`. - * - * Mirrors GitLab, which infers an omitted type from the default rather than assuming `string`. - * - * @param value - The input's parsed default. - * @returns The GitLab input type name the default implies; `'string'` for text and for `null`, which implies nothing. - */ -function inferTypeFromDefault(value: ParameterDefault): string { - if (typeof value === 'boolean') return 'boolean'; - if (typeof value === 'number') return 'number'; - if (Array.isArray(value)) return 'array'; - return 'string'; -} - export interface ComponentVariable { name: string; description: string; @@ -248,36 +232,27 @@ export class GitLabSpecParser { let currentInput: ComponentVariable | null = null; // The `default:` text for `currentInput`, held until the input ends so the declared `type:` can steer the parse. let rawDefault: string | null = null; - // Whether the spec stated `type:` for `currentInput`. `ComponentVariable.type` falls back to 'string', which - // would otherwise be indistinguishable from a declared one. - let declaredType: string | null = null; // Raw `options:` entries for `currentInput`, typed by `finalizeInput` for the same reason as `rawDefault`. let rawOptions: string[] | null = null; /** - * Resolve the pending `default:` and `options:` against the now-known type, infer an omitted type, and mark a - * defaultless input required. Deferred to here because `type:` may be declared after either of them. + * Resolve the pending `default:` and `options:` against the declared type and mark a defaultless input + * required. Deferred to here because `type:` may be declared after either of them. */ const finalizeInput = (input: ComponentVariable): ComponentVariable => { + // An omitted `type:` is `string` to GitLab, and `input.type` already holds that fallback. if (rawDefault !== null) { - input.default = parseDefaultValue(rawDefault, declaredType); - // GitLab infers an omitted `type:` from the default's type, so mirror that rather than leaving the - // 'string' fallback in place — consumers key off `type`, and an untyped `default: false` is a boolean - // input that should be offered as a true/false choice. - if (declaredType === null) { - input.type = inferTypeFromDefault(input.default); - } + input.default = parseDefaultValue(rawDefault, input.type); } else { // GitLab CI/CD component behavior: an input with no default is required. input.required = true; } if (rawOptions !== null) { - // Typed against the input's resolved type so an entry matches a `default` naming the same value. - input.options = rawOptions.map(entry => parseOptionEntry(entry, declaredType ?? input.type)); + // Typed against the input's type so an entry matches a `default` naming the same value. + input.options = rawOptions.map(entry => parseOptionEntry(entry, input.type)); } rawDefault = null; rawOptions = null; - declaredType = null; return input; }; @@ -296,7 +271,9 @@ export class GitLabSpecParser { // The name class includes `-`: GitLab input names are commonly hyphenated (e.g. `job-name`). Without // it, a hyphenated key isn't recognised as a new input, so its `description:`/`default:` lines bleed // onto the previous (non-hyphenated) input and mis-map every field after it (issue #211). - if (line.match(/^\s{2,4}[a-zA-Z_][a-zA-Z0-9_-]*:\s*$/)) { + // An input's own keys are excluded by name: on a spec indented `inputs:` at 0, they sit at 4 and would + // otherwise match, so a valueless `default:` would open a phantom input and swallow the real one's value. + if (line.match(/^\s{2,4}[a-zA-Z_][a-zA-Z0-9_-]*:\s*$/) && !INPUT_FIELD_KEYS.has(trimmedLine.slice(0, -1))) { // If we have a current input, finalize it before starting a new one if (currentInput) { extractedVariables.push(finalizeInput(currentInput)); @@ -320,8 +297,7 @@ export class GitLabSpecParser { // whether the value is parsed as YAML or kept literal. Resolved by `finalizeInput`. rawDefault = trimmedLine.substring(8); } else if (trimmedLine.startsWith('type:')) { - declaredType = trimmedLine.substring(5).replace(/^["']|["']$/g, '').trim(); - currentInput.type = declaredType; + currentInput.type = trimmedLine.substring(5).replace(/^["']|["']$/g, '').trim(); } else if (trimmedLine.startsWith('options:')) { // Open the options list. An inline form (`options: [a, b]`) carries its values on the same line; // the expanded form leaves them for the `- item` lines below. diff --git a/src/services/component/componentFetcherTemplates.ts b/src/services/component/componentFetcherTemplates.ts index 64c36ccd..4c7d9658 100644 --- a/src/services/component/componentFetcherTemplates.ts +++ b/src/services/component/componentFetcherTemplates.ts @@ -206,6 +206,11 @@ export async function buildCatalogComponents( * Both sides read the same `spec.inputs`, so a disagreement is API loss rather than a real difference. `description` * and `required` stay with the catalog, which resolves them where our parser only infers them. * + * `type` is the one field the template does not always win: both sides fall back to `'string'`, so a template + * `'string'` may mean "the spec said string" or "the line-based parse missed the `type:` line". Overwriting with it + * would downgrade a catalog-typed `number` on a spec layout the parser doesn't match, so the template's `type` is + * taken only when it is more specific than that shared fallback. + * * @param catalogParams - Parameters built from the Catalog API response; the base each result is merged onto. * @param templateParams - Parameters from the locally-parsed template, matched to the above by `name`. * @returns A new array in `catalogParams` order, each entry carrying the template's type signature where it has @@ -220,10 +225,11 @@ export function backfillParameterSpecDetail( const fromTemplate = templateByName.get(p.name); if (!fromTemplate) return p; + const typeIsMoreSpecific = Boolean(fromTemplate.type) && fromTemplate.type !== 'string'; return { ...p, ...(fromTemplate.options?.length ? { options: fromTemplate.options } : {}), - ...(fromTemplate.type ? { type: fromTemplate.type } : {}), + ...(typeIsMoreSpecific ? { type: fromTemplate.type } : {}), ...(fromTemplate.default !== undefined ? { default: fromTemplate.default } : {}), }; }); diff --git a/tests/unit/GitLabSpecParser.test.ts b/tests/unit/GitLabSpecParser.test.ts index 941d51ee..fe9e0aa7 100644 --- a/tests/unit/GitLabSpecParser.test.ts +++ b/tests/unit/GitLabSpecParser.test.ts @@ -108,9 +108,8 @@ $[[ inputs.job-name ]]: assert.strictEqual(byName['architecture'].description, 'Target CPU architecture'); assert.strictEqual(byName['architecture'].default, 'amd64'); assert.strictEqual(byName['skip-find-images'].description, 'Skip image discovery'); - assert.strictEqual(byName['skip-find-images'].default, false); - // Two dots isn't a number to YAML, so a version-like default stays the string it looks like. - assert.strictEqual(byName['package-version'].default, '0.0.1'); + // No `type:` on this input, so GitLab resolves it as a string — the default stays the text it appears as. + assert.strictEqual(byName['skip-find-images'].default, 'false'); // A hyphenated input with no default is marked required, same as a non-hyphenated one. assert.strictEqual(byName['package-name'].default, undefined); @@ -294,33 +293,53 @@ suite('GitLabSpecParser.parse — default value types', () => { assert.strictEqual(byName['numeric_string'].default, '8080'); }); - test('infers an omitted type from the default, as GitLab does', () => { - // Without this, an untyped `default: false` keeps the 'string' type fallback, and completion offers no - // true/false choice because it has no way to tell the input is a boolean. + test('treats an omitted type as string, leaving the default as written', () => { + // GitLab resolves an untyped input as StringInput and coerces the default with `to_s`, so an untyped default + // is text whatever it looks like. Sending it through YAML instead would renumber it: `0755` loses its leading + // zero, `1.0` becomes `1`, `1e5` becomes `100000` — silent value corruption. const template = `spec: inputs: - untyped_bool: + file_mode: + default: 0755 + version: + default: 1.0 + scientific: + default: 1e5 + padded: + default: 007 + flag: description: no type declared default: false - untyped_number: - default: 8080 - untyped_string: - default: production - untyped_array: - default: [a, b] untyped_nothing: description: no type and no default`; const parsed = GitLabSpecParser.parse(template); const byName = Object.fromEntries(parsed.variables.map((v) => [v.name, v])); - assert.strictEqual(byName['untyped_bool'].type, 'boolean'); - assert.strictEqual(byName['untyped_bool'].default, false); - assert.strictEqual(byName['untyped_number'].type, 'number'); - assert.strictEqual(byName['untyped_string'].type, 'string'); - assert.strictEqual(byName['untyped_array'].type, 'array'); - // Nothing to infer from, so the 'string' fallback stands. - assert.strictEqual(byName['untyped_nothing'].type, 'string'); + assert.strictEqual(byName['file_mode'].default, '0755'); + assert.strictEqual(byName['version'].default, '1.0'); + assert.strictEqual(byName['scientific'].default, '1e5'); + assert.strictEqual(byName['padded'].default, '007'); + assert.strictEqual(byName['flag'].default, 'false'); + for (const name of ['file_mode', 'version', 'scientific', 'padded', 'flag', 'untyped_nothing']) { + assert.strictEqual(byName[name].type, 'string', `${name} should resolve as a string input`); + } + }); + + test('a valueless `default:` at input-name indentation is not read as an input', () => { + // On a spec indented `inputs:` at 0, an input's keys sit at 4 and match the input-name pattern — a bare + // `default:` would otherwise open a phantom input named `default` and swallow the real input's value. + const template = `spec: +inputs: + flag: + type: boolean + default:`; + + const parsed = GitLabSpecParser.parse(template); + + assert.deepStrictEqual(parsed.variables.map((v) => v.name), ['flag']); + assert.strictEqual(parsed.variables[0].default, ''); + assert.strictEqual(parsed.variables[0].required, false); }); test('an explicit `type: string` keeps a numeric-looking default as text', () => { diff --git a/tests/unit/componentFetcherTemplates.test.ts b/tests/unit/componentFetcherTemplates.test.ts index 55dea148..dc7a0e86 100644 --- a/tests/unit/componentFetcherTemplates.test.ts +++ b/tests/unit/componentFetcherTemplates.test.ts @@ -200,6 +200,17 @@ suite('backfillParameterSpecDetail', () => { assert.strictEqual(merged[0].default, 'staging'); }); + test('does not downgrade a catalog type to the template parse fallback', () => { + // Both sides fall back to 'string', so a template 'string' may just mean the line-based parse missed the + // `type:` line. Overwriting with it would lose a type the catalog got right. + const catalog = [param('count', { type: 'number' })]; + const template = [param('count', { type: 'string' })]; + + const merged = backfillParameterSpecDetail(catalog, template); + + assert.strictEqual(merged[0].type, 'number'); + }); + test('keeps a catalog default the template parse does not have', () => { const catalog = [param('env', { default: 'production' })]; const template = [param('env')]; // no default parsed