Skip to content

Two /simplify slices, and five bugs they turned up - #49

Merged
jpablo merged 6 commits into
viewerfrom
simplify/viewer-state
Aug 22, 2026
Merged

Two /simplify slices, and five bugs they turned up#49
jpablo merged 6 commits into
viewerfrom
simplify/viewer-state

Conversation

@jpablo

@jpablo jpablo commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Two quality passes (viewer/state, gx-cli), plus the bugs the reviews surfaced — each fixed separately, with regression tests verified to fail against the code they fix.

Cleanup (behaviour-preserving)

  • 52fb68fc viewer/state — 9 files, net −70 lines. One rankdir reader instead of two (the local copy also defaulted to Rankdir.TB by hand instead of Rankdir.default); 8 members collapsed to one selectRelated(outgoing, transitive); beyond(b) was exactly gap(b) >= 0; dead members removed; RightPanelSection's four hand-assigned numbers looked like the persisted encoding and weren't (persistence uses ordinal). Two per-mouse-move wins: concealedCountsNow() is now lazy, and a document-wide elementsFromPoint hit test is now by-name.
  • 877128f5 gx-cli — one persist helper for six copies of the save ladder, one --list printer, one bound-target rule. Target.OnDisk carried an OriginUri all four match sites discarded, costing a redundant toRealPath per invocation.

Fixes (behaviour changes, each with tests)

  • 790605f3 CRLF origins. base/remote are hashes of file bytes; local was hashed with a hardcoded LineEnding.Lf. Any CRLF-authored origin was Ahead forever — and in --mode sync that triggers a push, so gx sync silently rewrote untouched Windows-authored files as LF. A byte-identical regeneration also landed on Diverged instead of Converged. Every pre-existing sync test used text with no newline, where LF and CRLF are identical bytes; that is how it shipped.
  • 00e0a4fb four ways a bad ref or failed write passed for success. watch skipped checkPolicy (the one path-taking command that bypassed the guardrail); gx sync <typo> exited 0; ambiguity was reported as absence, and at the fall-through sites as a path, so gx set <ambiguous> --stdin created a file of that name; syncOne discarded three store.save results, reporting success after failing to persist.
  • df623d1a audit clock + dead traps. Audit stamped its own currentTimeMillis — the one hole in the CLI's clock seam — and re-chmodded per line, which watch pays per event forever. Deleted LibraryStore.inFolder (exact-match semantics disagreeing with gx ls's recursive query) and ChannelError.describe (terser than the CLI messages CliSpec asserts on): both zero-caller, both traps rather than reusable helpers.

Not changed, deliberately

watch --json renders compact because it emits NDJSON; open correctly does not map NO_SESSION to NeedsDesktop; DiagramId.derivedFrom does no sanitising despite a comment claiming it does, but every reachable id is already file-safe.

Integration

8c6998d0 merges #48, which landed mid-branch and touched the same two files. Cli.scala auto-merged and was checked semantically (skill is independent of ref resolution/sync/watch); CliSpec.scala had one import-block conflict, resolved as the union. Both sides' tests are present: gx-cli 81, gx-core 181.

Full suite green across all modules.

Note on merge strategy: the recent convention here is squash, which would collapse the two cleanup commits and three fix commits into one. The fixes each carry their own regression tests and are independently revertible — worth keeping separate if you'd rather not squash.

jpablo added 6 commits August 20, 2026 23:50
…er lines

A quality-only pass over viewer/state (23 files, 4238 L), reviewed along four
independent angles and applied where the fix was contained and behaviour-
preserving. Tests: 149 passed, 0 failed — identical to the pre-pass baseline.

Reuse:
- rankdirNow() re-implemented graphRankDirNow(), and defaulted to Rankdir.TB
  by hand instead of Rankdir.default. Flagged independently by all three
  reviewing angles.
- DefFontSize/DefWidthIn/DefHeightIn duplicated FontSize/Width/Height.default.
  DefFontName stays local: FontName.default is the UI spelling, not gv's.
- clientToLocal hand-rolled the inverse-CTM step that ClientPoint.toSvgPoint
  already owns, and returned a bare tuple where the package passes SvgPoint.

Simplification:
- selectRelated: 4 partially-applied vals + 4 wrappers -> 1 method taking the
  two booleans it already had. The selector is derivable from them, so a
  mismatched pair can no longer compile.
- beyond(b) was exactly gap(b) >= 0 — two enum matches that had to stay
  sign-consistent, now one.
- ThumbnailRenderer: 8 sites repeating the telemetryContext splat -> one local
  log(). Kept context-only so no event's payload changes.
- RightPanelSection carried four hand-assigned numbers that looked like the
  persisted encoding; persistence uses ordinal, and they disagreed.

Dead code (each confirmed by the compiler, not just by grep):
- ViewerState.nodeById, VisibilityOps.hiddenElements.toggle, six unused
  ProjectOps members, two commented-out blocks.
- promptLabelBeforeNewGroup: never written, no UI, no ViewerSettings field —
  unlike its persisted sibling promptLabelBeforeNewNode. Removing it and its
  unreachable else branch leaves behaviour identical (the prompt always shows).

Efficiency (both on paths that run per mouse-move):
- concealedCountsNow() is now lazy: it is O(visible nodes) with 4 set-folds
  each, and every drag computed it and threw it away.
- elementsFromRectEnd is now by-name: a document-wide hit test that forces
  layout, read only in the click branch.

Deliberately NOT in this commit, and why:
- selectAll() misses folded boxes (proxyIds), and six selection ops classify
  raw canvas-spelled ids. Real bugs, not cleanup — separate commits.
- The `format == Mermaid` branch in reverseArrowsStyle belongs on
  DiagramBackend. Contained, but it changes the backend seam on purpose.
- Everything touching Persistence/ProjectsStorage/ThumbnailDiskCache: a dev
  server is live against the real library.
- The successor/predecessor merge in VisibilityOps: the two halves have
  already diverged, so merging them is a behaviour decision.
Second quality-only slice, same four-angle review. Tests: 61 passed, 0 failed —
identical to the pre-pass baseline. Net line count barely moves because three
documented helpers replace duplication that was cheaper per copy than to state
once; the count that dropped is the number of places a rule lives.

Reuse / simplification:
- persist(): the store.save result ladder was spelled out six times, each
  deciding independently that a failed write means ExitCode.Unknown. Now one
  helper. (The three saves inside syncOne still drop their error — that is a
  BUG, not duplication, and is deliberately left for its own commit.)
- listNames(): `run --list` and `session --list` printed a Vector[String] with
  four identical lines each.
- boundTargets(): "--all, or no arguments, means every bound diagram" is
  documented in Usage as shared by sync and watch, and was stated twice.

Dead / no-op:
- Target.OnDisk carried an OriginUri that all four match sites discarded as
  `case OnDisk(path, _)`. Building it cost a toRealPath walk on a path that
  checkPolicy had already canonicalised, on every path-target invocation of
  get, set and run.
- `.left.map(identity)` in pathToShow, and `Right(()).flatMap(_ => ...)` in the
  Args parser, both pure identity wrappers.
- import called store.initialize() before saving; no other save site does, and
  AtomicFiles.write already creates the parent directory. A caller compensating
  for a precondition the callee guarantees.

Output contract:
- emitWatchEvent wrapped a TOTAL ContentHash in Some and unwrapped it with
  getOrElse(""), so the emitted JSON advertised `"hash": ""` as a reachable
  state. Every WatchEvent case carries a hash; typed as such, same output.

Deliberately NOT in this commit:
- The LF/CRLF hash disagreement in syncOne (phantom "Ahead" forever on any
  CRLF-authored origin), watch skipping checkPolicy, syncOne swallowing save
  errors, `gx sync typo` exiting 0, the --json indent/"[]" drift, and the
  open-vs-session NO_SESSION exit code. All real bugs; each wants its own fix.
- Extracting the shared import/bind path prelude. It is the biggest remaining
  simplification, but it reorders "cannot read this file" against "this scheme
  cannot support this mode", and I could not establish from the code that the
  combination is unreachable. Ordering changes are not cleanup.
…ently rewritten

`base` and `remote` are hashes of file BYTES (Hashing.ofBytes, via Documents).
`local` was hashed from the record's text with a hardcoded LineEnding.Lf. For
any CRLF-authored origin the three were never in the same space, so:

  localMoved = (local != base) = ALWAYS TRUE

`gx sync` therefore reported Ahead on a file nobody had touched — and in
--mode sync that is not just a wrong label, it triggers a PUSH: the user's
untouched CRLF file was rewritten as LF, changing every line's bytes. The new
first test asserts the file is byte-identical after a no-op sync, and that
assertion is what fails on the old code.

The same mismatch made `local == remote` unreachable, so a generator rewriting
a CRLF file to byte-identical content landed on Diverged instead of Converged —
precisely the "conflict machine" SyncState.Converged's scaladoc exists to
prevent.

Fix: read the origin ONCE and hash the record's text with that file's own
convention (V-04), so all three hashes describe bytes:

  val origin = path.flatMap(Documents.read(_).toOption)
  val remote = origin.map(_.hash)
  val local  = Hashing.ofText(d.text, origin.map(_.lineEnding).getOrElse(Lf))

Hashing.ofText already demanded the convention explicitly and its scaladoc names
this failure ("a phantom conflict, the least debuggable failure this design
has", V-16); the CLI was the caller it was warning about. Documents.read has
always returned the lineEnding — the CLI discarded it.

Reading once also removes a second full read+SHA of the same file in the Pull
branch, which had re-read what the hash check had just read.

`bind` needs no change: it takes base from Documents.hashOf on an existing file
(already byte-space), and falls back to an LF hash only when the file does not
exist yet — which is what Documents.create writes.

Tests: three new cases covering InSync / Converged / Behind on CRLF origins.
They fail 3/3 on the previous code. Every pre-existing sync test used text with
NO newline in it, where LF and CRLF are identical bytes — which is exactly how
this shipped. 64 passed, 0 failed.
Each of these was found by the four-angle review of gx-cli and deliberately
left out of the cleanup commits. All four are behaviour changes with tests;
the five new cases fail 5/5 against the previous code. 70 passed, 0 failed.

1. `watch` skipped the access policy.
   A ref that was not in the library became an origin via
   FileOrigins.originOf(cwd.resolve(raw)) with no checkPolicy — the one
   path-taking command that bypassed the guardrail every other one applies,
   so `gx watch <denied>` happily followed a file the policy forbids.
   Now policy-checked like import/get/set/bind, and refused with exit 4.

2. `gx sync <typo>` exited 0.
   boundTargets flat-mapped unresolvable refs away, so a mistyped name became
   an empty selection: "(nothing bound to sync)" and success. A script that
   typo'd a diagram name was told it had synced. selectedRefs now RETURNS the
   failures, and sync reports every bad ref before failing.

3. Ambiguity was reported as absence — and worse, as a path.
   findInLibrary's own doc says "Ambiguity is reported rather than resolved by
   picking one", but it returned None for both "nothing matched" and "several
   matched". So callers said "no diagram matches" for a name two diagrams
   share, and the callers that fall back to a PATH did so on an ambiguous ref:
   `gx set <ambiguous> --stdin` created a FILE of that name instead of
   refusing. resolveRef now returns RefError.NotFound | RefError.Ambiguous;
   only NotFound may become a path. Tier order (id, name, origin) is unchanged.

4. `syncOne` discarded three save results.
   env.store.save(updated) with the Either dropped, at all three write sites,
   while six other call sites treated a failed save as ExitCode.Unknown. gx
   printed Behind/Ahead and exited 0 having failed to persist the record it
   had just reconciled — then redid the same work from the same stale baseline
   on the next run, silently. syncOne now returns a SyncOutcome carrying the
   failure, and an unsaved record outranks divergence in the exit code.

Two findings from the same review that are NOT bugs, and are left alone:
- `watch --json` renders compact while other commands use indent = 2. That is
  correct: it emits NDJSON, one object per line, and pretty-printing would
  break line-delimited parsing.
- `open` does not map NO_SESSION to NeedsDesktop the way `session` does. Also
  correct: for `session` the tier genuinely needs a window, while for `open`
  the desktop IS running and the show call failed — reporting "no desktop"
  would be false.
…per line

Audit was the one hole in the CLI's clock seam. Every timestamp in gx comes
from the injected `env.now()` — every `updatedAt`, every `lastSyncAt` — except
the audit line describing the very same operation, which stamped its own
`System.currentTimeMillis()` inside toJson. A record and the line explaining it
could disagree about when it happened, and no test could pin either.

Audit now takes the clock (defaulted, so existing callers are unaffected) and
Main hands the same `clock` to both Audit and CliEnv. The new test asserts the
stamp falls inside the fixture's 1000-and-counting range; against the previous
code it reports a thirteen-digit wall-clock value.

Same file: `record` called Files.createDirectories AND setPosixFilePermissions
around EVERY appended line — two extra syscalls per event, which `watch` pays
once per change for as long as it runs. Both now happen only when the file is
actually being created, tested with one isRegularFile stat. That still
re-restricts a log deleted underneath us, which a "do it once per process"
flag would not.

Also deleted two dead members of gx-core that were not merely unused but
actively misleading, each sitting next to a live hand-rolled answer to the
same question:

- LibraryStore.inFolder — zero callers, and filters `_.folder == folder`
  (exact) while the live folder query in `gx ls` uses `_.folder.isUnder(f)`
  (recursive). Whoever "reused the store helper" would have got results that
  silently disagree with gx ls.
- ChannelError.describe — zero callers, and its messages are terser than the
  CLI's, which deliberately add the next step ("Start Graph Explorer Desktop",
  "only `gx open` needs it") and are asserted on in CliSpec. Reusing it would
  have regressed them.

An unused helper beside two hand-rolled copies is a worse signal than plain
duplication: it says the right seam was identified and then routed around. In
both these cases the helper was also wrong for the job, so deleting is the fix
rather than adopting.

Not changed, deliberately: `DiagramId.derivedFrom` does no sanitising despite
LibraryStore.sanitize's comment claiming it "produces ids that are already
safe". Its only caller passes `ls-<ProjectId>` and ProjectId.random is a
dashless UUID, so every reachable id is already file-safe and no collision
exists today. Worth tightening when something else starts deriving ids.

Full suite: 2193 tests across all modules, 0 failures.
…anch

#48 landed on viewer while this branch was open and touched the same two files
this branch rewrote: Cli.scala and CliSpec.scala.

Cli.scala auto-merged and is semantically clean: #48 added a `skill` command —
a new dispatch arm plus a self-contained handler that goes nowhere near ref
resolution, sync or watch, which is all this branch restructured. Verified the
merged dispatch carries all thirteen arms and that resolveRef / selectedRefs /
SyncOutcome / reportRef all survive.

CliSpec.scala had one conflict, in the import block, where both sides added
imports: `Paths` and `scala.jdk.CollectionConverters.*` from #48, and
`PosixFilePermissions` from this branch's unwritable-store test. Resolved as
the union — no test on either side changed.

Full suite on the merged tree: 0 failures. gx-cli is 81 tests (71 here + 10
from #48) and gx-core 181 (175 + 6), so both sides' tests are present and
passing rather than one side having quietly displaced the other.
@netlify

netlify Bot commented Aug 22, 2026

Copy link
Copy Markdown

Deploy Preview for graph-explorer-net ready!

Name Link
🔨 Latest commit 8c6998d
🔍 Latest deploy log https://app.netlify.com/projects/graph-explorer-net/deploys/6a88ee57c87af70007d456a1
😎 Deploy Preview https://deploy-preview-49--graph-explorer-net.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@jpablo
jpablo merged commit 31b72b1 into viewer Aug 22, 2026
13 checks passed
@jpablo
jpablo deleted the simplify/viewer-state branch August 22, 2026 15:53
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