Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 2 additions & 10 deletions src/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ import { join } from "path";
import { matchesIgnore, RESOURCES_DIR } from "./config.ts";
import { findOrphanResourceIds } from "./new-file-gate.ts";
import {
extractBaseSlug,
fetchAllResources,
listExistingResourceIds,
type VapiResource,
} from "./pull.ts";
import { FOLDER_MAP } from "./resources.ts";
import { extractBaseSlug, slugify } from "./slug-utils.ts";
import { loadState } from "./state.ts";
import type { ResourceType, StateFile } from "./types.ts";
import { VALID_RESOURCE_TYPES } from "./types.ts";
Expand Down Expand Up @@ -132,17 +132,9 @@ async function defaultReadAssistantTools(
}

// ─────────────────────────────────────────────────────────────────────────────
// Slug helpers (kept local; mirror src/pull.ts conventions)
// Resource name extraction
// ─────────────────────────────────────────────────────────────────────────────

function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/-+/g, "-");
}

function extractRemoteName(resource: VapiResource): string | undefined {
if (typeof resource.name === "string" && resource.name) return resource.name;
// Tools store their name under function.name
Expand Down
29 changes: 9 additions & 20 deletions src/dep-dedup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,18 @@
// duplicates from prior bug runs — the caller should warn and surface
// the loser UUIDs so a follow-up `npm run cleanup` can prune them).
//
// NOTE on duplication: `slugify` and `extractBaseSlug` here mirror the
// definitions in `src/pull.ts`. pull.ts imports config.ts, which calls
// `parseEnvironment()` at module load and `process.exit(1)`s without a
// CLI env arg — making it impossible to import in a unit test. This
// module imports ONLY from `./types.ts` so it stays testable in
// isolation. Five lines duplicated is the right tradeoff; do not "DRY"
// these back into pull.ts.
// `slugify` and `extractBaseSlug` previously lived inline here as a
// deliberate copy of pull.ts's definitions — pull.ts imports config.ts
// which `process.exit(1)`s under unit tests, blocking direct reuse. They
// now live in `./slug-utils.ts` (config-free, side-effect-free) and are
// re-exported below so any existing test importing them from this module
// keeps working unchanged.

import { extractBaseSlug, slugify } from "./slug-utils.ts";
import type { ResourceState } from "./types.ts";

export { extractBaseSlug, slugify } from "./slug-utils.ts";

export interface RemoteResource {
id: string;
name?: string;
Expand All @@ -43,19 +45,6 @@ export interface DedupMatch {
duplicateUuids: string[];
}

export function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/-+/g, "-");
}

export function extractBaseSlug(resourceId: string): string {
const match = resourceId.match(/^(.*)-([a-f0-9]{8})$/i);
return match?.[1] ?? resourceId;
}

// Minimal payload shape this module needs. Local resource files are loaded
// as `Record<string, unknown>`, so the only fields we know exist are `name`
// (top-level, used by SOs / assistants / squads) and a nested `function.name`
Expand Down
7 changes: 4 additions & 3 deletions src/new-file-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@
// ─────────────────────────────────────────────────────────────────────────────

import { matchesIgnore, VAPI_ENV } from "./config.ts";
import { extractBaseSlug, listExistingResourceIds } from "./pull.ts";
import { listExistingResourceIds } from "./pull.ts";
import { FOLDER_MAP } from "./resources.ts";
import { extractBaseSlug } from "./slug-utils.ts";
import type { ResourceType, StateFile } from "./types.ts";
import { VALID_RESOURCE_TYPES } from "./types.ts";

Expand Down Expand Up @@ -62,8 +63,8 @@ export interface DetectOrphanYamlsOptions {
// list are reported. Mirrors `APPLY_FILTER.filePaths` semantics used by
// selective push.
filePathFilter?: string[];
// Optional override of `extractBaseSlug`. Defaults to the pull.ts helper
// — only swapped in tests to keep the unit suite filesystem-free.
// Optional override of `extractBaseSlug`. Defaults to the slug-utils
// helper — only swapped in tests to keep the unit suite filesystem-free.
extractBaseSlug?: (resourceId: string) => string;
// Optional override of `matchesIgnore`. Defaults to the config.ts helper
// which reads `.vapi-ignore` from disk. Tests pass a stub so they don't
Expand Down
30 changes: 3 additions & 27 deletions src/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
formatRecanonicalizeReport,
recanonicalizeStateKeys,
} from "./recanonicalize.ts";
import { FOLDER_MAP } from "./resources.ts";
import { extractBaseSlug, slugify } from "./slug-utils.ts";
import { hashPayload, loadState, saveState, upsertState } from "./state.ts";
import type { ResourceState, ResourceType, StateFile } from "./types.ts";

Expand Down Expand Up @@ -59,19 +61,6 @@ const ENDPOINT_MAP: Record<ResourceType, string> = {
evals: "/eval",
};

// Map resource types to their folder paths (relative to resources/)
const FOLDER_MAP: Record<ResourceType, string> = {
tools: "tools",
structuredOutputs: "structuredOutputs",
assistants: "assistants",
squads: "squads",
personalities: "simulations/personalities",
scenarios: "simulations/scenarios",
simulations: "simulations/tests",
simulationSuites: "simulations/suites",
evals: "evals",
};

// ─────────────────────────────────────────────────────────────────────────────
// Git Helpers (detect locally changed files to skip during pull)
// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -251,17 +240,9 @@ async function pullCredentials(state: StateFile): Promise<void> {
}

// ─────────────────────────────────────────────────────────────────────────────
// Naming & Slug Generation
// Resource naming (slug generation lives in src/slug-utils.ts)
// ─────────────────────────────────────────────────────────────────────────────

function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/-+/g, "-");
}

function extractName(resource: VapiResource): string | undefined {
if (resource.name) return resource.name;
// Tools store their name under function.name
Expand All @@ -276,11 +257,6 @@ function generateResourceId(resource: VapiResource): string {
return name ? `${slugify(name)}-${shortId}` : `resource-${shortId}`;
}

export function extractBaseSlug(resourceId: string): string {
const match = resourceId.match(/^(.*)-([a-f0-9]{8})$/i);
return match?.[1] ?? resourceId;
}

export function resourceIdMatchesName(
resourceId: string,
resource: VapiResource,
Expand Down
26 changes: 9 additions & 17 deletions src/recanonicalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,11 @@ import { existsSync } from "fs";
import { join } from "path";
import { RESOURCES_DIR } from "./config.ts";
import { FOLDER_MAP, VALID_EXTENSIONS } from "./resources.ts";
import { isEngineSuffixedSlug } from "./slug-utils.ts";
import type { TouchedSets } from "./state-merge.ts";
import type { ResourceType, StateFile } from "./types.ts";
import { VALID_RESOURCE_TYPES } from "./types.ts";

const UUID_SUFFIX_RE = /^(.+)-([0-9a-f]{8})$/i;

export interface RecanonicalizeRekey {
type: ResourceType;
fromKey: string;
Expand Down Expand Up @@ -132,21 +131,14 @@ export function recanonicalizeStateKeys(
const entry = section[stateKey];
if (!entry) continue;

const match = stateKey.match(UUID_SUFFIX_RE);
if (!match) continue;

const [, canonicalSlug, capturedUuid8] = match;
if (!canonicalSlug || !capturedUuid8) continue;

// Precondition 2 — only recanonicalize engine-generated suffixes. If
// the captured 8 hex chars don't match the entry's UUID prefix, this
// is a user-named resource that coincidentally looks suffixed.
//
// Mirrors generateResourceId() in src/pull.ts:265-273 — UUID dashes
// start at index 8, so `slice(0, 8)` on the raw UUID is equivalent
// to stripping dashes first; the dash-strip is defensive.
const entryUuid8 = entry.uuid.replace(/-/g, "").slice(0, 8).toLowerCase();
if (capturedUuid8.toLowerCase() !== entryUuid8) continue;
// Preconditions 1 + 2 — the key must match the engine-generated
// shape `<base>-<uuid8>` AND the captured 8-hex must match the
// entry's UUID prefix. `isEngineSuffixedSlug` returns `null` on
// either failure, ruling out user-named resources that
// coincidentally end in `-abcd1234`.
const parsed = isEngineSuffixedSlug(stateKey, entry.uuid);
if (!parsed) continue;
const canonicalSlug = parsed.base;

// Precondition 3 — canonical slot must be unclaimed in state.
//
Expand Down
9 changes: 1 addition & 8 deletions src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { mkdir, rm, writeFile } from "fs/promises";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import searchableCheckbox, { BACK_SENTINEL } from "./searchableCheckbox.js";
import { slugify } from "./slug-utils.ts";

// ─────────────────────────────────────────────────────────────────────────────
// Constants
Expand Down Expand Up @@ -152,14 +153,6 @@ async function fetchAllResourceSnapshots(
// Slug helpers
// ─────────────────────────────────────────────────────────────────────────────

function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/-+/g, "-");
}

// ─────────────────────────────────────────────────────────────────────────────
// Dependency detection — scan selected resources for UUID references
// to resources that aren't yet selected
Expand Down
77 changes: 77 additions & 0 deletions src/slug-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// ─────────────────────────────────────────────────────────────────────────────
// Shared slug helpers — config-free, no side-effect imports.
//
// This module exists to break two duplications that previously lived across
// `pull.ts`, `dep-dedup.ts`, `audit.ts`, and `setup.ts`:
// - `slugify(name)` — 4 byte-identical copies
// - `extractBaseSlug(resourceId)` — 2 byte-identical copies
//
// It also exposes the strict `isEngineSuffixedSlug` form used by
// `recanonicalize.ts` to prove a state key was engine-generated (i.e. the
// captured 8-hex matches the entry's UUID prefix), and the canonical
// `UUID_SUFFIX_RE` constant.
//
// Config-free is load-bearing: `config.ts` asserts `argv[2]` / `VAPI_TOKEN`
// at module load. Any test that imports a slug helper without going through
// this module would otherwise have to prime `process.argv` and
// `process.env.VAPI_TOKEN` (see `tests/recanonicalize.test.ts:7-8`). This
// module has zero such side effects so it's safely importable from any test.
// ─────────────────────────────────────────────────────────────────────────────

// `^(.+)-([0-9a-f]{8})$` deliberately requires a non-empty base. An engine-
// generated state key always carries a real slug before the 8-hex suffix —
// the synthetic `-deadbeef` shape (empty base) is never produced.
export const UUID_SUFFIX_RE = /^(.+)-([0-9a-f]{8})$/i;

// Lowercase, replace non-alphanumeric runs with `-`, trim leading/trailing
// `-`, and collapse repeated `-`. Mirrors the slug shape produced by
// `generateResourceId` in `src/pull.ts` and downstream `<base>-<uuid8>`
// patterns.
export function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/-+/g, "-");
}

// Loose form: strip a trailing 8-hex segment if the resourceId matches the
// engine-generated `<base>-<uuid8>` shape; otherwise return the input
// unchanged. Used by callers that don't have a UUID handy (audit's
// sibling-base-slug check, the orphan-gate's pairing pass, pull's
// `findExistingResourceId`, dep-dedup's `extractBaseSlug` consumers).
//
// This intentionally does NOT verify that the captured suffix matches any
// specific UUID — that proof requires `isEngineSuffixedSlug`. Loose callers
// only need a best-effort canonical form.
export function extractBaseSlug(resourceId: string): string {
const match = resourceId.match(UUID_SUFFIX_RE);
return match?.[1] ?? resourceId;
}

// Strict form: return the parsed `{ base, suffix }` ONLY when the captured
// 8 hex chars match the leading 8 hex chars of `uuid` (case-insensitive,
// dashes stripped defensively). Returns `null` otherwise — including when
// the resourceId doesn't match the engine shape at all.
//
// Use this when you have BOTH a state key AND its entry's UUID and need to
// prove the key was engine-generated (the precondition-2 check in
// `recanonicalize.ts`). A user-given name that coincidentally ends in
// `-abcd1234` will NOT match because its UUID prefix is different.
//
// Mirrors `generateResourceId` in `src/pull.ts:265-273` — UUIDs have the
// form `xxxxxxxx-xxxx-...` so the first 8 hex chars are dash-free, but the
// dash-strip is kept as defense against malformed input.
export function isEngineSuffixedSlug(
stateKey: string,
uuid: string,
): { base: string; suffix: string } | null {
const match = stateKey.match(UUID_SUFFIX_RE);
if (!match) return null;
const base = match[1];
const capturedSuffix = match[2];
if (!base || !capturedSuffix) return null;
const uuidPrefix = uuid.replace(/-/g, "").slice(0, 8).toLowerCase();
if (capturedSuffix.toLowerCase() !== uuidPrefix) return null;
return { base, suffix: capturedSuffix.toLowerCase() };
}
Loading