Skip to content

Commit 3075218

Browse files
authored
feat(push): orphan-YAML gate — halt push when local files lack state entries (#30)
* feat(push): orphan-YAML gate — halt push when local files lack state entries Catches the duplicate-creation patterns surfaced during the gitops-mudflap working session 2026-05-13: - Flow F: user renames a file locally (`mv foo.md bar.md`) then pushes. bar.md has no state entry → engine silently POSTed as a new resource, leaving the original foo's UUID orphaned on the dashboard. - Flow G: dashboard rename → pull writes a new <name>-<uuid8>.md file but leaves the old YAML on disk. Next push iterates the old YAML, POSTs it, creates a duplicate. - Flow M: `npm run apply` (pull → merge → push) compresses Flow G into one click, silently spawning duplicates. The gate is default-on. When it fires, push exits 1 with a verbose error that lists every orphan, pairs orphans with possible state-only rename sources by shared base-slug, and includes an AI-agent-addressed paragraph explicitly asking agents NOT to auto-pass --allow-new-files without confirming with the human. ANSI color when stderr is a TTY; plain text when piped/captured (CI, AI agent stdout capture). Override: --allow-new-files flag bypasses the gate with a one-line notice. Apply propagates the flag through `pushArgs`. Bootstrap mode (empty state) suppresses the gate — genuine first-time pushes are all "new". Selective push (`-- <paths>`) scopes the gate to the selection. Unrelated orphans elsewhere don't block. Reuses audit.ts's `checkOrphanYaml` detection via a shared `findOrphanResourceIds` helper exported from new-file-gate.ts — single source of truth for "what counts as an orphan YAML". Suite: 188/188 tests pass (21 new for this gate). * fix(new-file-gate): respect .vapi-ignore + use real env in paths + dry-run verb Addresses three code-review findings on the new-file-gate PR: 1. **M1 — .vapi-ignore now suppresses the gate** for files the customer has explicitly opted out of gitops tracking. Without this, the gate would halt every push for stale dashboard artifacts that customers keep around (silenced via .vapi-ignore) — defeating both the .vapi-ignore workaround and the gate's intent of catching REAL rename / orphan cases. Same `matchesIgnore` helper that audit.ts uses; injected via the gate's DI options for filesystem-free tests. 2. **M2 — `<org>` placeholder in relativePath replaced with real `VAPI_ENV`**. Previously the gate's message rendered `resources/<org>/...` literally. Now renders e.g. `resources/mudflap-prod/...`. Tests updated to use `[^/]+` regex class so they work regardless of the test env name. 3. **M2 — asymmetric `filter.endsWith(relativePath)` direction removed** from `pathMatchesAnyFilter`. The remaining checks (exact match, suffix match, resourceId-with-extension tail match) cover the real cases without over-matching when relativePath is short. Cleanups: - Dead UUID-length conditional in formatGateMessage (real UUIDs are always 36 chars, slice always fires) → unconditional slice. - Duplicate `(possible rename SOURCES):` header in both branches of the rename-sources block → print header once, then either '(none)' or entries. - Dry-run-aware verb in the bypass notice: "would create" vs "creating" based on DRY_RUN flag. New test: pin .vapi-ignore suppression as a regression guard against re-introducing the M1 bug. 189/189 tests pass.
1 parent 7e051fa commit 3075218

6 files changed

Lines changed: 1011 additions & 20 deletions

File tree

src/apply.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,28 @@ export async function runApply(): Promise<void> {
3333
const pushArgs = allArgs.join(" ");
3434

3535
if (!env || !SLUG_RE.test(env)) {
36-
console.error("Usage: npm run apply <org> [--force]");
36+
console.error("Usage: npm run apply <org> [--force] [--allow-new-files]");
3737
console.error("");
3838
console.error(" Pull → Merge → Push (safe bidirectional sync)");
3939
console.error("");
4040
console.error(" Pulls latest platform state (preserving local changes),");
4141
console.error(" then pushes the result back to the platform.");
4242
console.error("");
4343
console.error(
44-
" --force Enable deletions: resources you deleted locally",
44+
" --force Enable deletions: resources you deleted locally",
45+
);
46+
console.error(
47+
" will also be deleted from the platform.",
48+
);
49+
console.error(
50+
" --allow-new-files Bypass the orphan-YAML pre-flight gate (push stage).",
51+
);
52+
console.error(
53+
" Use only after confirming every local file without a",
54+
);
55+
console.error(
56+
" state entry is genuinely new — see src/new-file-gate.ts.",
4557
);
46-
console.error(" will also be deleted from the platform.");
4758
process.exit(1);
4859
}
4960

src/audit.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import { readFile } from "fs/promises";
2222
import { join } from "path";
2323
import { matchesIgnore, RESOURCES_DIR } from "./config.ts";
24+
import { findOrphanResourceIds } from "./new-file-gate.ts";
2425
import {
2526
extractBaseSlug,
2627
fetchAllResources,
@@ -179,21 +180,20 @@ function checkOrphanYaml(
179180
localIds: string[],
180181
state: StateFile,
181182
): AuditFinding[] {
182-
const stateKeys = new Set(Object.keys(state[type]));
183-
const findings: AuditFinding[] = [];
184-
for (const localId of localIds) {
185-
if (stateKeys.has(localId)) continue;
186-
findings.push({
187-
severity: "warn",
188-
type,
189-
rule: "orphan-yaml",
190-
resourceIds: [localId],
191-
message: `local file ${type}/${localId} has no state entry`,
192-
suggestedAction:
193-
"delete file OR run `npm run pull` to re-key it into state",
194-
});
195-
}
196-
return findings;
183+
// Single source of truth for "what counts as an orphan YAML" — shared with
184+
// the push-time pre-flight gate in src/new-file-gate.ts. Both surfaces map
185+
// the same predicate to different output shapes (here: AuditFinding[];
186+
// there: OrphanFile[] + verbose halt message).
187+
const orphanIds = findOrphanResourceIds(type, localIds, state);
188+
return orphanIds.map((localId) => ({
189+
severity: "warn",
190+
type,
191+
rule: "orphan-yaml",
192+
resourceIds: [localId],
193+
message: `local file ${type}/${localId} has no state entry`,
194+
suggestedAction:
195+
"delete file OR run `npm run pull` to re-key it into state",
196+
}));
197197
}
198198

199199
function checkStateGhosts(

src/config.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ function parseEnvironment(): Environment {
4444
console.error(
4545
" --type <type> (apply only specific resource type, repeatable)",
4646
);
47+
console.error(
48+
" --allow-new-files (bypass orphan-YAML pre-flight gate)",
49+
);
4750
console.error(" -- <file...> (apply only specific files)");
4851
process.exit(1);
4952
}
@@ -88,6 +91,7 @@ function parseFlags(): {
8891
dryRun: boolean;
8992
strictValidation: boolean;
9093
overwriteDrift: boolean;
94+
allowNewFiles: boolean;
9195
applyFilter: ApplyFilter;
9296
} {
9397
const args = process.argv.slice(3);
@@ -97,13 +101,15 @@ function parseFlags(): {
97101
dryRun: boolean;
98102
strictValidation: boolean;
99103
overwriteDrift: boolean;
104+
allowNewFiles: boolean;
100105
applyFilter: ApplyFilter;
101106
} = {
102107
forceDelete: args.includes("--force"),
103108
bootstrapSync: args.includes("--bootstrap"),
104109
dryRun: args.includes("--dry-run"),
105110
strictValidation: args.includes("--strict"),
106111
overwriteDrift: args.includes("--overwrite"),
112+
allowNewFiles: args.includes("--allow-new-files"),
107113
applyFilter: {},
108114
};
109115

@@ -119,7 +125,8 @@ function parseFlags(): {
119125
arg === "--bootstrap" ||
120126
arg === "--dry-run" ||
121127
arg === "--strict" ||
122-
arg === "--overwrite"
128+
arg === "--overwrite" ||
129+
arg === "--allow-new-files"
123130
)
124131
continue;
125132

@@ -184,7 +191,7 @@ function parseFlags(): {
184191
console.error(
185192
` Expected a resource type (e.g. assistants, tools), a folder path ` +
186193
`(e.g. assistants/foo.yml or resources/<org>/assistants/foo.yml), or ` +
187-
`a flag (--force, --bootstrap, --type, --id).`,
194+
`a flag (--force, --bootstrap, --type, --id, --allow-new-files).`,
188195
);
189196
process.exit(1);
190197
}
@@ -257,6 +264,7 @@ export const {
257264
dryRun: DRY_RUN,
258265
strictValidation: STRICT_VALIDATION,
259266
overwriteDrift: OVERWRITE_DRIFT,
267+
allowNewFiles: ALLOW_NEW_FILES,
260268
applyFilter: APPLY_FILTER,
261269
} = parseFlags();
262270

0 commit comments

Comments
 (0)