fix(clv2): align Python _update_registry schema with shell counterpart (#2369)

* fix(clv2): align Python _update_registry schema with shell counterpart

The Python `_update_registry` in instinct-cli.py wrote registry entries
without the `id` and `created_at` fields, while the shell counterpart in
detect-project.sh writes both. A projects.json entry could therefore have a
different shape depending on which path (Python CLI or shell hook) last
touched it.

Emit the same field set and order as the shell version: id, name, root,
remote, created_at (preserved from any existing entry), last_seen. Add
regression tests asserting field parity and created_at preservation.

Fixes #2299

* fix(clv2): guard _update_registry against a non-dict registry entry

A malformed projects.json (a non-dict value for the current project id, e.g.
null) would make existing.get("created_at", ...) raise and crash the update,
losing the old code's ability to self-heal a corrupt per-entry value. Normalize
existing to {} when it is not a dict so the entry is healed by the rewrite. Add
a regression test for the malformed-entry path.

* test(clv2): assert the first-write created_at == last_seen contract

The new _update_registry tests only checked both timestamps were truthy. On the
initial write both derive from the same `now`, so created_at must equal
last_seen; assert that explicitly so a later refactor that breaks the contract
is caught. Split the compound assertions into single-expression checks.

* fix(clv2): heal a non-dict top-level registry in _update_registry

A projects.json that is valid JSON but not a mapping (e.g. `[]` or a
string) previously crashed _update_registry on registry.get(), before
the per-entry guard could run, so the corrupt file could not be healed.
Guard the top-level shape right after the load and fall back to {} so the
rewrite repairs the file — matching the per-entry healing already in place.

Resolves the remaining CodeRabbit finding on #2299.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gaurav Dubey 2026-06-30 07:13:19 +05:30 committed by GitHub
parent be91f21837
commit f12b106c3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 85 additions and 1 deletions

View file

@ -430,12 +430,32 @@ def _update_registry(pid: str, pname: str, proot: str, premote: str) -> None:
registry = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
registry = {}
# A registry that is valid JSON but not a mapping (e.g. a list from a
# corrupt projects.json) must not crash the update before the per-entry
# guard below: fall back to an empty dict so the whole file is healed.
if not isinstance(registry, dict):
registry = {}
# Mirror the shell counterpart in detect-project.sh: the entry carries
# "id" and "created_at" alongside the other fields so a projects.json
# record has the same shape regardless of which path (Python CLI or
# shell hook) last wrote it. "created_at" is preserved from any
# existing entry; only "last_seen" advances on update.
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
existing = registry.get(pid, {})
# A malformed registry (e.g. a non-dict value for this id) must not
# crash the update: fall back to an empty dict so the corrupt entry is
# healed by the rewrite, matching the old unconditional-overwrite
# behavior.
if not isinstance(existing, dict):
existing = {}
registry[pid] = {
"id": pid,
"name": pname,
"root": proot,
"remote": premote,
"last_seen": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"created_at": existing.get("created_at", now),
"last_seen": now,
}
tmp_file = REGISTRY_FILE.parent / f".{REGISTRY_FILE.name}.tmp.{os.getpid()}"