From ad2dc5759a3734ea33b55368c22670f5dd9e5e64 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sat, 9 May 2026 09:23:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(voyage):=20add=20OPTIONAL=5FSTRING=5FKEYS?= =?UTF-8?q?=20path=20to=20manifest-yaml=20=E2=80=94=20profile=5Fused=20add?= =?UTF-8?q?itive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 av v4.1-execute (Wave 1, Session 1). Legg ny eksportert const OPTIONAL_STRING_KEYS = ['profile_used'] parallel til eksisterende OPTIONAL_KEYS. Utvid parseManifest med ny dispatch-loop etter OPTIONAL_BOOLEAN_KEYS. Returnerer MANIFEST_OPTIONAL_TYPE hvis profile_used finnes men ikke er string. Forskjell fra OPTIONAL_BOOLEAN_KEYS: absence == not-present (NOT defaulted til false, unlike boolean). Downstream-konsumenter kan dermed skille mellom unset og empty-string. Tester (5 nye, baseline 372 → 377): - OPTIONAL_STRING_KEYS export drift-pin - profile_used: economy parses successfully (SC #10 forward-compat) - profile_used: numeric rejected - absence: field NOT in parsed (string-key semantics) - profile_used + skip_commit_check + memory_write co-existence Co-Authored-By: Claude Opus 4.7 --- plugins/voyage/lib/parsers/manifest-yaml.mjs | 28 +++++++++++- .../lib/manifest-schema-extensions.test.mjs | 43 ++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/plugins/voyage/lib/parsers/manifest-yaml.mjs b/plugins/voyage/lib/parsers/manifest-yaml.mjs index d1cb24d..c5a3903 100644 --- a/plugins/voyage/lib/parsers/manifest-yaml.mjs +++ b/plugins/voyage/lib/parsers/manifest-yaml.mjs @@ -31,7 +31,20 @@ const OPTIONAL_KEYS = [ const OPTIONAL_BOOLEAN_KEYS = new Set(OPTIONAL_KEYS); -export { OPTIONAL_KEYS }; +// Optional string-typed manifest keys (v4.1 Step 3 — additive forward-compat). +// `profile_used`: name of the model profile (economy|balanced|premium|) the +// step was executed under. Absence is fine (v4.0 manifests have no +// profile concept); presence MUST be a string. +// Unlike OPTIONAL_BOOLEAN_KEYS, absence is NOT defaulted — the field is simply +// missing from `parsed` so downstream consumers can distinguish "not set" from +// "explicitly empty string". +const OPTIONAL_STRING_KEYS = [ + 'profile_used', +]; + +const OPTIONAL_STRING_KEYS_SET = new Set(OPTIONAL_STRING_KEYS); + +export { OPTIONAL_KEYS, OPTIONAL_STRING_KEYS }; /** * Extract the first fenced YAML block whose first non-blank line begins with @@ -110,6 +123,19 @@ export function parseManifest(stepBody) { } } + // v4.1 Step 3 — string-typed optional keys. Absence == not-present (no default, + // unlike boolean keys above) so downstream can distinguish unset vs empty string. + for (const k of OPTIONAL_STRING_KEYS_SET) { + if (k in parsed) { + if (typeof parsed[k] !== 'string') { + errors.push(issue( + 'MANIFEST_OPTIONAL_TYPE', + `${k} must be string if present (got ${typeof parsed[k]})`, + )); + } + } + } + return { valid: errors.length === 0, errors, warnings, parsed }; } diff --git a/plugins/voyage/tests/lib/manifest-schema-extensions.test.mjs b/plugins/voyage/tests/lib/manifest-schema-extensions.test.mjs index fa326f7..5f2fe00 100644 --- a/plugins/voyage/tests/lib/manifest-schema-extensions.test.mjs +++ b/plugins/voyage/tests/lib/manifest-schema-extensions.test.mjs @@ -8,7 +8,7 @@ import { test } from 'node:test'; import { strict as assert } from 'node:assert'; -import { parseManifest, OPTIONAL_KEYS } from '../../lib/parsers/manifest-yaml.mjs'; +import { parseManifest, OPTIONAL_KEYS, OPTIONAL_STRING_KEYS } from '../../lib/parsers/manifest-yaml.mjs'; const BASE = `### Step 1: Cover - Manifest: @@ -90,3 +90,44 @@ test('extension does NOT break REQUIRED_KEYS contract', () => { assert.ok(k in r.parsed, `required key ${k} missing after extension`); } }); + +// v4.1 Step 3 — OPTIONAL_STRING_KEYS dispatch (profile_used) + +test('OPTIONAL_STRING_KEYS exports profile_used', () => { + assert.deepEqual( + [...OPTIONAL_STRING_KEYS].sort(), + ['profile_used'].sort(), + 'OPTIONAL_STRING_KEYS export drift — pin contract', + ); +}); + +test('profile_used: economy parses successfully (SC #10 forward-compat)', () => { + const r = parseManifest(bodyWithExtras(' profile_used: economy')); + assert.equal(r.valid, true, JSON.stringify(r.errors)); + assert.equal(r.parsed.profile_used, 'economy'); +}); + +test('profile_used: numeric rejected with MANIFEST_OPTIONAL_TYPE', () => { + const r = parseManifest(bodyWithExtras(' profile_used: 42')); + assert.equal(r.valid, false); + const found = r.errors.find(e => e.code === 'MANIFEST_OPTIONAL_TYPE'); + assert.ok(found, `expected MANIFEST_OPTIONAL_TYPE, got: ${JSON.stringify(r.errors)}`); + assert.match(found.message, /profile_used/); + assert.match(found.message, /string/); +}); + +test('absence of profile_used: field is NOT in parsed (NOT defaulted, unlike boolean)', () => { + const r = parseManifest(bodyOnlyRequired()); + assert.equal(r.valid, true, JSON.stringify(r.errors)); + // Absence semantics differ from boolean: parsed should NOT contain the key + assert.equal('profile_used' in r.parsed, false, + 'profile_used must NOT be auto-defaulted when absent — string-key semantics'); +}); + +test('profile_used works alongside boolean optional keys (skip_commit_check + memory_write)', () => { + const r = parseManifest(bodyWithExtras(' skip_commit_check: true\n memory_write: true\n profile_used: balanced')); + assert.equal(r.valid, true, JSON.stringify(r.errors)); + assert.equal(r.parsed.skip_commit_check, true); + assert.equal(r.parsed.memory_write, true); + assert.equal(r.parsed.profile_used, 'balanced'); +});