test(cli): cover dedupe, including the separator boundary that justifies the NUL - #40
Conversation
|
Body correction, recorded here so the edit is attributable rather than invisible: the original body carried a literal NUL byte inside backticks at the separator-boundary bullet, which GitHub rendered as empty backticks and a stray line break ("joins on `` rather than a printable delimiter"). The sentence now reads "joins on a NUL (\u0000) rather than a printable delimiter". No claim, number or evidence changed — only that one garbled span. The committed test file was never affected: checked with a byte counter that was itself positive-controlled against a planted NUL first, since a shell |
|
Correction to my own correction comment above, since it stated something that then was not true. That comment said the sentence "now reads: a NUL (backslash-u-0000)". It did not. My first repair wrote caret notation into the body instead — the rendered text became |
…ies the NUL
`grep -rn "'dedupe'" tests/` returned zero hits across 37 test files. The command
calls `itemStore.deleteMany`, so a destructive command was shipping with no
regression guard at all.
THE FIXTURE IS A DISCRIMINATOR, NOT A HAPPY PATH. It holds one genuine duplicate
pair plus four items that must survive, so it fails in both directions: neuter
dedupe and the pair assertions go red, make it too eager and the survivor
assertions go red. A fixture of only duplicates cannot tell working dedupe from
absent dedupe, and a fixture of only distinct items cannot tell it from a no-op.
Covered, all asserted against the store read back rather than against the command's
own reported counts:
* refusal without --yes: non-zero exit AND the store byte-identical to the seed
(compared with Buffer.equals, not by item count, because a rewrite preserving
the count is still a write this command must not make)
* exact title+content duplicate collapses: removed 1, remaining 5, and both
numbers cross-checked against the ids actually on disk
* same title different content, and same content different title, both survive
* THE SEPARATOR BOUNDARY: title 'T' + content 'AB' versus title 'TA' + content
'B'. Both concatenate to 'TAB', so with an empty separator they collide and
dedupe silently deletes one of two unrelated items. This is the only reason the
key joins on NUL rather than a printable delimiter, and nothing else in the
suite would notice if it were "simplified" away.
* the FIRST occurrence survives, not an arbitrary winner
* idempotence: a second run reports removed 0 and deletes nothing more
Two current behaviours are pinned deliberately so that changing them has to be a
decision rather than a discovery:
* the key is title+content ONLY, so the losing duplicate's url and tags are
destroyed even when they differ. The fixture gives the pair different urls and
tags to make that visible.
* `dedupe` does not filter on `archived`, unlike `stats` and the default `list`.
It therefore reaches rows an operator has already put out of sight. A separate
test covers it with a live control item, so the assertion cannot be satisfied by
a run that simply deleted everything archived.
MUTATION-TESTED, each plant reverted immediately, tree clean after:
M0 baseline rc=0 2 pass 0 fail
M1 NUL separator emptied rc=1 1 pass 1 fail (removed 1 -> 2)
M2 deleteMany([]) instead of the dupes rc=1 0 pass 2 fail
M3 --yes gate removed rc=1 1 pass 1 fail
M4 last duplicate wins instead of first rc=1 0 pass 2 fail
M5 archived items excluded rc=1 1 pass 1 fail
M6 restored rc=0 2 pass 0 fail
a05a27b to
db1ba84
Compare
…intable delimiters
Found in adversarial review. The existing boundary pair does not support the claim
built on it. 'T'+'AB' vs 'TA'+'B' collide only under an EMPTY separator, so it
discriminates empty vs non-empty and nothing more. Substituting ',', tab, newline or a
multi-char sentinel for the NUL left the pair distinct and the test GREEN, while the
title, body and the comment at the fixture all claimed this was "the separator boundary
that justifies the NUL".
That gap was live, not theoretical. With the separator changed to ',' and a store holding
{'A','B,C'} and {'A,B','C'} - two unrelated items:
unmutated (NUL): removed 0, remaining 2 both survive
',' separator: removed 1, remaining 1 one silently deleted
Same silent-data-loss class the test exists to catch, and it passed straight through.
A fixture can only tell one delimiter from another by putting that delimiter INSIDE the
data. Three pairs added, each distinct under NUL and colliding under its own candidate
delimiter, plus a positive control that asserts exactly that in both directions - a pair
that failed to collide under its substitute would look like a passing test while guarding
nothing.
Measured, mutating src/cli.ts:2066 and guard-checking with `git diff --quiet` first so an
unapplied mutation could not masquerade as a pass:
baseline (new fixture) rc=0 2 pass 0 fail
separator -> ',' rc=1 1 pass 1 fail (was GREEN before)
separator -> tab rc=1 1 pass 1 fail (was GREEN before)
separator -> newline rc=1 1 pass 1 fail (was GREEN before)
Also corrected in the same pass, all three claims of record that review found inaccurate:
- "dedupe had no test anywhere in the suite ... across 37 test files". The CLI command had
none, but tests/mcp.test.ts covers ok_dedupe with a duplicate pair and removed===1. The
probe `grep -rn "'dedupe'" tests/` misses it because the tool is named 'ok_dedupe'. The
file count is 38, not 37.
- "If the NUL separator is ever emptied, THIS is the assertion that fails" - it is not.
bun aborts at the earlier `result.removed` expectation, so those lines never execute on
that mutation. They are the second line of defence, not the detector.
- "The three non-duplicates" while four were asserted (now ten).
The NUL is written as a six-character escape, never a literal NUL byte. A literal NUL also
makes grep treat the file as binary and go silent, which is how one survived unnoticed in
this PR's own description; my first attempt at this edit reintroduced two of them, caught
by a byte counter positive-controlled against a planted NUL before being trusted.
tests/cli.test.ts: 0 literal NUL bytes.
Not fixed here, filed instead: the same dedupe key is hand-copied at src/mcp.js:1745 and
these fixtures guard only the src/cli.ts copy - emptying the mcp separator leaves the whole
suite green. That wants one shared helper rather than a second fixture.
Adversarial review — APPROVE-WITH-FIXES (fixes pushed as
|
| rc | result | |
|---|---|---|
| baseline, new fixture | 0 | 2 pass 0 fail |
separator -> , |
1 | 1 pass 1 fail — was GREEN |
| separator -> tab | 1 | 1 pass 1 fail — was GREEN |
| separator -> newline | 1 | 1 pass 1 fail — was GREEN |
Three claims of record corrected in the same commit
- "
dedupehad no test anywhere in the suite … across 37 test files" — the CLI command had
none, buttests/mcp.test.tscoversok_dedupewith a duplicate pair andremoved === 1. The
probegrep -rn "'dedupe'" tests/misses it because the tool isok_dedupe. File count is
38, not 37. - "If the NUL separator is ever emptied, THIS is the assertion that fails" — it is not. bun
aborts at the earlierresult.removedexpectation, so those lines never execute on that
mutation. - "The three non-duplicates" while four were asserted (now ten).
My own error, recorded rather than hidden
My first attempt at this edit reintroduced two literal NUL bytes into tests/cli.test.ts —
the same trap that produced this PR's body correction, firing on me through the editing pipeline.
Caught by a byte counter positive-controlled against a planted NUL before being trusted (1 on
the plant, 0 on clean), and repaired into the six-character escape. Also worth recording: with
those NULs present, grep -n on the file returned nothing at all — it treats the file as
binary and goes silent, which is exactly how such a byte survives review. Committed blob: 0.
Not fixed here, filed instead
The same dedupe key is hand-copied at src/mcp.js:1745 as well as src/cli.ts:2066, and these
fixtures guard only the cli.ts copy — emptying the mcp separator leaves the whole suite green.
That wants one shared dedupeKey() helper, not a second copy of the fixture (rule 11 /
abstractions.md). Filed as HC-00202. Whether dedupe should reach archived rows is a
USER-DECISION, filed as HC-00205; this PR correctly pins current behaviour with a live control
either way.
Verified after my fix
263 pass / 2 skip / 1 fail, 266 tests across 38 files — sole failure the pre-existing one, no
regressions. No type errors in the edited region. Staged secrets scan 0 matches on a 10022-byte
diff, scanner positive-controlled against a planted token first.
Task 320d26cf. Formal approval is impossible from this identity, so this comment is the review of
record.
Closes
320d26cf. Baseorigin/maine99f4fd. Test-only — no source change.The gap
grep -rn "'dedupe'" tests/returned zero hits across 37 test files (positive control: thesame pattern against
src/cli.tsreturns 3).dedupecallsitemStore.deleteMany, so adestructive command was shipping with no regression guard at all.
The fixture is a discriminator, not a happy path
This is the part that matters. The fixture holds one genuine duplicate pair plus four items
that must survive, so it fails in both directions:
A fixture of only duplicates cannot tell working dedupe from absent dedupe. A fixture of only
distinct items cannot tell it from a no-op. Either shape produces a green result that measures
nothing.
What is covered
Every assertion reads the store back off disk, never the command's own reported counts alone:
--yes— non-zero exit and the store byte-identical to the seed,compared with
Buffer.equalsrather than by item count, because a rewrite that preserves thecount is still a write this command must not make
removed: 1,remaining: 5, and both numbers cross-checkedagainst the ids actually on disk (
after.length === remaining,seeded - after.length === removed)'T'+ content'AB'versus title'TA'+ content'B'.Both concatenate to
TAB, so with an empty separator they collide and dedupe silently deletesone of two unrelated items. This is the only reason the key at
src/cli.ts:2066joins on a NUL byte rather than a printable delimiter, and nothing else in the suite would notice if itwere "simplified" away. The reasoning sits in a comment beside the pair.
removed: 0and deletes nothing moreTwo current behaviours pinned deliberately
Neither is changed here; both are now assertions so that changing them has to be a decision
rather than a discovery by a user:
urlandtagsare destroyedeven when they differ. The fixture gives the pair different urls and tags to make that visible
in the diff.
dedupedoes not filter onarchived, unlikestatsand the defaultlist. It thereforereaches rows an operator has already put out of sight. Its own test includes a live control
item, so the assertion cannot be satisfied by a run that simply deleted everything archived.
Verification — mutation-tested
Each plant reverted immediately;
git status --porcelainclean after. Unpiped exit codes,-t "dedupe":M1 is the one the task asked for specifically, and it fires exactly as predicted: the boundary
pair collapses and
removedgoes from 1 to 2.The four other failures in the full run are not mine
bun test tests/cli.test.ts --timeout 60000on this branch:rc=1, 70 pass / 5 fail. Four ofthose five are load-induced timeouts, not regressions:
Evidence they are environmental, not caused by this change:
tests/cli.test.tscarries 10 hardcoded per-test timeouts (}, 10000)at lines 2497,2533, 2571, 2598;
}, 15000)at 2998;}, 20000)five times) which--timeout 60000doesnot override. Machine load average was 52-60.
68fc5c1ctouches onlysrc/mcp.js,tests/mcp.test.tsandbin/knowledge-mcp.js— it cannot affecttests/cli.test.ts— and itsfull-suite run showed the same sync/search timeouts.
e99f4fdwith this change stashed:rc=0, 12 pass.Running them alone with the change: 11 pass / 1 fail, the same timeout. Same test, both
directions, under the same load — a flake, not a diff.
context pack and proposal context commands return bounded agent JSONis the genuinepre-existing failure, already recorded on
mainand on PR #36. Not touched here.Task:
320d26cf. Not merged by me — needs an independent adversarial review first.