diff --git a/src/apply.ts b/src/apply.ts index 86acb0e..0d9df37 100644 --- a/src/apply.ts +++ b/src/apply.ts @@ -33,7 +33,7 @@ export async function runApply(): Promise { const pushArgs = allArgs.join(" "); if (!env || !SLUG_RE.test(env)) { - console.error("Usage: npm run apply [--force]"); + console.error("Usage: npm run apply [--force] [--allow-new-files]"); console.error(""); console.error(" Pull → Merge → Push (safe bidirectional sync)"); console.error(""); @@ -41,9 +41,20 @@ export async function runApply(): Promise { console.error(" then pushes the result back to the platform."); console.error(""); console.error( - " --force Enable deletions: resources you deleted locally", + " --force Enable deletions: resources you deleted locally", + ); + console.error( + " will also be deleted from the platform.", + ); + console.error( + " --allow-new-files Bypass the orphan-YAML pre-flight gate (push stage).", + ); + console.error( + " Use only after confirming every local file without a", + ); + console.error( + " state entry is genuinely new — see src/new-file-gate.ts.", ); - console.error(" will also be deleted from the platform."); process.exit(1); } diff --git a/src/audit.ts b/src/audit.ts index 2a1d1d2..ebc30a3 100644 --- a/src/audit.ts +++ b/src/audit.ts @@ -21,6 +21,7 @@ import { readFile } from "fs/promises"; import { join } from "path"; import { matchesIgnore, RESOURCES_DIR } from "./config.ts"; +import { findOrphanResourceIds } from "./new-file-gate.ts"; import { extractBaseSlug, fetchAllResources, @@ -179,21 +180,20 @@ function checkOrphanYaml( localIds: string[], state: StateFile, ): AuditFinding[] { - const stateKeys = new Set(Object.keys(state[type])); - const findings: AuditFinding[] = []; - for (const localId of localIds) { - if (stateKeys.has(localId)) continue; - findings.push({ - severity: "warn", - type, - rule: "orphan-yaml", - resourceIds: [localId], - message: `local file ${type}/${localId} has no state entry`, - suggestedAction: - "delete file OR run `npm run pull` to re-key it into state", - }); - } - return findings; + // Single source of truth for "what counts as an orphan YAML" — shared with + // the push-time pre-flight gate in src/new-file-gate.ts. Both surfaces map + // the same predicate to different output shapes (here: AuditFinding[]; + // there: OrphanFile[] + verbose halt message). + const orphanIds = findOrphanResourceIds(type, localIds, state); + return orphanIds.map((localId) => ({ + severity: "warn", + type, + rule: "orphan-yaml", + resourceIds: [localId], + message: `local file ${type}/${localId} has no state entry`, + suggestedAction: + "delete file OR run `npm run pull` to re-key it into state", + })); } function checkStateGhosts( diff --git a/src/config.ts b/src/config.ts index fd952a9..1d43f1b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -44,6 +44,9 @@ function parseEnvironment(): Environment { console.error( " --type (apply only specific resource type, repeatable)", ); + console.error( + " --allow-new-files (bypass orphan-YAML pre-flight gate)", + ); console.error(" -- (apply only specific files)"); process.exit(1); } @@ -88,6 +91,7 @@ function parseFlags(): { dryRun: boolean; strictValidation: boolean; overwriteDrift: boolean; + allowNewFiles: boolean; applyFilter: ApplyFilter; } { const args = process.argv.slice(3); @@ -97,6 +101,7 @@ function parseFlags(): { dryRun: boolean; strictValidation: boolean; overwriteDrift: boolean; + allowNewFiles: boolean; applyFilter: ApplyFilter; } = { forceDelete: args.includes("--force"), @@ -104,6 +109,7 @@ function parseFlags(): { dryRun: args.includes("--dry-run"), strictValidation: args.includes("--strict"), overwriteDrift: args.includes("--overwrite"), + allowNewFiles: args.includes("--allow-new-files"), applyFilter: {}, }; @@ -119,7 +125,8 @@ function parseFlags(): { arg === "--bootstrap" || arg === "--dry-run" || arg === "--strict" || - arg === "--overwrite" + arg === "--overwrite" || + arg === "--allow-new-files" ) continue; @@ -184,7 +191,7 @@ function parseFlags(): { console.error( ` Expected a resource type (e.g. assistants, tools), a folder path ` + `(e.g. assistants/foo.yml or resources//assistants/foo.yml), or ` + - `a flag (--force, --bootstrap, --type, --id).`, + `a flag (--force, --bootstrap, --type, --id, --allow-new-files).`, ); process.exit(1); } @@ -257,6 +264,7 @@ export const { dryRun: DRY_RUN, strictValidation: STRICT_VALIDATION, overwriteDrift: OVERWRITE_DRIFT, + allowNewFiles: ALLOW_NEW_FILES, applyFilter: APPLY_FILTER, } = parseFlags(); diff --git a/src/new-file-gate.ts b/src/new-file-gate.ts new file mode 100644 index 0000000..f349b14 --- /dev/null +++ b/src/new-file-gate.ts @@ -0,0 +1,316 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Orphan-YAML pre-flight gate for `npm run push`. +// +// "Orphan YAML" = a local YAML/MD file whose slug has no entry in the state +// file. At push time this is ambiguous from the engine's perspective: +// +// (a) NEW resource the user intentionally wants to create +// (b) RENAME of an existing resource (state has the old slug; the YAML on +// disk has the new name) +// (c) MOVED file — file copied without the state entry being rekeyed +// +// Silently treating every orphan as case (a) is what produced the duplicate +// fleet we surfaced during the gitops-mudflap working session 2026-05-13. +// Flow F (`mv foo.md bar.md` + push), Flow G (dashboard rename → pull writes +// new file but leaves stale YAML), and Flow M (`apply` compresses Flow G into +// one click) all share this shape. +// +// Default-on gate. Halts push with a verbose error listing every orphan and +// pairing them with state-only "rename source" candidates by shared base +// slug. Override: `--allow-new-files`. +// +// Detection logic mirrors src/audit.ts's `checkOrphanYaml` — single source +// of truth for "what counts as an orphan YAML". Audit retains its own +// finding shape; this module exposes the inputs gate-formatted for the +// push CLI. +// ───────────────────────────────────────────────────────────────────────────── + +import { matchesIgnore, VAPI_ENV } from "./config.ts"; +import { extractBaseSlug, listExistingResourceIds } from "./pull.ts"; +import { FOLDER_MAP } from "./resources.ts"; +import type { ResourceType, StateFile } from "./types.ts"; +import { VALID_RESOURCE_TYPES } from "./types.ts"; + +// ───────────────────────────────────────────────────────────────────────────── +// Public types +// ───────────────────────────────────────────────────────────────────────────── + +export interface OrphanFile { + type: ResourceType; + resourceId: string; + relativePath: string; +} + +export interface RenameSource { + type: ResourceType; + resourceId: string; + uuid: string; +} + +export interface OrphanReport { + orphans: OrphanFile[]; + possibleRenameSources: RenameSource[]; + scopedToPaths: boolean; +} + +export interface DetectOrphanYamlsOptions { + state: StateFile; + types?: ResourceType[]; + // DI seam — defaults to filesystem walk via `listExistingResourceIds`. + listLocalIds?: (t: ResourceType) => string[]; + // When provided, only orphans whose relativePath matches an entry in this + // 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. + 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 + // need a fixture-tree. + matchesIgnore?: (folder: string, resourceId: string) => string | null; + // Optional override of `VAPI_ENV`. Defaults to the config.ts constant. + // Tests pass an explicit value to avoid relying on `process.argv` priming + // at module-load time. + vapiEnv?: string; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Detection +// ───────────────────────────────────────────────────────────────────────────── + +// Shared predicate: "which local resourceIds for this type have NO state +// entry?" Both this module's gate and src/audit.ts's `checkOrphanYaml` +// delegate to this so the definition of "orphan YAML" stays unambiguous. +// Kept tiny on purpose — the value is in NOT having two copies of the +// stateKeys.has check drift apart. +export function findOrphanResourceIds( + type: ResourceType, + localIds: string[], + state: StateFile, +): string[] { + const stateKeys = new Set(Object.keys(state[type])); + return localIds.filter((id) => !stateKeys.has(id)); +} + +function pathMatchesAnyFilter( + relativePath: string, + filters: string[], + resourceId: string, +): boolean { + for (const filter of filters) { + // Exact match, suffix match (filter omits the env prefix), or the + // resourceId-with-extension matching at the tail. We deliberately do NOT + // do `filter.endsWith(relativePath)` — that asymmetric direction + // over-matches when the relativePath is short. + if (filter === relativePath) return true; + if (relativePath.endsWith(filter)) return true; + if (filter.endsWith(`${resourceId}.yml`)) return true; + if (filter.endsWith(`${resourceId}.yaml`)) return true; + if (filter.endsWith(`${resourceId}.md`)) return true; + } + return false; +} + +// Detect orphan YAMLs across every resource type. Pairs them with possible +// rename SOURCES — state entries that have no matching local file AND share a +// base slug with at least one orphan. The pairing is heuristic (intended to +// surface candidates, not to auto-fix) so it errs toward listing more than +// strictly necessary. +export function detectOrphanYamls( + opts: DetectOrphanYamlsOptions, +): OrphanReport { + const { + state, + types = [...VALID_RESOURCE_TYPES], + listLocalIds = listExistingResourceIds, + filePathFilter, + extractBaseSlug: baseSlugFn = extractBaseSlug, + matchesIgnore: matchesIgnoreFn = matchesIgnore, + vapiEnv = VAPI_ENV, + } = opts; + + const orphans: OrphanFile[] = []; + // Track state entries that have no matching local file, grouped by type and + // by base slug, so we can pair them with orphan YAMLs sharing that base. + type StateOnlyEntry = { + type: ResourceType; + resourceId: string; + uuid: string; + }; + const stateOnlyByType = new Map(); + // Base slugs that appear in the orphan set, per type. Used to filter the + // rename-source candidates so we only surface plausible pairings. + const orphanBaseSlugsByType = new Map>(); + + const scopedToPaths = !!(filePathFilter && filePathFilter.length > 0); + + for (const type of types) { + const folder = FOLDER_MAP[type]; + const localIds = listLocalIds(type); + const orphanIds = findOrphanResourceIds(type, localIds, state); + const localIdSet = new Set(localIds); + + // Pass 1: orphan YAMLs (local files with no state entry). + for (const localId of orphanIds) { + // Skip files matched by `.vapi-ignore`. The user has explicitly opted + // these out of gitops tracking — they exist on disk but the engine is + // not supposed to upload them. Without this skip, ignored files trip + // the gate and halt every push, which defeats `.vapi-ignore`'s purpose. + if (matchesIgnoreFn(folder, localId)) { + continue; + } + // Determine relative path. Files are loaded with one of {.yml, .yaml, + // .md}; we don't have the actual extension on hand here because + // `listExistingResourceIds` strips it. Use `.md` for assistants (per + // convention) and `.yml` for everything else when reporting — this is + // only for human-readable output, not for matching. + const ext = type === "assistants" ? "md" : "yml"; + const relativePath = `resources/${vapiEnv}/${folder}/${localId}.${ext}`; + if ( + scopedToPaths && + !pathMatchesAnyFilter( + `${folder}/${localId}.${ext}`, + filePathFilter ?? [], + localId, + ) + ) { + continue; + } + orphans.push({ type, resourceId: localId, relativePath }); + const baseSet = orphanBaseSlugsByType.get(type) ?? new Set(); + baseSet.add(baseSlugFn(localId)); + orphanBaseSlugsByType.set(type, baseSet); + } + + // Pass 2: state entries with no matching local file. We collect these per + // type, then in Pass 3 pair them with orphans sharing a base slug. + const stateOnly: StateOnlyEntry[] = []; + for (const [resourceId, entry] of Object.entries(state[type])) { + if (localIdSet.has(resourceId)) continue; + stateOnly.push({ type, resourceId, uuid: entry.uuid }); + } + if (stateOnly.length > 0) { + stateOnlyByType.set(type, stateOnly); + } + } + + // Pass 3: rename-source candidates. For each state-only entry, surface it + // as a candidate when its base slug matches at least one orphan in the same + // type. Cross-type matches are uncommon enough that we ignore them — the + // signal would be noisy. + const possibleRenameSources: RenameSource[] = []; + for (const [type, entries] of stateOnlyByType) { + const orphanBases = orphanBaseSlugsByType.get(type); + if (!orphanBases || orphanBases.size === 0) continue; + for (const entry of entries) { + const base = baseSlugFn(entry.resourceId); + if (!orphanBases.has(base)) continue; + possibleRenameSources.push(entry); + } + } + + return { orphans, possibleRenameSources, scopedToPaths }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Message formatting +// +// ANSI color codes emitted only when the caller passes `color: true` — for +// production this is gated on `process.stderr.isTTY` so pipes / CI logs / +// AI-agent stdout captures get plain text. +// ───────────────────────────────────────────────────────────────────────────── + +interface AnsiPalette { + red: string; + yellow: string; + bold: string; + reset: string; +} + +function buildPalette(color: boolean): AnsiPalette { + if (!color) { + return { red: "", yellow: "", bold: "", reset: "" }; + } + return { + red: "\x1b[31m", + yellow: "\x1b[33m", + bold: "\x1b[1m", + reset: "\x1b[0m", + }; +} + +export function formatGateMessage( + report: OrphanReport, + env: string, + options: { color?: boolean } = {}, +): string { + const ansi = buildPalette(options.color === true); + const { red, yellow, bold, reset } = ansi; + + const lines: string[] = []; + const count = report.orphans.length; + const scopeNote = report.scopedToPaths + ? " (scoped to the selective-push file set)" + : ""; + + lines.push(""); + lines.push( + `${red}${bold}❌ Push refused${reset}${red}: ${count} file(s) on disk have no state-file UUID mapping for env "${env}"${scopeNote}.${reset}`, + ); + lines.push(""); + lines.push("The engine cannot tell whether each is:"); + lines.push(" - A NEW resource you intentionally want to create"); + lines.push( + " - A RENAME of an existing resource (state has the old slug; YAML has new name)", + ); + lines.push(" - A MOVED file (file copied without state updated)"); + lines.push(""); + lines.push(`${bold}Files without state entry:${reset}`); + for (const orphan of report.orphans) { + lines.push(` - ${orphan.relativePath}`); + } + + lines.push(""); + lines.push( + `${bold}State entries with no matching local file (possible rename SOURCES):${reset}`, + ); + if (report.possibleRenameSources.length === 0) { + lines.push(" (none — no plausible rename pairings found)"); + } else { + for (const src of report.possibleRenameSources) { + const shortUuid = `${src.uuid.slice(0, 8)}…`; + lines.push( + ` - ${src.type}/${src.resourceId} → ${shortUuid} (no ${src.resourceId} on disk)`, + ); + } + } + + lines.push(""); + lines.push( + `${yellow}${bold}⚠️ FOR AI AGENTS reading this:${reset} do NOT auto-pass --allow-new-files. Pause`, + ); + lines.push( + "and ask the human to confirm, for EACH file above, whether it is:", + ); + lines.push(" (a) intentionally new (then pass --allow-new-files)"); + lines.push( + " (b) a rename (then rename it back and use `npm run pull` to re-key", + ); + lines.push(" state, or apply the rename explicitly)"); + lines.push(" (c) stale cruft (then delete the local file)"); + lines.push(""); + lines.push( + "If the user is reachable, surface this gate to them. If running headless,", + ); + lines.push( + "abort. Silent --allow-new-files defeats the entire purpose of this gate.", + ); + lines.push(""); + lines.push( + `To proceed (after confirming intent for every file above): re-run with ${bold}--allow-new-files${reset}.`, + ); + lines.push(""); + + return lines.join("\n"); +} diff --git a/src/push.ts b/src/push.ts index 450b6d5..c068429 100644 --- a/src/push.ts +++ b/src/push.ts @@ -2,8 +2,10 @@ import { resolve } from "path"; import { fileURLToPath } from "url"; import { getDryRunCounts, VapiApiError, vapiRequest } from "./api.ts"; import { + ALLOW_NEW_FILES, APPLY_FILTER, BASE_DIR, + BOOTSTRAP_SYNC, DRY_RUN, FORCE_DELETE, loadIgnorePatterns, @@ -19,6 +21,7 @@ import { type RemoteResource, } from "./dep-dedup.ts"; import { checkDriftForUpdate } from "./drift.ts"; +import { detectOrphanYamls, formatGateMessage } from "./new-file-gate.ts"; import { writeSnapshot } from "./snapshot.ts"; import { mergeScoped } from "./state-merge.ts"; import { @@ -1364,6 +1367,37 @@ async function main(): Promise { state = await maybeBootstrapState(loadedResources, state); + // Orphan-YAML pre-flight gate. Runs ONCE for ALL resource types after + // bootstrap (so state-recovery has a chance to rekey first) and BEFORE + // any apply phase. Halts push when local files exist with no state entry + // — the duplicate-creation pattern we surfaced during the gitops-mudflap + // working session 2026-05-13 (see src/new-file-gate.ts for context). + // + // Skipped during explicit `--bootstrap` runs: a bootstrap is supposed to + // populate state from scratch, so every local file legitimately lacks a + // state entry at that point. + if (!BOOTSTRAP_SYNC) { + const orphanReport = detectOrphanYamls({ + state, + filePathFilter: APPLY_FILTER.filePaths, + }); + if (orphanReport.orphans.length > 0) { + if (ALLOW_NEW_FILES) { + const verb = DRY_RUN ? "would create" : "creating"; + console.log( + ` ⚠️ bypassing new-file gate: ${verb} ${orphanReport.orphans.length} new resource(s) on the dashboard`, + ); + } else { + console.error( + formatGateMessage(orphanReport, VAPI_ENV, { + color: process.stderr.isTTY === true, + }), + ); + process.exit(1); + } + } + } + // Run client-side validators against the loaded resource set. In default // mode, errors are surfaced as warnings so a single bad spec doesn't block // an otherwise-good push. With --strict, any error-severity finding aborts diff --git a/tests/new-file-gate.test.ts b/tests/new-file-gate.test.ts new file mode 100644 index 0000000..4ffb6d1 --- /dev/null +++ b/tests/new-file-gate.test.ts @@ -0,0 +1,622 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + cpSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +// new-file-gate.ts → pull.ts → config.ts: config.ts asserts argv[2] / +// VAPI_TOKEN at module load. Prime both before importing the module under +// test. Same trick used in tests/audit.test.ts. +process.argv = ["node", "test", "test-fixture-org"]; +process.env.VAPI_TOKEN = process.env.VAPI_TOKEN || "test-token-not-used"; + +const { detectOrphanYamls, formatGateMessage } = await import( + "../src/new-file-gate.ts" +); + +import type { OrphanReport } from "../src/new-file-gate.ts"; +import type { ResourceState, ResourceType, StateFile } from "../src/types.ts"; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers — DI-style, filesystem-free. +// ───────────────────────────────────────────────────────────────────────────── + +function makeStateEntry(uuid: string): ResourceState { + return { uuid }; +} + +function makeStateFile(overrides: Partial = {}): StateFile { + return { + credentials: {}, + assistants: {}, + structuredOutputs: {}, + tools: {}, + squads: {}, + personalities: {}, + scenarios: {}, + simulations: {}, + simulationSuites: {}, + evals: {}, + ...overrides, + }; +} + +// Mirror of src/pull.ts:extractBaseSlug — kept inline so the unit tests don't +// pull pull.ts transitively (which would re-run the filesystem walk). We +// already exercise the real helper through the integration fixtures. +function fakeExtractBaseSlug(resourceId: string): string { + const match = resourceId.match(/^(.*)-([a-f0-9]{8})$/i); + return match?.[1] ?? resourceId; +} + +interface LocalsByType { + tools?: string[]; + structuredOutputs?: string[]; + assistants?: string[]; + squads?: string[]; + personalities?: string[]; + scenarios?: string[]; + simulations?: string[]; + simulationSuites?: string[]; + evals?: string[]; +} + +function makeListLocalIds(locals: LocalsByType) { + return (t: ResourceType): string[] => { + return locals[t] ?? []; + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// detectOrphanYamls — unit +// ───────────────────────────────────────────────────────────────────────────── + +test("detectOrphanYamls: empty state + 1 local file produces 1 orphan and 0 rename sources", () => { + const report = detectOrphanYamls({ + state: makeStateFile(), + listLocalIds: makeListLocalIds({ assistants: ["orphan-bot"] }), + extractBaseSlug: fakeExtractBaseSlug, + }); + assert.equal(report.orphans.length, 1); + assert.equal(report.orphans[0]!.type, "assistants"); + assert.equal(report.orphans[0]!.resourceId, "orphan-bot"); + assert.match( + report.orphans[0]!.relativePath, + /resources\/[^/]+\/assistants\/orphan-bot\.md/, + ); + assert.equal(report.possibleRenameSources.length, 0); + assert.equal(report.scopedToPaths, false); +}); + +test("detectOrphanYamls: all local files present in state produces empty report", () => { + const report = detectOrphanYamls({ + state: makeStateFile({ + assistants: { + "known-bot": makeStateEntry("uuid-1"), + }, + }), + listLocalIds: makeListLocalIds({ assistants: ["known-bot"] }), + extractBaseSlug: fakeExtractBaseSlug, + }); + assert.equal(report.orphans.length, 0); + assert.equal(report.possibleRenameSources.length, 0); +}); + +test("detectOrphanYamls: rename pairing — state entry without local file shares base slug with orphan", () => { + // Scenario: state has `old-bot-aaaaaaaa` (UUID v1), user renamed it locally + // to `new-bot-aaaaaaaa` (Flow F-ish — base slugs intentionally don't share + // here, so we instead test the more common Flow G: dashboard rename produced + // a new local file with the same base slug but a different UUID suffix. + // The state still has the old suffix; the new file is the orphan). + const report = detectOrphanYamls({ + state: makeStateFile({ + assistants: { + "foo-aaaaaaaa": makeStateEntry("11111111-1111-1111-1111-111111111111"), + }, + }), + listLocalIds: makeListLocalIds({ assistants: ["foo-bbbbbbbb"] }), + extractBaseSlug: fakeExtractBaseSlug, + }); + assert.equal(report.orphans.length, 1); + assert.equal(report.orphans[0]!.resourceId, "foo-bbbbbbbb"); + assert.equal(report.possibleRenameSources.length, 1); + assert.equal(report.possibleRenameSources[0]!.resourceId, "foo-aaaaaaaa"); + assert.equal( + report.possibleRenameSources[0]!.uuid, + "11111111-1111-1111-1111-111111111111", + ); +}); + +test("detectOrphanYamls: state entry without local file but no orphan sharing base slug → no rename pairing", () => { + // A pure state-ghost (no orphan sharing the base) should NOT show up as a + // rename source — that's the audit's `state-ghost` rule, not this gate's + // concern. + const report = detectOrphanYamls({ + state: makeStateFile({ + assistants: { + "ghost-bot-aaaaaaaa": makeStateEntry("uuid-ghost"), + }, + }), + listLocalIds: makeListLocalIds({ assistants: ["totally-new-bot"] }), + extractBaseSlug: fakeExtractBaseSlug, + }); + assert.equal(report.orphans.length, 1); + // ghost-bot has base "ghost-bot"; orphan has base "totally-new-bot" — no + // match → no rename source surfaced. + assert.equal(report.possibleRenameSources.length, 0); +}); + +test("detectOrphanYamls: filePathFilter scopes detection to the selective-push file set", () => { + // Two orphans across two types; only one matches the filter. + const report = detectOrphanYamls({ + state: makeStateFile(), + listLocalIds: makeListLocalIds({ + assistants: ["foo"], + tools: ["bar"], + }), + filePathFilter: ["resources/test/assistants/foo.md"], + extractBaseSlug: fakeExtractBaseSlug, + }); + assert.equal(report.scopedToPaths, true); + assert.equal(report.orphans.length, 1); + assert.equal(report.orphans[0]!.type, "assistants"); + assert.equal(report.orphans[0]!.resourceId, "foo"); +}); + +test("detectOrphanYamls: files matched by `.vapi-ignore` are excluded from orphans (M1 regression guard)", () => { + // The user has explicitly opted certain on-disk files OUT of gitops + // tracking via `.vapi-ignore`. Those files exist on disk but the engine + // never uploads them. Without the .vapi-ignore skip here, the gate would + // halt every push for a file the engine wouldn't have touched anyway — + // defeating the workaround customers use to silence audit noise on stale + // dashboard artifacts. Surfaced by canonical code review of feature/new-file-gate. + const report = detectOrphanYamls({ + state: makeStateFile(), + listLocalIds: makeListLocalIds({ + assistants: ["ignored-stub", "real-orphan"], + }), + extractBaseSlug: fakeExtractBaseSlug, + matchesIgnore: (_folder, resourceId) => + resourceId === "ignored-stub" ? "ignored-stub" : null, + }); + // Only the non-ignored file appears as an orphan; the ignored file is gone. + assert.equal(report.orphans.length, 1); + assert.equal(report.orphans[0]!.resourceId, "real-orphan"); +}); + +test("detectOrphanYamls: filePathFilter that excludes the orphan produces empty report", () => { + // The gate must NOT fire when the orphan is outside the user's selective + // push scope. This is the central case for Flow F (push assistants/foo.md + // while tools/bar.yml happens to be orphan). + const report = detectOrphanYamls({ + state: makeStateFile({ + assistants: { "known-foo": makeStateEntry("uuid-1") }, + }), + listLocalIds: makeListLocalIds({ + assistants: ["known-foo"], + tools: ["orphan-bar"], + }), + filePathFilter: ["resources/test/assistants/known-foo.md"], + extractBaseSlug: fakeExtractBaseSlug, + }); + assert.equal(report.scopedToPaths, true); + assert.equal(report.orphans.length, 0); +}); + +test("detectOrphanYamls: empty state across ALL types → all locals flagged (bootstrap-like)", () => { + // The gate caller suppresses on BOOTSTRAP_SYNC, but detectOrphanYamls + // itself is stateless wrt that flag. Confirm it returns the full orphan + // set when state is empty. + const report = detectOrphanYamls({ + state: makeStateFile(), + listLocalIds: makeListLocalIds({ + assistants: ["a1"], + tools: ["t1", "t2"], + squads: ["s1"], + }), + extractBaseSlug: fakeExtractBaseSlug, + }); + assert.equal(report.orphans.length, 4); + const byType = new Map(); + for (const o of report.orphans) { + byType.set(o.type, (byType.get(o.type) ?? 0) + 1); + } + assert.equal(byType.get("assistants"), 1); + assert.equal(byType.get("tools"), 2); + assert.equal(byType.get("squads"), 1); +}); + +test("detectOrphanYamls: covers all 9 resource types", () => { + const everyType: LocalsByType = { + assistants: ["a"], + tools: ["t"], + structuredOutputs: ["so"], + squads: ["sq"], + personalities: ["p"], + scenarios: ["sc"], + simulations: ["sim"], + simulationSuites: ["suite"], + evals: ["e"], + }; + const report = detectOrphanYamls({ + state: makeStateFile(), + listLocalIds: makeListLocalIds(everyType), + extractBaseSlug: fakeExtractBaseSlug, + }); + assert.equal(report.orphans.length, 9); + const seen = new Set(report.orphans.map((o) => o.type)); + for (const t of [ + "assistants", + "tools", + "structuredOutputs", + "squads", + "personalities", + "scenarios", + "simulations", + "simulationSuites", + "evals", + ] as ResourceType[]) { + assert.equal(seen.has(t), true, `expected ${t} in orphan set`); + } +}); + +test("detectOrphanYamls: relative path extension distinguishes assistants (.md) from others (.yml)", () => { + const report = detectOrphanYamls({ + state: makeStateFile(), + listLocalIds: makeListLocalIds({ + assistants: ["a1"], + tools: ["t1"], + }), + extractBaseSlug: fakeExtractBaseSlug, + }); + const a = report.orphans.find((o) => o.type === "assistants"); + const t = report.orphans.find((o) => o.type === "tools"); + assert.ok(a); + assert.ok(t); + assert.ok(a.relativePath.endsWith(".md")); + assert.ok(t.relativePath.endsWith(".yml")); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// formatGateMessage — unit +// ───────────────────────────────────────────────────────────────────────────── + +function sampleReport(): OrphanReport { + return { + orphans: [ + { + type: "assistants", + resourceId: "foo", + relativePath: "resources/test/assistants/foo.md", + }, + { + type: "tools", + resourceId: "bar", + relativePath: "resources/test/tools/bar.yml", + }, + ], + possibleRenameSources: [ + { + type: "assistants", + resourceId: "old-foo", + uuid: "64df6206-aaaa-bbbb-cccc-dddddddddddd", + }, + ], + scopedToPaths: false, + }; +} + +test("formatGateMessage: color=false produces NO ANSI escape sequences", () => { + const out = formatGateMessage(sampleReport(), "test-env", { color: false }); + // ESC = \x1b — any occurrence is an ANSI code we should not emit. + assert.doesNotMatch( + out, + // eslint-disable-next-line no-control-regex + /\x1b\[/, + `expected no ANSI; got: ${JSON.stringify(out)}`, + ); +}); + +test("formatGateMessage: color=true emits red, yellow, bold, reset escape codes", () => { + const out = formatGateMessage(sampleReport(), "test-env", { color: true }); + assert.ok(out.includes("\x1b[31m"), "expected red"); + assert.ok(out.includes("\x1b[33m"), "expected yellow"); + assert.ok(out.includes("\x1b[1m"), "expected bold"); + assert.ok(out.includes("\x1b[0m"), "expected reset"); +}); + +test("formatGateMessage: includes all orphan relative paths", () => { + const out = formatGateMessage(sampleReport(), "test-env", { color: false }); + assert.ok(out.includes("resources/test/assistants/foo.md")); + assert.ok(out.includes("resources/test/tools/bar.yml")); +}); + +test("formatGateMessage: includes the rename-sources block with state-only entries", () => { + const out = formatGateMessage(sampleReport(), "test-env", { color: false }); + assert.match(out, /possible rename SOURCES/); + assert.match(out, /assistants\/old-foo/); + // Short UUID display (first 8 chars). + assert.match(out, /64df6206/); +}); + +test("formatGateMessage: includes the AI-agent paragraph and the --allow-new-files hint", () => { + const out = formatGateMessage(sampleReport(), "test-env", { color: false }); + assert.match(out, /FOR AI AGENTS/); + assert.match(out, /do NOT auto-pass --allow-new-files/); + assert.match(out, /--allow-new-files/); +}); + +test("formatGateMessage: scopedToPaths=true surfaces the scope note", () => { + const report = { ...sampleReport(), scopedToPaths: true }; + const out = formatGateMessage(report, "test-env", { color: false }); + assert.match(out, /scoped to the selective-push file set/); +}); + +test("formatGateMessage: empty rename-sources block prints '(none — …)' message", () => { + const report = { ...sampleReport(), possibleRenameSources: [] }; + const out = formatGateMessage(report, "test-env", { color: false }); + assert.match(out, /no plausible rename pairings found/); +}); + +test("formatGateMessage: environment name appears in the headline", () => { + const out = formatGateMessage(sampleReport(), "prod-cluster", { + color: false, + }); + assert.match(out, /env "prod-cluster"/); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Integration — fixture tree + spawnSync push +// ───────────────────────────────────────────────────────────────────────────── + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, ".."); + +interface Fixture { + dir: string; + env: string; + cleanup: () => void; +} + +interface FixtureInit { + env?: string; + // Map of `/.` → file contents. + resources?: Record; + // Pre-populated state file. Omit to leave the engine in "no state file" mode + // (which the bootstrap path normally handles — used here for the + // `--bootstrap` integration test). + state?: Record | null; +} + +function setupFixture(init: FixtureInit = {}): Fixture { + const env = init.env ?? "test-new-file-gate"; + const dir = mkdtempSync(join(tmpdir(), "vapi-new-file-gate-")); + cpSync(join(REPO_ROOT, "src"), join(dir, "src"), { recursive: true }); + cpSync(join(REPO_ROOT, "package.json"), join(dir, "package.json")); + symlinkSync( + join(REPO_ROOT, "node_modules"), + join(dir, "node_modules"), + "dir", + ); + + const resourceRoot = join(dir, "resources", env); + mkdirSync(resourceRoot, { recursive: true }); + + for (const [relPath, contents] of Object.entries(init.resources ?? {})) { + const fullPath = join(resourceRoot, relPath); + mkdirSync(dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, contents); + } + + // Default state: every section empty BUT credentials populated so + // `maybeBootstrapState` doesn't trigger an off-network bootstrap pull + // (which would fail with a fake token). The gate runs after that step. + const defaultState = { + credentials: { fake: { uuid: "11111111-1111-1111-1111-111111111111" } }, + assistants: {}, + structuredOutputs: {}, + tools: {}, + squads: {}, + personalities: {}, + scenarios: {}, + simulations: {}, + simulationSuites: {}, + evals: {}, + }; + const stateToWrite = + init.state === null ? null : (init.state ?? defaultState); + if (stateToWrite !== null) { + writeFileSync( + join(dir, `.vapi-state.${env}.json`), + JSON.stringify(stateToWrite, null, 2), + ); + } + + writeFileSync(join(dir, `.env.${env}`), "VAPI_TOKEN=fake-token-not-used\n"); + + return { + dir, + env, + cleanup: () => rmSync(dir, { recursive: true, force: true }), + }; +} + +function runPush( + fx: Fixture, + extraArgs: string[], +): { code: number | null; stdout: string; stderr: string } { + const result = spawnSync( + "node", + ["--import", "tsx", "src/push.ts", fx.env, ...extraArgs], + { + cwd: fx.dir, + env: { ...process.env, VAPI_TOKEN: "fake-token-not-used" }, + encoding: "utf-8", + timeout: 30_000, + }, + ); + return { + code: result.status, + stdout: result.stdout || "", + stderr: result.stderr || "", + }; +} + +const MINIMAL_ASSISTANT_FOO = `--- +name: foo +model: + provider: openai + model: gpt-4o +voice: + provider: 11labs + voiceId: burt +--- + +You are foo. +`; + +const MINIMAL_TOOL_BAR = `type: endCall +async: false +function: + name: bar + description: stop the call +`; + +// Sentinel state used by integration fixtures: every targeted resource type +// has at least one entry so `maybeBootstrapState` does NOT trigger a real +// bootstrap pull (which would burn an API call to a fake token and fail +// with a 401 before the gate gets to run). The sentinel entry's slug is +// intentionally NOT present on disk so we can also exercise the rename- +// source pairing in the "no flag" case below. +function stateWithSentinel() { + return { + credentials: { fake: { uuid: "11111111-1111-1111-1111-111111111111" } }, + assistants: { + "sentinel-assistant": { + uuid: "33333333-3333-3333-3333-333333333333", + }, + }, + structuredOutputs: {}, + tools: {}, + squads: {}, + personalities: {}, + scenarios: {}, + simulations: {}, + simulationSuites: {}, + evals: {}, + }; +} + +test("integration: orphan + no flag + --dry-run → exit 1, gate fires, no API call attempted", () => { + const fx = setupFixture({ + resources: { + "assistants/foo.md": MINIMAL_ASSISTANT_FOO, + }, + state: stateWithSentinel(), + }); + try { + const res = runPush(fx, ["--dry-run"]); + assert.equal( + res.code, + 1, + `expected exit 1; stdout=${res.stdout}\nstderr=${res.stderr}`, + ); + assert.match(res.stderr, /Push refused/); + assert.match(res.stderr, /resources\/[^/]+\/assistants\/foo\.md/); + assert.match(res.stderr, /FOR AI AGENTS/); + // The gate must fire BEFORE the apply loops — no "would PATCH/POST" lines + // should appear for the orphan. + assert.doesNotMatch(res.stdout, /would (PATCH|POST)/); + } finally { + fx.cleanup(); + } +}); + +test("integration: orphan + --allow-new-files + --dry-run → gate bypassed with single-line notice", () => { + const fx = setupFixture({ + resources: { + "assistants/foo.md": MINIMAL_ASSISTANT_FOO, + }, + state: stateWithSentinel(), + }); + try { + const res = runPush(fx, ["--dry-run", "--allow-new-files"]); + // The bypass notice must show, and exit code must NOT be 1 (the orphan + // gate is the only thing that would exit 1 in this minimal fixture). + assert.match( + res.stdout, + /bypassing new-file gate/, + `expected bypass notice; stdout=${res.stdout}`, + ); + // The gate's error message must NOT appear when bypassed. + assert.doesNotMatch(res.stderr, /Push refused/); + } finally { + fx.cleanup(); + } +}); + +test("integration: orphan + --bootstrap → gate suppressed (bootstrap legitimately creates from scratch)", () => { + const fx = setupFixture({ + resources: { + "assistants/foo.md": MINIMAL_ASSISTANT_FOO, + }, + state: null, + }); + try { + const res = runPush(fx, ["--dry-run", "--bootstrap"]); + // The gate must not fire under --bootstrap. + assert.doesNotMatch(res.stderr, /Push refused/); + assert.doesNotMatch(res.stdout, /bypassing new-file gate/); + } finally { + fx.cleanup(); + } +}); + +test("integration: selective push with orphan OUTSIDE the selection → gate does NOT fire", () => { + // Pre-populate state with the assistant we'll push so the gate doesn't + // count it as an orphan, then leave an unrelated orphan tool on disk that + // is NOT in the selective-push paths. + const fx = setupFixture({ + resources: { + "assistants/foo.md": MINIMAL_ASSISTANT_FOO, + "tools/bar.yml": MINIMAL_TOOL_BAR, + }, + state: { + credentials: { fake: { uuid: "11111111-1111-1111-1111-111111111111" } }, + assistants: { foo: { uuid: "22222222-2222-2222-2222-222222222222" } }, + structuredOutputs: {}, + tools: {}, + squads: {}, + personalities: {}, + scenarios: {}, + simulations: {}, + simulationSuites: {}, + evals: {}, + }, + }); + try { + const res = runPush(fx, [ + "--dry-run", + "--", + "resources/test-new-file-gate/assistants/foo.md", + ]); + // The orphan is `tools/bar` but the user only pushed `assistants/foo` — + // the gate must scope to the file set and not block. + assert.doesNotMatch( + res.stderr, + /Push refused/, + `gate should not fire for orphan outside selection; stderr=${res.stderr}`, + ); + } finally { + fx.cleanup(); + } +});