8000 fix(snooze): let --snooze re-affirm a lapsed entry by k-sok · Pull Request #167 · habit-hooks/habit-hooks · GitHub
[go: up one dir, main page]

Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -799,8 +799,8 @@ commits lost). Give every git-backed section a
`✏️GIT_CEILING_DIRECTORIES` = `$PWD/..` step next to its `git init`, as
`## Scope` → `### Git-derived scopes` in habit-sensors.spec.md does: git's
upward walk then stops at the case directory and the case can only ever see the
repository it built. Older git-backed cases (habit-snooze.spec.md's
`## --until-changed`, and two in habit-sensors.spec.md) still lack it.
repository it built. Two older git-backed cases in habit-sensors.spec.md still
lack it.

### `git diff --name-only` answers from the repo root, and quotes odd names

Expand Down
65 changes: 64 additions & 1 deletion docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,9 @@ mapping, config validation) are resolved and recorded above / in
ever encoded content in a key, so the ratchet simply vanished. Keeping the
index keyed on `key` alone and moving the question into the drop decision
leaves the index format and `--prune` untouched.
_(SUPERSEDED by "A snooze records what it was granted against" below — the
index now records content per anchor and `--prune` reaps anchors too. It is
still keyed on `key` alone.)_
- **An issue is anchored to `details.file`, falling back to `key`.** A sensor
keys by whatever groups issues best — `deptry` by module, `knip` by export —
so the key is not always a path; all eight shipped sensors carry
Expand Down Expand Up @@ -384,7 +387,8 @@ mapping, config validation) are resolved and recorded above / in
- **A malformed index fails by name.** `load_index` was `json.loads` with no
type check: `null` iterated as `None`, a bare `"src/a.py"` iterated per
character (a silent index of nothing), an object survived only to be flattened
on the next `--snooze`. It now demands a JSON list of strings and raises
on the next `--snooze`. It now demands a JSON list of strings _(AMENDED below
— a list of entries, each a key or a key with its recorded anchors)_ and raises
`SnoozeError` naming the file — a checked-in file a human edits must not fail
as a traceback or, worse, quietly. It **exits 2**, not 1: the index is part of
the tool's own inputs, so a broken one is #103's "failure of the tool itself",
Expand Down Expand Up @@ -461,3 +465,62 @@ mapping, config validation) are resolved and recorded above / in
`unused-import`, `unused-file`, `unused-dependency`, `unused-class-member` are
all enforced). A project that wants it advisory sets
`[smells.unused-export] severity = "suggested"`.

## A snooze records what it was granted against (2026-09, issue #163, agent decision)

Supersedes "leaves the index format and `--prune` untouched" under #80 above: both change here.

- **Re-affirming is `--snooze` itself, not a new flag** _(Ivett's call)_. Running it again on an
entry whose file changed records the file as it now stands, and the finding is dropped until the
next edit. The issue had proposed a `--reaffirm`; it was refused as a new option for behaviour
the existing one should have.
- **An entry is a key and the anchors it was granted against** — `{"key": …, "anchors": {"<file>":
"sha256:…"}}` — and an entry recording nothing stays a bare key string, so an index written
before this loads and rewrites unchanged and a project migrates one `--snooze` at a time.
- **A lapse needs both halves: git says the anchor changed, *and* the recorded content no longer
matches.** Content alone would break the promise recorded under #80 — work landed on the base ref
afterwards lapses nothing — because a branch that never touched the file still sees the content
someone else landed. The git half is what keeps that branch alone; the index half is the only
thing a re-run of `--snooze` can change, which is why a bare path could never re-affirm anything.
- **One digest per anchor, not per key.** A key can cover several files (`deptry` keys by module,
`knip` by export), and the pre-#163 rule already decided per issue against that issue's own
anchor. A single digest per key could only describe one of them, and *which* one fell out of the
order the sensor emitted its issues — a checked-in file that differs by sensor output order. It
also suppressed new debt: a key that becomes aliased on day two carries a digest recorded on day
one, and the file that has only just started reporting the smell inherits an affirmation nobody
gave it. Affirmation is therefore per `(key, anchor)`, which is what the rule always was.
- **The digest normalises `\r\n` and nothing else, deliberately one notch more forgiving than
`git diff`.** Measured: under `core.autocrlf` git reports nothing for a CRLF-only rewrite;
without it git reports the file. Hashing the bytes on disk would lapse an affirmation made on a
CRLF checkout the moment an LF one read the index, and the index is checked in precisely so the
answer travels. A line-ending-only rewrite is not the debt the ratchet exists to surface.
- **Rejected: `git hash-object`.** It would honour `eol=` and other clean filters and would be the
most faithful to "let the tool answer", but `--snooze` must work with no repository at all
(habit-snooze.spec.md snoozes without a `git init`), and a spec case without
`GIT_CEILING_DIRECTORIES` would read *this* checkout's attributes.
- **Every `--snooze` records, plain `snooze` included.** `--snooze --until-changed` is already a
usage error (#86), so there is no flag to key the recording off. A project that never opted into
the ratchet therefore gains the new entry shape too — and with it the read break below.
- **The break is forwards-only, and loud.** 1.5.0's `_parse_index` refuses any entry this version
records into, including a half-migrated index, with `SnoozeError` and exit 2. A team where one
person upgrades breaks everyone else's runs, and CI's, until they upgrade. It fails by name and
names the file, which is the right direction to fail, but it is a minor-version break.
- **`--prune` reaps a stale anchor the same way it reaps a key.** An anchor goes stale inside an
entry that is still live — one of two files deleted while the key is still reported through the
other — and pruning by key alone would leave it recorded forever. Its contract is unchanged, and
the #94 refusal to empty a populated index on a run that measured nothing is untouched.
- **A key written twice is refused when the two disagree, and only then.** Loading into a mapping
keeps the last, so a bare duplicate beside a recorded one drops the recording with the order in
the file deciding — the "survived only to be flattened on the next write" class #94 parses
strictly to prevent. Two entries that agree lose nothing, so a plain `["a", "a"]` stays legal as
it always was, and nothing that 1.5.0 reads is refused here. The same rule covers a name written
twice inside one object, which `json` would otherwise resolve last-wins in silence.
- **Only an anchor git called changed is hashed.** The other half of the rule decides every other
one without it, so asking them all would read and hash every snoozed file in the project on the
ordinary run where git flagged none — in a transform that runs inside a hook loop, and whose git
half is batched into a single call for exactly that reason. The narrowing lives on `Lapse`, which
already holds git's answer, so the transform's argument count is unchanged.
- **Out of scope, deliberately:** a `reason` field and a per-smell key (both from the issue's own
proposal, neither needed for the ruling given), and restricting the recording to
`oversized-file` — the transformer works on keys and knows nothing of the smell vocabulary,
which is `catalogue.py`'s; the property that matters is structural, not a name.
113 changes: 110 additions & 3 deletions docs/habit-snooze.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@ habit-snooze | jq .
`--snooze` reads the findings on stdin and adds each issue's `key` to the index.
`--list` then shows what is snoozed.

An entry also records the content of each file the key is anchored to —
`{"key": "src/x.ts", "anchors": {"src/x.ts": "sha256:…"}}` — which is what lets a
later `--snooze`
[re-affirm it](#--snooze-re-affirms-a-lapsed-entry-until-the-next-change). It
remembers per file, because a key does not always stand for exactly one
([sensor-interface.spec.md](sensor-interface.spec.md)). A key with nothing to
record — an anchor that is no file on disk — is written as a bare string
instead. `--list` shows the keys either way.

⌨️
```json
[
Expand Down Expand Up @@ -210,6 +219,12 @@ takes the key out of the index. That is deliberate — a project upgrading must
not find its snoozes re-arming by themselves — and it is the whole difference
from [`--until-changed`](#--until-changed-keeps-a-snooze-only-while-its-file-is-unchanged)
below. The file here is committed and then edited, and the issue stays dropped.
The ceiling keeps `git init` from finding habit-hooks' own checkout above it.

✏️GIT_CEILING_DIRECTORIES
```text
$PWD/..
```

📄src/x.ts
```ts
Expand Down Expand Up @@ -317,7 +332,8 @@ deleted — is stale, and `--prune` drops it. But `--prune` must read the findin
stripped every snoozed issue, so a naive `--prune` would see none of them and
empty the whole index (#94). The documented pipeline therefore runs
`habit-sensors --no-snooze`, so `--prune` compares the index against everything
the run still finds — snoozed or not.
the run still finds — snoozed or not. It reaps stale **anchors** the same way: a
key can stay live through one file while another it recorded is gone.

These cases drive that real pipeline through a stub sensor rather than hand-fed
findings, so the bypass that hid the bug cannot come back. Discovery is opt-in
Expand Down Expand Up @@ -470,7 +486,7 @@ habit-snooze --list 2>&1 >/dev/null | sed 's| /.*/\.habit-hooks/| .habit-hooks/|

🖥️ ❌ 2
```text
habit-snooze: .habit-hooks/snooze.json: expected a JSON list of string keys, got an object
habit-snooze: .habit-hooks/snooze.json: expected a JSON list of snoozed entries, got an object
```

## `--until-changed` keeps a snooze only while its file is unchanged
Expand Down Expand Up @@ -501,7 +517,15 @@ snooze permanent again, silently, which is the bug this transformer exists to
fix.

Every case below inherits this repository: `src/x.ts` and `src/other.ts`
committed on `main`, with `src/x.ts` snoozed.
committed on `main`, with `src/x.ts` snoozed. The ceiling is what keeps it
*this* repository: the spec harness runs each case inside habit-hooks' own
checkout, and without it git walks up and answers about habit-hooks — a case
that branches would then rename a ref in the checkout you are reading.

✏️GIT_CEILING_DIRECTORIES
```text
$PWD/..
```

📄src/x.ts
```ts
Expand Down Expand Up @@ -585,6 +609,89 @@ habit-snooze --until-changed | jq -c '[.[].issues[].key]'
["src/x.ts"]
```

### `--snooze` re-affirms a lapsed entry until the next change

The file lapsed above, and sometimes the answer is still the one given last
time. Running `--snooze` again records the entry against the file as it now
stands, and the finding is dropped for the rest of this branch. The index keeps
that answer, so a teammate's checkout and CI honour it too.

Note that `--snooze` re-affirms every lapsed entry the run reports, not only the
one you had in mind: it renews what it is fed.

```bash
printf 'export const extra = 1;\n' >> src/x.ts
```

⌨️
```json
[
{
"smell": "oversized-file",
"details": { "maxAllowed": 200 },
"issues": [
{ "key": "src/x.ts", "details": { "file": "src/x.ts", "lines": 251 } }
]
}
]
```

```bash
habit-snooze --snooze
```

The finding is gone again, and the file was never changed back.

⌨️
```json
[
{
"smell": "oversized-file",
"details": { "maxAllowed": 200 },
"issues": [
{ "key": "src/x.ts", "details": { "file": "src/x.ts", "lines": 251 } }
]
}
]
```

```bash
habit-snooze --until-changed | jq -c '[.[].issues[].key]'
```

🖥️ ✅
```json
[]
```

The next edit asks again: what was affirmed was that state of the file.

```bash
printf 'export const more = 2;\n' >> src/x.ts
```

⌨️
```json
[
{
"smell": "oversized-file",
"details": { "maxAllowed": 200 },
"issues": [
{ "key": "src/x.ts", "details": { "file": "src/x.ts", "lines": 251 } }
]
}
]
```

```bash
habit-snooze --until-changed | jq -c '[.[].issues[].key]'
```

🖥️ ✅
```json
["src/x.ts"]
```

### A commit against the base ref lapses the snooze

The change is committed on a branch, so the working tree is clean — the
Expand Down
Loading
0