Skip to content

test(cli): cover dedupe, including the separator boundary that justifies the NUL - #40

Merged
andrei-hasna merged 2 commits into
mainfrom
test/hc-dedupe-coverage
Jul 27, 2026
Merged

test(cli): cover dedupe, including the separator boundary that justifies the NUL#40
andrei-hasna merged 2 commits into
mainfrom
test/hc-dedupe-coverage

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Closes 320d26cf. Base origin/main e99f4fd. Test-only — no source change.

The gap

grep -rn "'dedupe'" tests/ returned zero hits across 37 test files (positive control: the
same pattern against src/cli.ts returns 3). dedupe calls itemStore.deleteMany, so a
destructive 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:

  • neuter dedupe → the pair assertions go red
  • make dedupe too eager → the survivor assertions go red

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:

  • refusal without --yes — non-zero exit and the store byte-identical to the seed,
    compared with Buffer.equals rather than by item count, because a rewrite that preserves the
    count is still a write this command must not make
  • the duplicate collapsesremoved: 1, remaining: 5, and both numbers cross-checked
    against the ids actually on disk (after.length === remaining,
    seeded - after.length === removed)
  • 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 at src/cli.ts:2066 joins on a NUL byte rather than a printable delimiter, and nothing else in the suite would notice if it
    were "simplified" away. The reasoning sits in a comment beside the pair.
  • the FIRST occurrence survives, not an arbitrary winner
  • idempotence — a second run reports removed: 0 and deletes nothing more

Two 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:

  1. 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
    in the diff.
  2. dedupe does not filter on archived, unlike stats and the default list. It therefore
    reaches 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 --porcelain clean after. Unpiped exit codes,
-t "dedupe":

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 from dedupe     rc=1   1 pass  1 fail
M6 restored                                rc=0   2 pass  0 fail

M1 is the one the task asked for specifically, and it fires exactly as predicted: the boundary
pair collapses and removed goes from 1 to 2.

The four other failures in the full run are not mine

bun test tests/cli.test.ts --timeout 60000 on this branch: rc=1, 70 pass / 5 fail. Four of
those five are load-induced timeouts, not regressions:

✗ sync status, snapshot, machines, and conflicts use the project catalog  "timed out after 10000ms"
✗ sync dry-run and push copy a project catalog into a peer workspace      "timed out after 10000ms"
✗ sync peer-workspace works without machines adapter calls                "timed out after 10000ms"
✗ sync export and import move a bundle through stdin/stdout               "timed out after 10000ms"
✗ context pack and proposal context commands return bounded agent JSON    <- the real pre-existing one

Evidence they are environmental, not caused by this change:

  • tests/cli.test.ts carries 10 hardcoded per-test timeouts (}, 10000) at lines 2497,
    2533, 2571, 2598; }, 15000) at 2998; }, 20000) five times) which --timeout 60000 does
    not override. Machine load average was 52-60.
  • The decisive control: the sibling branch for 68fc5c1c touches only src/mcp.js,
    tests/mcp.test.ts and bin/knowledge-mcp.js — it cannot affect tests/cli.test.ts — and its
    full-suite run showed the same sync/search timeouts.
  • Running the sync tests alone at base e99f4fd with 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 JSON is the genuine
pre-existing failure, already recorded on main and on PR #36. Not touched here.

Task: 320d26cf. Not merged by me — needs an independent adversarial review first.

@andrei-hasna

Copy link
Copy Markdown
Contributor Author

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 grep -qP '\x00' has silently failed to match a planted NUL on this machine before. tests/cli.test.ts and src/cli.ts both contain 0 NUL bytes and the test comment refers to the separator in words.

@andrei-hasna

Copy link
Copy Markdown
Contributor Author

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 \^@, bytes 92/94/64 — because the escape did not survive the shell/JSON/API pipeline intact. I stopped trying to write an escaped NUL through it and set the sentence to plain words: it now reads "joins on a NUL byte rather than a printable delimiter". Verified by byte-level readback: no NUL bytes and no caret notation in the body. Two body edits total, both before any review, both recorded here rather than left for someone to notice.

…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
@andrei-hasna
andrei-hasna force-pushed the test/hc-dedupe-coverage branch from a05a27b to db1ba84 Compare July 27, 2026 22:21
…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.
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

Adversarial review — APPROVE-WITH-FIXES (fixes pushed as 78efe7f, then merging)

Two reviewers, independent worktrees, cloud-flip vars neutralised, NUL detectors
positive-controlled before use.

Confirmed — the verification table is honest

All 5 reported mutation plants reproduce exactly, same pass/fail split and same
removed 1 -> 2 detail. Nothing fabricated. The general discriminating-power claim holds in
both directions: neutering dedupe reddens the pair assertions, making it too eager reddens the
survivor assertions, and every assertion reads the store back off disk. 10 hardcoded per-test
timeouts confirmed at the exact lines claimed. Suite at db1ba84: 263 pass / 2 skip / 1 fail,
sole failure the pre-existing context pack …, proven pre-existing by running it at base
535daf0 and getting the identical tests/cli.test.ts:3044:88. Zero timeout artefacts this run.
Committed file: 0 literal NUL bytes, and both correction comments for the body's NUL are
present and attributable.

Blocking finding — the headline claim was unsupported, and the gap was live

The title, body and fixture comment all claimed "the separator boundary that justifies the NUL".
The pair 'T'+'AB' / 'TA'+'B' only collides under an empty separator, so it discriminates
empty vs non-empty and nothing more. Four printable substitutions all kept the test GREEN:
,, tab, newline, and a multi-char sentinel.

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

That is the exact silent-data-loss class this test exists to catch, passing straight through it.
This is the PR #36 lesson repeating: a corpus without a discriminating item makes correct and
broken behaviour look identical.

Fixed by putting each candidate delimiter inside the data — three pairs, each distinct under
NUL and colliding under its own delimiter — plus a positive control asserting both directions, so
a pair that failed to collide under its substitute cannot masquerade as a passing test. Measured,
with git diff --quiet guarding each mutation so an unapplied one could not be misread as a pass:

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

  1. "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 ok_dedupe. File count is
    38, not 37.
  2. "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.
  3. "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.

@andrei-hasna
andrei-hasna merged commit 5f7d297 into main Jul 27, 2026
0 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant