feat: [edit] A parsed entry cannot be deleted from the reconstructed résumé — only user-ADDED ones (achievement, role, education, project) (#856) - #858
Conversation
s-annam
left a comment
There was a problem hiding this comment.
Verdict: APPROVE
(Posted as a review comment because GitHub restricts PR authors from self-submitting formal approval reviews)
Reviewed against issue #856. All 10 acceptance criteria are fully met with comprehensive test coverage across 4 dedicated test suites. All offlinecv verification gates (check:fixtures, lint, typecheck, dead-code audit, and full test suite) are green.
Acceptance Criteria Checklist (#856)
- Parsed achievement deletion: Verified across re-renders (
ReconstructedResume.remove-parsed-entry.test.tsx,useEditableParse.removed-entry.test.tsx). - Experience, education, project deletion: All 4 sections support parsed entry removal via tombstoning.
- Bullet & raw text removal:
removeEntryWithBulletsdrops associated bullets from the graded pool (sections) andrawText. - Download PDF & Round-trip invariant: Verified via
render-roundtrip-entry-remove.repro.test.ts. - Index integrity: Overriding an entry and deleting an earlier entry preserves override alignment via
survivingParsedIndices. - Edit state & snapshots:
hasEditstracksremovedEntries, captured inEditSnapshot, restored onreplay. - Backwards compatibility: Legacy snapshots without
removedEntriesreplay cleanly without throwing (snap.removedEntries ?? []). - Reset support:
resetclearsremovedEntries. - No regressions: Added-entry removal, bullet removal, and prune-on-blur remain green.
- Full verify suite: 363 test files / 5901 tests passed.
Gates & Quality Audit
- Fixture PII (Gate 3a): Passed (
check:fixturesgreen, 0 real personas). - Design System & Reuse (Gate 3b): Passed (
RemoveButtonand design system primitives reused consistently; no raw interactive elements in feature code). - Style Tokens (Gate 3c): Passed (0 hardcoded hex or raw Tailwind palette violations).
- Dead Code & Fallow (Gate 3d): Passed (0 dead exports, verify suite green).
- Description Accuracy (Gate 3f): Accurate. All 3 deliberate design deviations from the issue's initial plan (
removedEntriesRefomission, UIsurvivingParsedIndicesindex-remap, and reusingisTitleOwnedLinefromgroup-bullets.ts) are well-justified, accurately described, and tested.
Reviewed by: Gemini 3.7 Flash (high)
s-annam
left a comment
There was a problem hiding this comment.
⚠️ This is a SELF-REVIEW and carries no approval weight
This PR is authored by s-annam, which is also the account this review is posted from. GitHub will not accept an approving review from a PR's own author, and posting the same verdict as a COMMENT would not make it one either. This PR still needs a second-party reviewer before it merges. Read everything below as the author's own audit trail, not as a sign-off.
The change does what #856 specified, the tombstone ordering is right, and the deviations from the issue's plan are improvements on it rather than shortcuts. Zero blocking findings. Everything below is Secondary or Nit.
Acceptance criteria (#856) — 10 / 10 met
| AC | Status | Evidence |
|---|---|---|
| Parsed achievement deletable, survives re-render | ✅ | useEditableParse.removed-entry.test.tsx:231,254 |
| Same for role / education / project | ✅ | role :275; education + achievement render-roundtrip-entry-remove.repro.test.ts:137,143; project apply-overrides.removed-entry.test.ts:145,183 |
Bullets leave the score + rawText |
✅ | :275, apply-overrides.removed-entry.test.ts:232 |
| Absent from Download PDF, re-parses without it | ✅ | render-roundtrip-entry-remove.repro.test.ts:128–151 |
| Index integrity — survivors keep their own overrides | ✅ | apply-overrides.removed-entry.test.ts:105–167, all four sections + a two-in-one-fold case |
hasEdits + EditSnapshot + replay |
✅ | :327 |
| Pre-#856 snapshot replays without error | ✅ | :347 |
reset clears removedEntries |
✅ | :333–334 |
| No regression to added-entry removal | ✅ | :379 |
npm run verify green |
✅ | reproduced below |
Gates
| Gate | Result |
|---|---|
tsc -b --noEmit |
pass |
eslint . |
pass |
check:fixtures |
pass — N/A by construction, the diff touches no tests/fixtures/** binary |
check:baselines |
pass |
check:core |
pass |
Full test suite (OFFLINECV_FULL_TESTS=1) |
363 files passed / 8 skipped · 5901 tests passed / 10 skipped |
vite build |
pass — 1028 modules, 3.28s |
| Style tokens (3c) | clean — every regex hit was the literal #856, not a hex colour |
| Skill/script command bugs (3e) | N/A — no scripts/** or .claude/skills/** file in the diff |
fallow audit |
exit 1 — 3 complexity findings, 35 clone groups. Report-only, and all three complexity findings (RoleHeader, applyExperienceHeaderOverrides, groupBulletsByExperience) are on functions this diff does not touch; fallow re-attributes them because the diff touched their files |
The reported numbers match the PR body's Validation section exactly (363/5901), so that claim round-trips.
Description accuracy (3f) — accurate, and unusually so
Every checkable claim in the body was round-tripped against the diff and holds:
- Deviation 1 (no
removedEntriesRef) — confirmed;useEditableParse.ts:921documents the absent reader, andremoveEntrydoes returnvoid. - Deviation 2 (UI index remap) — confirmed and correctly characterised as load-bearing.
survivingParsedIndicesis exported, and all four sections resolve through it; the issue's Step 4 as literally written would have re-deleted the wrong entry after the first deletion. - Deviation 3 (reuse the scorer's ownership relation) — confirmed;
matchesOwnedKeyis the single extracted predicate and bothsuppressTitleOwnedBulletsandisTitleOwnedLineroute through it. - The forced drive-by — verified independently, not taken on trust.
scripts/hooks/check_conventions.py:103-106scanssrc/components/**afterstrip_line_comments, which strips//but not/** */, so the pre-existingexactlyatReconstructedRole.tsx:262genuinely fired on every edit to that file. Comment-text-only, and it rides a PR already in range rather than earning its own. parseRemovedEntryKeydigit matching — confirmed, and pinned by the"projects:"case atapply-overrides.removed-entry.test.ts:197. TheNumber("") === 0trap was real.- Files-changed list — matches
git diff --name-onlyexactly, 16 for 16. - "Adversarial review: not yet run" — correct to state plainly rather than let a reader assume it happened.
One thing the body flags as a landmine that is worth restating, because it is the sharpest edge here: inside ExperienceSection the two experience:<n> strings are in different index spaces (rewrite-chain section id = render position; entryKey = parsed index). They coincide until the first parsed-role deletion. The code comments it in place, which is the right treatment.
Secondary
Three, all inline. In short: EntryRemove.ts is domain logic sitting under src/components/; ReconstructedResume.tsx grew 1356 → 1489 on a file CLAUDE.md names as known debt; and a one-click destructive delete now bypasses the per-bullet confirmation strip that guards removing a single bullet.
Nits
- Duplicate-title first-match in
applyRemovedEntries(inline). Near-zero real impact — noting it so the next reader does not have to re-derive why it is safe. fallowattributes 3 clone groups toExperienceSection.test.tsx/.other-bullets/.prune-hold, which this PR touched by +3 lines each. Pre-existing duplication, re-attributed by proximity. Report-only; not worth a change.
Nothing was auto-fixed, deliberately
With 0 blockers the reviewer loop would normally apply small fixes and push. It did not here, because none of the findings qualify: all three Secondary items are structural judgement calls (where a file lives, how a component is decomposed, whether a destructive action confirms) rather than non-behavioural mechanical nits, and the Step 5.6 suggestion-block form does not fit any of them either. The branch is untouched and still exactly one commit (bfa94b5), so the one-commit merge-queue invariant holds as-is.
Head SHA reviewed: bfa94b5a465d56e1536b3cb797c052ff61b47cd5
Reviewed by: Claude Opus 5 (high)
| /> | ||
| )} | ||
| <RemoveButton | ||
| label="Remove achievement" |
There was a problem hiding this comment.
Secondary — a one-click destructive delete that skips the confirmation its own bullets have.
RemoveButton (ReconstructedAdd.tsx:132) is a bare <Button>; it does not confirm. And removeEntryWithBullets calls onRemoveBullet directly, which bypasses the per-bullet confirmation strip (removes.pending, #626) that guards removing a single bullet in the Experience path.
So the current gradient is inverted: removing one bullet asks first; removing an entry silently destroys the entry and all N of its bullets on one click. Before this PR that only applied to user-ADDED entries — trivially re-creatable, so no confirm was needed. A parsed entry is different: it carries parser output, and the only recovery is resetAll (which discards every edit in the session) or re-uploading the PDF. #856 scoped out an undo toast; it did not scope out a confirm, and the asymmetry with #626 is the part that stands out.
Not blocking — the behaviour is what the issue asked for and the tests pin it. Worth a follow-up issue rather than a change in this PR, given the round-trip and index work here is already large and well-tested. Same applies to label="Remove project" (:814), "Remove role" (ReconstructedRole.tsx:440) and "Remove education" (ReconstructedEducationSkills.tsx:166).
There was a problem hiding this comment.
Deferred to #860 — #860. No code change in this PR, per your own recommendation.
The issue carries the full argument: the inverted gradient vs #626, why "added entries were trivially re-creatable" stops holding once parsed entries are deletable, that resetAll / re-upload is the only recovery, and that #856 scoped out an undo toast but never a confirm. It lists all four call sites you named — Remove achievement (ReconstructedResume.tsx:1058), Remove project (:814), Remove role (ReconstructedRole.tsx:440), Remove education (ReconstructedEducationSkills.tsx:166).
Its Reuse analysis lands on extending the #626 inline-confirm strip rather than reaching for Dialog — a modal for a row-level delete diverges from the very bullet path this is meant to align with.
Leaving this thread open for you to close.
| // section resolves through the map rather than re-deriving one, so the four | ||
| // cannot drift; see `survivingParsedIndices` for why it is enumerated rather | ||
| // than computed by subtraction. | ||
| const expParsedIndices = survivingParsedIndices( |
There was a problem hiding this comment.
Secondary — this file grew on the one file CLAUDE.md tells us not to grow.
ReconstructedResume.tsx: 1356 → 1489 lines (+133). From CLAUDE.md:
⚠️ Known debt — do not imitate:ReconstructedResume.tsx(1338) … If you are editing one, prefer extracting your change into a new sibling over growing the file further.
The PR did extract a sibling — EntryRemove.ts — which is the right instinct; it just is not where the mass is. fallow now reports three functions in this one file over the ~200 LOC guidance: ReconstructedResume (370), ExperienceSection (320), ProjectsSection (143), with AchievementsSection alongside them.
The honest framing: this PR did not create that debt and the four sections were already co-located, so splitting them out here would balloon a diff that is already 1846 insertions and would make the index-remap change — the part that actually needs careful review — harder to see. Recommend not doing it in this PR, and instead filing the section split as its own issue. Flagging it because the file is now 151 lines past the number CLAUDE.md quotes, so that quote is stale and the next person to read it will under-estimate the problem.
There was a problem hiding this comment.
Split deferred to #861 — #861 — taking your recommendation not to do it here.
The one part I did act on is the stale quote, since that was the concrete harm you named: CLAUDE.md’s known-debt line now reads ReconstructedResume.tsx (1489) and ReconstructedRole.tsx (490), replacing 1338 / 483 (5ec090c). The next reader gets the real number instead of one 151 lines light.
#861 carries the fallow breakdown (ReconstructedResume 370, ExperienceSection 320, ProjectsSection 143), asks for each section as a pure move in its own commit, and pins no behaviour change as an acceptance criterion — a test that needs editing means the move was not pure. It also requires the CLAUDE.md figures be refreshed again post-split, so this does not go stale a second time.
Leaving this thread open for you to close.
Every section gated its remove control on the entry being user-ADDED, so a phantom achievement, a duplicated role, or an education entry stitched out of two could be edited but never removed — and shipped into the Download PDF. The gap was one level down: `removeEntry` only filtered the added-entry list, and no removed-parsed-entry state existed at all. Adds a `removedEntries` tombstone set, the entry-level analogue of the existing `removedBullets`. It is a tombstone and not a splice because every per-entry override map is keyed by PARSED ARRAY INDEX: filtering an entry out before those passes run shifts every later index and silently rebinds each later entry's edits to the wrong entry. `applyRemovedEntries` therefore runs last, after every index-keyed pass and after the added-entry append, so each key still resolves against the array it was captured on. An entry's bullets are dropped through the existing `removeBullet` rather than by teaching the new pass a second kind of text surgery — a `•` line the entry does not own is not findable from its fields at all, only from the rendered group. So the bullets leave `rawText`, `sections` (hence the graded pool) and the role descriptions through code that was already tested. Order is fixed, bullets first: `removeEntry` deletes the added-bullet bucket outright, so reversing the two would leave every added row with nothing to splice. - add `removedEntries` state, snapshot and replay in useEditableParse.ts, with a backward-compatible default so a draft written before this change replays - add `applyRemovedEntries` to apply-overrides.ts and wire it through useAnalyzedResume.ts - add `removeEntryWithBullets` in lib/edit/entry-remove.ts as the one definition of "delete this entry", and route all four sections through it - drop the `added &&` gate in achievements, projects, experience and education Covered by an explicit index-rebinding regression test, a snapshot/replay round-trip including a pre-change draft, and a render round-trip asserting a removed entry is absent from the exported PDF and stays absent on re-parse. Closes #856
5ec090c to
3f08d0d
Compare
Implements #856: parsed entries (achievement, role, education, project) can now be deleted from the reconstructed résumé, not only user-added ones.
Design is the
removedEntriestombstone set the issue specified — mark, run every index-keyed override pass, then filter once at the end ofapplyOverrides, so no override rebinds to a neighbour.Deviations from the issue plan
Three deliberate departures from the issue's implementation plan, all additive to its intent:
NO
removedEntriesRef(plan Step 1). TheremovedBulletsRefit mirrors exists solely soremoveBulletcan read the committed set to decide its boolean return;removeEntryreturns void, so a ref besideremovedEntrieswould have had no reader. Omitted rather than shipped dead.ADDED a UI index-remap the plan does not mention, and it is load-bearing. The plan's Step 4 resolves the delete key as
parsedEntryKey(<section>, i)whereiis the render index. Because Step 3 filters tombstoned entries out of the parsed arrays,istops being the parsed index after the first deletion — so that line would (a) re-delete the already-gone entry instead of the clicked one, and (b) leave every neighbouringonAchievementField(i, …)/educationOverrides[i]/descriptionOverrideskey rebound to the wrong entry, which is the exact failure the tombstone design prevents one level down.survivingParsedIndices(exported fromuseEditableParse.ts) is the single map every section resolves through. It enumerates survivors rather than subtracting the set's size, so a stale tombstone naming an index the parse no longer has cannot over-shift.The entry's own source-line removal (plan Step 3) reuses the SCORER's ownership relation rather than a new "normalise-and-match-first-line" matcher.
normalizeTitleKey+stripAchievementLabel(already private ingroup-bullets.ts) are whatsuppressTitleOwnedBulletshides an entry-owned line on;isTitleOwnedLineexposes the inverse, sharing one extractedmatchesOwnedKeyso the two cannot drift. This is not cosmetic: a title-only achievement/project is parsed OUT of a•-marked line, so that line is in the graded pool with no description to attribute it to — deleting the entry without dropping the line leaves its content grading the résumé forever. It is best-effort and documented as such (the parsed model keeps no source-line provenance, so a glued one-line header matches nothing and the line stays in the raw-text view); safe when it misses, since the lines it can miss are the UNMARKED ones the bullet pool ignores.Two smaller notes:
keys in all four sections moved from the render position to the entry key. A position key would hand a deleted row's in-flight edit state to its successor — newly reachable now that parsed rows can disappear.ReconstructedRole.tsx:262carried a pre-existing "exactly" inside a/** */block, whichscripts/hooks/check_conventions.pyrule 2 blocks on (block comments are deliberately scanned) — it fired on every edit to that file. Reworded to "just as". Comment text only.Also fixed while testing:
parseRemovedEntryKeymatches the index half as DIGITS rather than coercing withNumber.Number("")is 0 andNumber.isInteger(0)is true, so a truncated"projects:"key would have silently deleted the section's FIRST entry. Pinned by a test.Files changed
src/hooks/useEditableParse.tssrc/hooks/useAnalyzedResume.tssrc/lib/edit/apply-overrides.tssrc/lib/score/group-bullets.tssrc/components/features/EntryRemove.tssrc/components/features/ReconstructedResume.tsxsrc/components/features/ReconstructedRole.tsxsrc/components/features/ReconstructedEducationSkills.tsxsrc/components/features/ExperienceSection.test.tsxsrc/components/features/ExperienceSection.other-bullets.test.tsxsrc/components/features/ExperienceSection.prune-hold.test.tsxsrc/components/features/ReconstructedEducationSkills.test.tssrc/components/features/ReconstructedResume.remove-parsed-entry.test.tsxsrc/hooks/useEditableParse.removed-entry.test.tsxsrc/lib/edit/apply-overrides.removed-entry.test.tssrc/lib/pdf/render-roundtrip-entry-remove.repro.test.tsValidation
npm run typecheck— pass — tsc -b --noEmit — no output, exit 0npm run lint— pass — eslint . — no output, exit 0npm test— pass — Test Files 363 passed | 8 skipped (371); Tests 5901 passed | 10 skipped (5911); Duration 19.77sAdversarial review
Not yet run. The run halted at submit (a stray
fakegit remote madegh stack submitambiguous), so the review pass never executed. Reviewer notes below are fromnpx fallow auditonly.Handoff
New exports the next issue can build on:
useEditableParse.tsEditableParse.removedEntries: ReadonlySet<string>— parsedEntryKey tombstones.EditSnapshot.removedEntries?: string[]— OPTIONAL, so pre-[edit] A parsed entry cannot be deleted from the reconstructed résumé — only user-ADDED ones (achievement, role, education, project) #856 persisted drafts replay;replaydefaults it to[]. Any new consumer must keep it optional.removeEntry(key: string)— widened from "added id" to "added id OR parsedEntryKey". Also deletes that key'saddedBulletsbucket for BOTH kinds.survivingParsedIndices(section, removedEntries, renderedCount): number[]— render position → PARSED index. ANY new surface that renders a parsed section and writes an index-keyed override MUST resolve through this.lib/score/group-bullets.ts:isTitleOwnedLine(line, title)— the entry-owns-this-line relation, shared withsuppressTitleOwnedBullets.components/features/EntryRemove.ts:removeEntryWithBullets(entryKey, bullets, {onRemoveEntry, onRemoveBullet})— the one definition of the delete gesture. Bullets first, then the entry.applyOverridesgained an 18th positional paramremovedEntries: ReadonlySet<string> = new Set(). A 19th param must be appended AFTER it, andapplyRemovedEntriesmust stay the LAST pass (it runs afterapplyAddedEntriesAndBullets) or index-keyed overrides rebind.AchievementsSectionis now exported fromReconstructedResume.tsx(test-only, likeExperienceSection).Two landmines documented in code, worth knowing:
ExperienceSection, theexperience:<n>strings inrewriteApplyBySectionare in TWO different index spaces — the map KEY is the rewrite-chain section id (RENDER position, matchingbuildResumeSections), whileentryKeyis theaddedBulletsbucket key (PARSED index). They coincide only until a parsed role is deleted.onRemoveBulletinto both sections for the delete cascade, so that follow-up is now mostly prop-threading down toResumeBulletRow.Not touched, per the issue's "Not in scope": per-bullet edit/remove on achievements/projects, an undo-delete toast, and the 18-param positional signature of
applyOverrides.Closes #856