From e8de7dfa30cf07d9962572c7de2610058d23811f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Tue, 17 Mar 2026 18:01:52 -0600 Subject: [PATCH] fix(gsd): consolidate string-array normalizer functions into shared utility (#1009) Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/doctor.ts | 5 ---- .../extensions/gsd/migrate/parsers.ts | 20 ++++++-------- src/resources/extensions/gsd/preferences.ts | 26 +++++++------------ .../extensions/shared/format-utils.ts | 16 ++++++++++++ 4 files changed, 34 insertions(+), 33 deletions(-) diff --git a/src/resources/extensions/gsd/doctor.ts b/src/resources/extensions/gsd/doctor.ts index a11490545..24135f316 100644 --- a/src/resources/extensions/gsd/doctor.ts +++ b/src/resources/extensions/gsd/doctor.ts @@ -74,11 +74,6 @@ export interface DoctorSummary { byCode: Array<{ code: DoctorIssueCode; count: number }>; } -function normalizeStringArray(value: unknown): string[] | undefined { - if (!Array.isArray(value)) return undefined; - const items = value.filter((item): item is string => typeof item === "string").map(item => item.trim()).filter(Boolean); - return items.length > 0 ? Array.from(new Set(items)) : undefined; -} function validatePreferenceShape(preferences: GSDPreferences): string[] { const issues: string[] = []; diff --git a/src/resources/extensions/gsd/migrate/parsers.ts b/src/resources/extensions/gsd/migrate/parsers.ts index 5de6514c6..05d46deb7 100644 --- a/src/resources/extensions/gsd/migrate/parsers.ts +++ b/src/resources/extensions/gsd/migrate/parsers.ts @@ -3,6 +3,7 @@ // Zero Pi dependencies — uses only exported helpers from files.ts. import { splitFrontmatter, parseFrontmatterMap, extractBoldField } from '../files.js'; +import { normalizeStringArray } from '../../shared/format-utils.js'; import type { PlanningRoadmap, @@ -366,11 +367,6 @@ function parseRequiresArray(raw: unknown): PlanningSummaryRequires[] { }); } -function toStringArray(val: unknown): string[] { - if (Array.isArray(val)) return val.map(String); - return []; -} - /** * Parse YAML-like frontmatter lines into a flat key-value map. * Like parseFrontmatterMap but supports hyphenated keys (e.g. `tech-stack:`). @@ -459,14 +455,14 @@ function parseSummaryFrontmatter(fm: Record): PlanningSummaryFr phase: unquote(fm.phase), plan: unquote(fm.plan), subsystem: unquote(fm.subsystem), - tags: toStringArray(fm.tags), + tags: normalizeStringArray(fm.tags), requires: parseRequiresArray(fm.requires), - provides: toStringArray(fm.provides), - affects: toStringArray(fm.affects), - 'tech-stack': toStringArray(fm['tech-stack']), - 'key-files': toStringArray(fm['key-files']), - 'key-decisions': toStringArray(fm['key-decisions']), - 'patterns-established': toStringArray(fm['patterns-established']), + provides: normalizeStringArray(fm.provides), + affects: normalizeStringArray(fm.affects), + 'tech-stack': normalizeStringArray(fm['tech-stack']), + 'key-files': normalizeStringArray(fm['key-files']), + 'key-decisions': normalizeStringArray(fm['key-decisions']), + 'patterns-established': normalizeStringArray(fm['patterns-established']), duration: unquote(fm.duration), completed: unquote(fm.completed), }; diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 912f5f0f6..7a9c911d2 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -8,6 +8,7 @@ import type { PostUnitHookConfig, PreDispatchHookConfig, BudgetEnforcementMode, import type { DynamicRoutingConfig } from "./model-router.js"; import { defaultRoutingConfig } from "./model-router.js"; import { VALID_BRANCH_NAME } from "./git-service.js"; +import { normalizeStringArray } from "../shared/format-utils.js"; const GLOBAL_PREFERENCES_PATH = join(homedir(), ".gsd", "preferences.md"); const LEGACY_GLOBAL_PREFERENCES_PATH = join(homedir(), ".pi", "agent", "gsd-preferences.md"); @@ -869,10 +870,10 @@ export function validatePreferences(preferences: GSDPreferences): { } } - validated.always_use_skills = normalizeStringList(preferences.always_use_skills); - validated.prefer_skills = normalizeStringList(preferences.prefer_skills); - validated.avoid_skills = normalizeStringList(preferences.avoid_skills); - validated.custom_instructions = normalizeStringList(preferences.custom_instructions); + validated.always_use_skills = normalizeStringArray(preferences.always_use_skills); + validated.prefer_skills = normalizeStringArray(preferences.prefer_skills); + validated.avoid_skills = normalizeStringArray(preferences.avoid_skills); + validated.custom_instructions = normalizeStringArray(preferences.custom_instructions); if (preferences.skill_rules) { const validRules: GSDSkillRule[] = []; @@ -888,7 +889,7 @@ export function validatePreferences(preferences: GSDPreferences): { } const validatedRule: GSDSkillRule = { when }; for (const action of SKILL_ACTIONS) { - const values = normalizeStringList((rule as unknown as Record)[action]); + const values = normalizeStringArray((rule as unknown as Record)[action]); if (values.length > 0) { validatedRule[action as keyof GSDSkillRule] = values as never; } @@ -1052,7 +1053,7 @@ export function validatePreferences(preferences: GSDPreferences): { errors.push(`duplicate post_unit_hooks name: ${name}`); continue; } - const after = normalizeStringList(hook.after); + const after = normalizeStringArray(hook.after); if (after.length === 0) { errors.push(`post_unit_hooks "${name}" missing after`); continue; @@ -1119,7 +1120,7 @@ export function validatePreferences(preferences: GSDPreferences): { errors.push(`duplicate pre_dispatch_hooks name: ${name}`); continue; } - const before = normalizeStringList(hook.before); + const before = normalizeStringArray(hook.before); if (before.length === 0) { errors.push(`pre_dispatch_hooks "${name}" missing before`); continue; @@ -1382,21 +1383,14 @@ export function validatePreferences(preferences: GSDPreferences): { function mergeStringLists(base?: unknown, override?: unknown): string[] | undefined { const merged = [ - ...normalizeStringList(base), - ...normalizeStringList(override), + ...normalizeStringArray(base), + ...normalizeStringArray(override), ] .map((item) => item.trim()) .filter(Boolean); return merged.length > 0 ? Array.from(new Set(merged)) : undefined; } -function normalizeStringList(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value - .filter((item): item is string => typeof item === "string") - .map((item) => item.trim()) - .filter(Boolean); -} function mergePostUnitHooks( base?: PostUnitHookConfig[], diff --git a/src/resources/extensions/shared/format-utils.ts b/src/resources/extensions/shared/format-utils.ts index 008bb1326..3b9d2ce75 100644 --- a/src/resources/extensions/shared/format-utils.ts +++ b/src/resources/extensions/shared/format-utils.ts @@ -93,3 +93,19 @@ export function sparkline(values: number[]): string { export function stripAnsi(s: string): string { return s.replace(/\x1b\[[0-9;]*m/g, ""); } + +// ─── String Array Normalization ───────────────────────────────────────────── + +/** + * Normalize an unknown value to a string array. + * Filters to string items, trims whitespace, removes empty strings. + * Optionally deduplicates. + */ +export function normalizeStringArray(value: unknown, options?: { dedupe?: boolean }): string[] { + if (!Array.isArray(value)) return []; + const items = value + .filter((item): item is string => typeof item === "string") + .map(item => item.trim()) + .filter(Boolean); + return options?.dedupe ? [...new Set(items)] : items; +}