Skip to content

Commit 0a5f6fb

Browse files
committed
fix(pull): preserve lastPulledHash on classifier short-circuits (hotfix for #38)
The drift-direction-classifier PR (#38) introduced a regression: classifier short-circuit branches in `pullResourceType` silently dropped `lastPulledHash` from state, breaking drift detection for any resource that ever returned a non-clean direction. ## Root cause `pull.ts:769` initializes `newStateSection` as an empty object for a full pull: const newStateSection: Record<string, ResourceState> = resourceIds?.length ? { ...state[resourceType] } : {}; // ← empty for full pulls The three classifier short-circuit branches then did one of: - `both-diverged` (`:818`) \u2014 NO upsertState call at all. Entry vanishes from newStateSection. At end-of-loop, `state[type] = newStateSection` removes it from state entirely. - `dashboard-ahead` (`:870`) \u2014 `upsertState(newStateSection, id, { uuid })`. Merge against undefined produces `{ uuid }`-only, dropping lastPulledHash and lastPulledAt. - `local-ahead` (`:882`) \u2014 same shape as dashboard-ahead. Symptom on the next pull: classifier sees no baseline \u2192 `no-baseline` \u2192 content-drift detection silently disabled for that resource until something writes a fresh baseline. The `--resolve=ours` and `--resolve=theirs` paths masked the bug locally (they explicitly write full state entries), but bare plain pulls and `--resolve=fail` did not. Caught by the E2E both-diverged smoke test on mudflap-iform-test: - TEST 1: plain pull on both-diverged \u2192 exit 1 (gate fires) \u2192 state for that resource becomes `{ uuid }` only (lastPulledHash gone) - TEST 2: --resolve=fail \u2192 exit 1 \u2192 same state loss ## Fix Pass the existing baseline through at each short-circuit: - both-diverged: assign the existing state entry verbatim into newStateSection before `continue`. `resolveBothDivergedResources` will overwrite it later if the operator passes --resolve. - dashboard-ahead, local-ahead: include lastPulledHash + lastPulledAt in the upsertState patch so the merge against an empty section produces the full shape. ## Tests 4 new unit tests in tests/drift.test.ts \u00a7 Section J pin: - Full patch survives empty newStateSection (the fix shape) - Bare { uuid }-only patch DROPS lastPulledHash (the regression hazard \u2014 fails if someone reverts the caller) - both-diverged direct assignment preserves all fields verbatim - upsertState merge semantics still hold (union, not replacement) Empirically verified end-to-end against mudflap-iform-test: - Create scenario \u2192 baseline hash X recorded \u2192 manufacture both-diverged \u2192 plain pull \u2192 exit 1 \u2192 state still has lastPulledHash == X \u2705 ## Why this wasn't caught in review Two rounds of code-reviewer subagent passes on PR #38 verified contract correctness of `classifyDrift`'s output (which direction maps to which return value) but did NOT trace what each branch did to `newStateSection`. State mutation per classifier branch should be an explicit review surface; will add to future review briefs. ## Defer to follow-up A full integration test using the pull-same-name-clobber.test.ts pattern (spawn worker thread with HTTP stub, run pull-cmd.ts via spawnSync, assert post-pull state) would lock this contract more rigorously. The unit tests here pin the shape; the smoke + manual repro confirm the integration. 270/270 tests pass, tsc --noEmit clean.
1 parent e4678c0 commit 0a5f6fb

2 files changed

Lines changed: 125 additions & 2 deletions

File tree

src/pull.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,16 @@ export async function pullResourceType(
852852
});
853853
if (driftCounts) driftCounts[direction]++;
854854

855+
// STATE-PRESERVATION INVARIANT: classifier short-circuit branches
856+
// MUST carry `lastPulledHash` (and `lastPulledAt`) forward into
857+
// `newStateSection`. `newStateSection` starts EMPTY for a full pull
858+
// (pull.ts:769); a bare `upsertState(..., { uuid })` merges against
859+
// `undefined`, producing `{ uuid }`-only — dropping the baseline.
860+
// Then `state[type] = newStateSection` at end-of-loop persists the
861+
// loss. Symptom: next pull sees no baseline → `no-baseline`, the
862+
// classifier can never detect drift on this resource again until
863+
// something else writes a fresh baseline. The both-diverged branch
864+
// skipped `upsertState` entirely, dropping the entry outright.
855865
if (direction === "both-diverged") {
856866
bothDiverged?.push({
857867
resourceType,
@@ -861,6 +871,14 @@ export async function pullResourceType(
861871
platformHash,
862872
lastPulledHash,
863873
});
874+
// Preserve the existing entry verbatim. `resolveBothDivergedResources`
875+
// will overwrite it with the resolve-mode-appropriate hash after the
876+
// per-type loop completes; if the operator doesn't pass --resolve, we
877+
// want the baseline still intact so the next pull can see drift.
878+
const existing = state[resourceType][resourceId];
879+
if (existing) {
880+
newStateSection[resourceId] = existing;
881+
}
864882
skipped++;
865883
continue;
866884
}
@@ -869,7 +887,11 @@ export async function pullResourceType(
869887
console.log(
870888
` ✏️ ${resourceId} (locally modified, preserving) ${formatDriftLabel(direction)}`,
871889
);
872-
upsertState(newStateSection, resourceId, { uuid: resource.id });
890+
upsertState(newStateSection, resourceId, {
891+
uuid: resource.id,
892+
lastPulledHash,
893+
lastPulledAt: stateEntry?.lastPulledAt,
894+
});
873895
skipped++;
874896
continue;
875897
}
@@ -881,7 +903,11 @@ export async function pullResourceType(
881903
console.log(
882904
` ⬆️ ${resourceId} (local ahead of dashboard) ${formatDriftLabel(direction)}`,
883905
);
884-
upsertState(newStateSection, resourceId, { uuid: resource.id });
906+
upsertState(newStateSection, resourceId, {
907+
uuid: resource.id,
908+
lastPulledHash,
909+
lastPulledAt: stateEntry?.lastPulledAt,
910+
});
885911
skipped++;
886912
continue;
887913
}

tests/drift.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import test from "node:test";
33
import {
44
checkPronunciationDictDrop,
55
hashPayload,
6+
upsertState,
67
} from "../src/state-serialize.ts";
8+
import type { ResourceState } from "../src/types.ts";
79

810
// Stack G — drift unit tests.
911
// `checkDriftForUpdate` itself fires GET against the Vapi platform; a unit
@@ -659,3 +661,98 @@ test("canonicalizeForHash: strips server-managed fields (id, orgId, createdAt, u
659661
assert.equal(result.updatedAt, undefined);
660662
assert.equal(result.name, "Foo");
661663
});
664+
665+
// ─────────────────────────────────────────────────────────────────────────────
666+
// Section J (added 2026-05-20): classifier short-circuit state preservation.
667+
//
668+
// Regression coverage for a bug introduced by the drift-direction-classifier
669+
// PR (#38) and caught by the E2E both-diverged smoke test on mudflap-iform-test:
670+
//
671+
// pull.ts `newStateSection` starts EMPTY for a full pull (line 769). The
672+
// classifier short-circuit branches (`dashboard-ahead`, `local-ahead`,
673+
// `both-diverged`) previously called `upsertState(newStateSection, id, { uuid })`
674+
// against this empty section — dropping `lastPulledHash` and `lastPulledAt`
675+
// from state. The `both-diverged` branch was worse: it called no upsert at all,
676+
// so the entry vanished entirely.
677+
//
678+
// After the per-type loop, `state[type] = newStateSection` (line 1040)
679+
// persists the loss. The operator's NEXT pull sees no baseline → `no-baseline`
680+
// classification → the classifier can never detect drift on this resource
681+
// again until something writes a fresh baseline.
682+
//
683+
// These tests pin the upsertState patch SHAPE that the fix uses, plus the
684+
// bare-{ uuid }-only failure mode so a future contributor can't silently
685+
// revert without breaking a test.
686+
// ─────────────────────────────────────────────────────────────────────────────
687+
688+
test("classifier short-circuit: full patch (uuid + lastPulledHash + lastPulledAt) survives empty newStateSection", () => {
689+
const newStateSection: Record<string, ResourceState> = {};
690+
upsertState(newStateSection, "r1", {
691+
uuid: "u1",
692+
lastPulledHash: "h-baseline",
693+
lastPulledAt: "2026-01-01T00:00:00.000Z",
694+
});
695+
assert.equal(newStateSection.r1?.uuid, "u1");
696+
assert.equal(
697+
newStateSection.r1?.lastPulledHash,
698+
"h-baseline",
699+
"dashboard-ahead / local-ahead branches MUST pass lastPulledHash through; otherwise next pull sees no-baseline",
700+
);
701+
assert.equal(newStateSection.r1?.lastPulledAt, "2026-01-01T00:00:00.000Z");
702+
});
703+
704+
test("classifier short-circuit: bare { uuid }-only patch DROPS lastPulledHash on empty section (regression hazard)", () => {
705+
// Pins the failure mode — if a future contributor reverts to passing only
706+
// { uuid: resource.id } to upsertState (the shape the original PR shipped
707+
// with), this test catches it. The fix is in the CALLER (pull.ts classifier
708+
// branches); upsertState's merge semantics are correct as-is.
709+
const newStateSection: Record<string, ResourceState> = {};
710+
upsertState(newStateSection, "r1", { uuid: "u1" });
711+
assert.equal(newStateSection.r1?.uuid, "u1");
712+
assert.equal(
713+
newStateSection.r1?.lastPulledHash,
714+
undefined,
715+
"bare patch must NOT magically materialize lastPulledHash — fix lives in the caller's patch",
716+
);
717+
});
718+
719+
test("classifier both-diverged: direct assignment from existing state preserves all fields verbatim", () => {
720+
// both-diverged path does NOT call upsertState (resolveBothDivergedResources
721+
// takes over post-loop, with per-resolve-mode state mutation). Without the
722+
// preservation assignment in the branch, the entry vanishes from
723+
// newStateSection. With it, the operator can re-run pull with --resolve and
724+
// still have the baseline to compare against.
725+
const existingState: Record<string, ResourceState> = {
726+
r1: {
727+
uuid: "u1",
728+
lastPulledHash: "h-baseline",
729+
lastPulledAt: "2026-01-01T00:00:00.000Z",
730+
},
731+
};
732+
const newStateSection: Record<string, ResourceState> = {};
733+
const existing = existingState.r1;
734+
if (existing) {
735+
newStateSection.r1 = existing;
736+
}
737+
assert.deepEqual(
738+
newStateSection.r1,
739+
existing,
740+
"both-diverged branch MUST preserve the existing entry verbatim; saveState writes newStateSection over state[type] at end of loop",
741+
);
742+
});
743+
744+
test("upsertState merge: pre-existing entry + new patch produces union, NOT replacement", () => {
745+
// Sanity-check that upsertState's documented merge semantic still holds.
746+
// If a future refactor switches to replacement semantics, the classifier
747+
// short-circuits would lose lastPulledHash on subsequent calls.
748+
const section: Record<string, ResourceState> = {
749+
r1: {
750+
uuid: "u1",
751+
lastPulledHash: "h-old",
752+
lastPulledAt: "2026-01-01T00:00:00.000Z",
753+
},
754+
};
755+
upsertState(section, "r1", { uuid: "u1", lastPulledAt: "2026-02-01T00:00:00.000Z" });
756+
assert.equal(section.r1?.lastPulledHash, "h-old", "upsertState must preserve fields not in the patch");
757+
assert.equal(section.r1?.lastPulledAt, "2026-02-01T00:00:00.000Z", "upsertState must overwrite fields in the patch");
758+
});

0 commit comments

Comments
 (0)