fix(gsd): consolidate string-array normalizer functions into shared utility (#1009)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-17 18:01:52 -06:00 committed by GitHub
parent 5ecb6c6abb
commit e8de7dfa30
4 changed files with 34 additions and 33 deletions

View file

@ -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[] = [];

View file

@ -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<string, unknown>): 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),
};

View file

@ -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<string, unknown>)[action]);
const values = normalizeStringArray((rule as unknown as Record<string, unknown>)[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[],

View file

@ -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;
}