Codex P2: link_tests Pass 1 dropped duplicate (production, test) pairs
purely by arrival order — when two batches both emitted a tested_by
edge for the same pair with different confidences (0.3 vs 0.9), the
edge that happened to iterate first won. The general Step 6 deduper
at line 762 mirrors `weight > existing.weight` semantics but it only
ever saw one of the duplicates, so it couldn't rescue the heavier one.
Refactor Pass 1 to mirror Step 6's weight comparison locally:
- Track `pair_to_idx` mapping each kept (prod, test) pair to its
slot in the compacted edges list. On a duplicate, look up the
existing kept edge and compare weights; if the new edge is
strictly heavier, swap (if needed) and replace the slot. Tie or
lighter → drop the new edge.
- Defer the swap operation until we know an edge will survive — no
point canonicalizing a doomed duplicate.
- Track surviving swap pairs in a separate `swapped_pairs` set so
the `swapped` counter reflects the FINAL output, not the wasted
work on edges that were later replaced. This means: replacing a
swapped edge with a heavier canonical one drops the swap from
the count; replacing a canonical edge with a heavier swapped one
adds it.
- Extract the swap-in-place mutation into `_swap_tested_by_in_place`
so it can be invoked from both code paths.
Five new unit tests cover all four weight-vs-direction combinations
plus a tie case (existing test_drops_duplicate_canonical_edges, which
still passes — tie → keep first, no swap counted).
microservices-demo regression check unchanged: 7 → 7 edges, 3 swapped,
0 dropped, 7 tagged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Strip-and-rederive (current PR behaviour) drops real coverage signal on
projects whose test layout doesn't match a naming convention. On the
Google microservices-demo the LLM had emitted 7 valid tested_by edges
(3 with inverted direction); the strip pass dropped them and the path-
convention rederive could only re-pair 4 of them. Net: 7 → 4 edges,
3 production files lost their tested signal.
Replace strip-and-rederive with two-pass swap-then-supplement:
Pass 1 — walk LLM tested_by edges. Canonical (production → test)
edges pass through unchanged. Inverted (test → production) edges are
flipped in place; description gets a `[direction corrected]` audit
marker. Edges with no recoverable meaning (test↔test, prod↔prod,
orphan endpoint, duplicate pair) are dropped.
Pass 2 — for tests not yet paired by Pass 1, walk path-convention
candidates and emit a fresh production → test edge for the first
match. Pairs already covered by Pass 1 are skipped.
Tagging is consolidated into a final pass over all canonical edges so
production nodes get the "tested" tag whether the edge came from
Pass 1 (canonical / swapped) or Pass 2 (supplement).
Multi-language audit of production_candidates revealed three real-world
gaps surfaced by re-checking microservices-demo and common project
layouts:
- JS/TS walk-out only handled `__tests__/`. Extended to also walk out
of `<dir>/test/`, `<dir>/spec/`, and `<dir>/tests/` (some JS/TS
projects use these instead of __tests__/).
- Python walk-out only handled top-level `tests/`. Added in-package
`<pkg>/tests/test_<name>.py` → `<pkg>/<name>.py` (Django app style
and any project that colocates tests with the package).
- C# only had sibling fallback. Added two new mirrors:
* `<svc>/tests/X.cs` ↔ `<svc>/X.cs` and `<svc>/src/.../X.cs`
(microservices-demo cartservice exact layout).
* `<App>.Tests/Foo/BarTests.cs` ↔ `<App>/Foo/Bar.cs`
(.NET sibling-project convention).
Go is intentionally not changed — the "one _test.go covers several
.go files in the same package" pattern is now solved by Pass 1
(swapping LLM edges), not by trying to invent multi-pair path heuristics.
The file-analyzer prompt is updated: the `tested_by` row is restored
in the schema table because we now use those edges as evidence (Pass 1
canonicalizes the direction). The note explains direction will be
auto-corrected so the LLM doesn't need to be defensive about it.
link_tests now returns a 4-tuple (added, dropped, tagged, swapped);
the merge_and_normalize report distinguishes "edges produced
(supplement)" from "edges flipped" from "edges dropped".
Real-world validation on microservices-demo:
before: 7 tested_by edges, 3 inverted, 0 tagged
after PR: 4 tested_by edges, 0 inverted, 4 tagged ← strip-and-rederive
this: 7 tested_by edges, 0 inverted, 7 tagged ← swap-then-supplement
Tests: 47 pass (was 37). New cases cover all swap branches, the
shippingservice "one test, many sources" regression, and each new
language pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a `headingFont` option to ThemeConfig that lets users switch
heading typography between Serif (default), Sans, and Mono via the
existing Theme Picker UI.
- New `--font-heading` CSS custom property (defaults to `--font-serif`)
- Theme engine applies the selected font on config change
- ThemePicker gets a "Heading Font" toggle section
- All 15 component files updated from `font-serif` to `font-heading`
- Selection persists in localStorage alongside other theme settings
- Backwards-compatible: existing configs without `headingFont` default to serif
Closes#120
A controlled-experiment audit on a 1240-file Python project (opensre)
showed that 27.2% of resolved-internal imports never made it from
project-scanner's `importMap` into the final knowledge graph. Of the
404 source files with internal imports, 91 ended up with ZERO imports
edges in the graph despite their `file:` node being present (consistent
with main-session orchestrator dropping the entry from `batchImportData`
during batch construction), and 104 had partial coverage (consistent
with file-analyzer agent dropping rows during edge enumeration).
GitHub issue #128 reported the same failure mode at 16-21% on a Go
monorepo.
The fix has two layers:
1. `merge-batch-graphs.py` now runs a deterministic recovery pass
after merge: for every `(source, target)` in scan-result.json's
`importMap` whose source `file:` node exists in the assembled graph
and whose target `file:` node also exists, emit an `imports` edge
if the batches didn't already. Recovered edges are tagged
`recoveredFromImportMap: true` so downstream consumers can audit
which edges came from the deterministic source vs. agent emission.
The merge report logs the recovered count plus how many importMap
entries were skipped because their source/target had no graph node.
2. `file-analyzer.md` rewrites the imports edge rule to demand 1:1
emission with a self-check: "the number of `imports` edges in your
output MUST equal `sum(batchImportData[file].length)` across the
batch's code files". This drives the agent to enumerate every row
instead of summarizing — recovery should report 0 when this works.
Tests: +6 cases covering the recovery path — drops, no-double-emit,
missing source/target nodes, missing scan-result.json (incremental
update), and self-import suppression. 770 passing (was 764).
Bumps version to 2.6.3 across the five tracked manifests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A deep audit of the project-scanner → file-analyzer → merge pipeline
turned up a wide range of silent data-loss bugs. Each one alone is
small; together they were producing graphs with very few import edges,
missing sub-file nodes for non-code formats, and inconsistent metrics.
Root-cause fixes (high impact):
- project-scanner.md: extend import-pattern table to resolve absolute
imports for Python (`from a.b.c import x`), TS/JS (tsconfig.json
paths/baseUrl aliases), Java/Kotlin (`com.foo.Bar` ↔ file paths),
Ruby (`require 'foo/bar'` load-path), PHP (composer PSR-4 namespaces),
and C/C++ (`#include` headers). Was relative-only, which produced
empty importMap entries for the majority of real projects.
- project-scanner.md: add `.ps1`, `.bat`, `.cmd`, `.jsonc` to language
table; require non-null `language` field with an explicit fallback.
- file-analyzer.md: document `sections`, `definitions`, `services`,
`endpoints`, `steps`, `resources` in the extraction-output schema and
spell out the sub-file node-creation rules per category. Was missing,
so per-table / endpoint / resource nodes were never created from
SQL / OpenAPI / Terraform / K8s / Dockerfile parser output.
- file-analyzer.md: add explicit source-reading fallback rules for
PowerShell, Batch, Bash, Swift, Kotlin (no tree-sitter coverage).
- yaml-parser: declare `kubernetes`, `docker-compose`, `github-actions`,
`openapi` languages so files the language-registry tags with those
ids actually get section extraction. Recognize quoted top-level keys
(e.g. `"on":` in GitHub Actions). Emit one section per entry for
array-root YAML documents.
- json-parser: declare `json-schema`, `openapi`; add `stripJsoncSyntax`
helper that removes line / block comments and trailing commas before
parse so `.jsonc` files (wrangler, tsconfig with comments) parse cleanly.
- shell-parser: declare `jenkinsfile`. Tighten function-detection regex
to require a reachable `{` brace so `name() echo hi` and patterns
appearing inside heredocs are no longer false-positives.
- markdown-parser: track fenced-code-block state and skip headings
inside ``` / ~~~ blocks (`# install` shell comments were being
emitted as level-1 sections).
- merge-batch-graphs.py: add `article`, `entity`, `topic`, `claim`,
`source` to VALID_NODE_PREFIXES and TYPE_TO_PREFIX so knowledge-base
node types stop being flagged unknown / coerced to `file:`. Add
`direction` to the edge dedup key so `forward` and `bidirectional`
variants of the same (src, tgt, type) don't overwrite each other.
Use a placeholder in bare-id fallback when `filePath` is missing on
function/class nodes so unrelated `parse()` functions don't merge.
- typescript-extractor: actually compute `isDefault` for default
exports (was always emitted as `false` from buildResult).
- extract-structure.mjs: match `wc -l` semantics for `totalLines` so
the scanner's `sizeLines` and the extractor's `totalLines` agree on
POSIX text files. Filter the parser-imports fallback to relative-only
so `importCount` semantics stay *internal-import* whether the scanner
resolved them or not. Drop unused `isCode` local.
Tests: +19 cases covering JSONC parsing, markdown fenced-code skip,
YAML quoted-keys / array-root, shell function false-positives,
extract-structure import fallback semantics + totalLines off-by-one.
764 passing (was 745).
Bumps version to 2.6.2 across the five tracked manifests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs surfaced when analyzing Python projects that use absolute imports:
- The dispatch prompt (SKILL.md) and file-analyzer agent omitted the
per-file `language` field, so `extract-structure.mjs` received null and
passed it through to the graph.
- `extract-structure.mjs` used `if (importPaths)` to decide whether to
trust pre-resolved imports. Empty arrays are truthy, so files where the
project scanner could not resolve any imports (e.g. Python absolute
imports) clobbered the parser's import count with 0, never falling
back to tree-sitter's own analysis.
Bumps plugin version to 2.6.1 across the five tracked manifests and adds
unit tests for `buildResult` covering language pass-through and the
importCount fallback paths. To make the script testable, `buildResult` is
now exported and the CLI invocation is guarded so importing the module
no longer triggers `main()`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codex flagged that prod_node.setdefault("tags", []) returns the existing
value when the key is present, so a raw LLM batch with tags=None or
tags="some string" would crash the whole merge on the next "tested" not
in tags membership check.
The TypeScript autoFixGraph normalizer that handles this case runs
downstream of merge-batch-graphs.py, not before it, so the Python side
has to defend itself. Coerce non-list tags to a fresh [] before the
membership/append.
Regression test exercises None / comma-string / single-string / int /
dict inputs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- install.ps1 (P1): refuse to delete real files/directories; only remove
paths that are actual junctions/symlinks. Closes a data-loss path where
Cmd-Uninstall would Remove-Item -Recurse a pre-existing user directory at
~/.understand-anything-plugin (Cmd-Install correctly skipped touching it,
but uninstall did not). Cmd-Uninstall and Unlink-Skills now both go
through Remove-Reparse, which uses DirectoryInfo.Delete() so a junction's
target is never followed.
- install.sh (P2): --uninstall no longer exits early when the checkout has
been deleted. The per-skill unlinker falls back to scanning the target
dir for stale symlinks pointing into the plugin tree, so users can clean
up after manually rm -rf'ing the checkout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace 7 nearly-identical .<platform>/INSTALL.md files with one
install.sh + install.ps1 that enumerate skills dynamically from
understand-anything-plugin/skills/ — the previous hardcoded list of 6
was already stale, missing understand-domain and understand-knowledge.
Supported usage:
install.sh <platform> # gemini/codex/opencode/pi/openclaw/antigravity/vscode
install.sh # interactive prompt; reads /dev/tty so curl|sh works
install.sh --update # git pull on the shared checkout
install.sh --uninstall <plat> # removes skill links for that platform
Single shared checkout at ~/.understand-anything/repo (override via
UA_DIR). Antigravity keeps its existing ~/.gemini/antigravity/skills
path for backward compatibility; OpenClaw keeps its folder-symlink
style. Universal ~/.understand-anything-plugin link unchanged.
README.md and the 6 translated READMEs replace the old per-platform
"Fetch and follow instructions from .../<plat>/INSTALL.md" blocks with
a single curl|sh / iwr|iex one-liner. Compatibility tables updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Render a small green dot next to the complexity badge whenever a node's
tags contain "tested" — surfacing the deterministic linker's signal so
users can see at a glance which files have paired tests.
Plumb node.tags through both CustomNodeData construction sites in
GraphView.tsx; KnowledgeGraphView.tsx already passes tags.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- is_test_path: collapse 7 per-language conditional blocks into a
data-driven _TEST_NAME_PATTERNS table; JS/TS infix stays inline
- production_candidates: extract _join + module-level _add_unique to
drop the nested closure and the repeated trailing-slash idiom
- Drop dead _TEST_DIR_SEGMENTS constant and the local _splitext
reimplementation; use os.path.splitext
- link_tests: drop the impossible-malformed-tags guard, tighten the
docstring, change edge description to "Path-based pairing
(deterministic)", drop redundant break comment
- Trim Step 5b inline block that duplicated the module-level header
- Convert file-analyzer Note from blockquote to bold paragraph to
match surrounding prompt style
Tests: split the strip-edges test from the unrelated-edges-survive
test, add empty-input and missing-filePath cases, pin sibling-before-
walkup and sibling-before-mirror priority order, drop brittle report
text assertion. 36 tests, all passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a `headingFont` option to ThemeConfig that lets users switch
heading typography between Serif (default), Sans, and Mono via the
existing Theme Picker UI.
- New `--font-heading` CSS custom property (defaults to `--font-serif`)
- Theme engine applies the selected font on config change
- ThemePicker gets a "Heading Font" toggle section
- All 15 component files updated from `font-serif` to `font-heading`
- Selection persists in localStorage alongside other theme settings
- Backwards-compatible: existing configs without `headingFont` default to serif
Closes#120
Now that the merge script produces tested_by edges deterministically
from path conventions, the LLM should not emit them — its direction is
unreliable across batches and any emitted edges are stripped on merge.
- Remove tested_by row from file-analyzer's edge table.
- Add a note pointing to the deterministic linker.
- Document the new behaviour in the merge section of SKILL.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The file-analyzer LLM only sees the production↔test relationship when
analyzing a test file (production files don't import their tests), so
its emitted direction was unreliable across batches and recall was
massively undercounted (~7% on a real Nuxt 4 + Directus repo).
Move tested_by production entirely into the merge step. The linker:
- Strips every tested_by edge from batch input (LLM direction unreliable).
- Indexes file:* nodes and classifies each path as test or production.
- For each test, walks ordered candidate production paths (sibling
de-infix, __tests__/ walk-out, mirrored tests/→{src,app,lib,<root>}
tree, Maven/Gradle src/test/...→src/main/...).
- Emits canonical production → test edges and tags production nodes
"tested".
Supported conventions: JS/TS family (.test/.spec), Go (_test.go),
Python (test_*.py, *_test.py), Java (*Test/*Tests/*IT.java), Kotlin
(*Test/*Tests.kt), C# (*Test/*Tests.cs), C/C++ (test_*, *_test).
Stdlib only, type-hinted in existing style. Hooked into
merge_and_normalize between node dedup (Step 5) and edge dedup
(Step 6). Reports drops under "Fixed" and additions under a new
"Tested-by linker" section.
Tests cover path classification, candidate generation, full link_tests
behaviour (forward direction, idempotence, LLM-edge stripping,
test-to-test rejection), and the merge integration. 31 cases, stdlib
unittest, runnable with `python -m unittest test_merge_batch_graphs.py`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Move 6 non-English READMEs (zh-CN, zh-TW, ja-JP, ko-KR, es-ES, tr-TR)
into a new READMEs/ folder; the English README stays at the repo root
- Rewrite asset paths in the moved files: assets/* -> ../assets/*
- Rewrite the English link in each translated language switcher to
../README.md; sibling links stay the same name (now both in READMEs/)
- Update the English README's language switcher to point to
READMEs/README.xx.md for each translation
No version bump — file reorganization only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Hero CTA row: blurple "Join Discord" button alongside Get Started
- Hero pill below CTAs: "ENTERPRISE · lum@understand-anything.com →"
mirroring the badge style at the top of the hero
- Footer: add Discord and Contact (mailto) links to the link list
- READMEs (en, zh-CN, zh-TW, ja, ko, es, tr): replace the small
flat-square Discord badge with a prominent text link below the hero
image: "💬 Join the Discord community →" plus a translated tagline.
No version bump — content/copy only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps to 2.6.0.
- MobileLayout activates via useIsMobile at <768px with bottom-tab
navigation (Graph/Info/Files); panes stay mounted (visibility
toggle) to preserve ReactFlow dimensions and FileExplorer state.
- MobileDrawer holds persona, view mode, diff, node-type filters,
layers, and tool buttons (Filter/Export/Path/Theme/Help).
- Selecting a node auto-pivots to Info; CodeViewer is always
fullscreen on mobile; SearchBar collapses to a 🔍 toggle.
- Homepage Hero/Footer/Install responsive: drop nowrap on title and
tagline, stack title spans for editorial wrap, full-width CTAs at
<480px, narrow-width spacing refinements.
- Desktop dashboard: sidebar telescopes 260/300/360px, header gaps
tighten, Path button label collapses to icon at narrow widths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up on the Codex P2: now that `useNodes` is in the effect deps,
every node update during a step that already timed out re-enters the
poll, sets `tourFitPending=true`, runs RAF for 4s, hits the silent
fallback path, and clears the flag. Visually the "Locating tour
highlight…" overlay would flash on every reflow even though the user
has already given up waiting. Skip the pending flag once
`fallbackKeyRef` matches the current step — the retry still runs
silently so a late Stage 2 can still upgrade to the proper fit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codex review on PR #114: when the RAF poll window expires before
highlighted nodes have been measured, the timeout fallback was setting
`fittedKeyRef.current = targetKey`, marking the step as fitted even
though the proper highlight fit never ran. If Stage 2 layout landed
after the 4s cap, the effect early-returned on the next nodes update
because the target key already matched, so the camera stayed pinned to
the fallback layer fit instead of zooming onto the actual highlights.
Fix:
- Subscribe to React Flow's user-node array via `useNodes()` so the
effect re-fires when Stage 2 finally produces the highlighted ids
after the per-step RAF poll has already given up.
- On timeout, pan into the layer for usability but do NOT set
`fittedKeyRef`. The next nodes update gets another shot at the
highlight fit, and on success `fittedKeyRef` records the proper fit.
- Use a separate `fallbackKeyRef` to ensure the fallback `fitView`
fires at most once per step — without this, every subsequent nodes
update during the unready window would trigger a viewport jump.
- Reset both refs when `tourHighlightedNodeIds` clears (stop tour).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codex review on PR #114 flagged that `layerResetIfChanged` cleared
`containerLayoutCache` and `expandedContainers` but left
`pendingFocusContainer` intact. Because container ids collide across
layers (the very reason the cache reset exists), a manual expand in
layer A that hadn't yet hit its 1.2s clear timer could leak its id
into layer B's namespace and recenter the viewport on an unrelated
container right after navigation.
The same hazard applies to every other reset path that drops the
container caches. Add `pendingFocusContainer: null` to all of them:
- layerResetIfChanged (tour cross-layer reset, the originally flagged
site)
- drillIntoLayer
- navigateToOverview
- setFocusNode
- setPersona
- setGraph
- toggleNodeTypeFilter
- clearContainerLayouts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four related issues that surfaced while walking the Learn-mode tour
through a multi-layer project (microservices-demo):
1. Tour auto-expand never released. The tour effect that expands
highlighted nodes' containers had no corresponding collapse when the
step changed, so containers accumulated open as the user advanced.
Track the set of containers we expanded and release any not needed
by the current step; user-toggled containers are never tracked here,
so they're never auto-collapsed.
2. Manual container toggle yanked off-screen. Stage 2 reflow shifted
the just-clicked container away from the cursor. `toggleContainer`
now records `pendingFocusContainer` on expand; GraphView locks the
viewport onto that container's centre with the current zoom so it
appears to expand in place.
3. Tour fitView fired before highlighted children existed. A single
RAF after `tourHighlightedNodeIds` change wasn't enough — child
nodes only appear once Stage 2 layout writes
`containerLayoutCache`, and React Flow only knows their absolute
position after a measure pass. `useNodes()` doesn't fire on
measure completion, so we poll `getInternalNode().measured` each
frame (up to ~4s) and call `fitView({ nodes })` once every
highlight is measured, with `maxZoom: 1.2 / minZoom: 0.4`. While
waiting, a new `tourFitPending` flag drives a "Locating tour
highlight…" overlay so the user knows the layout is still settling.
4. Cross-layer tour transitions reused stale Stage 2 cache. Container
ids derive from per-layer state (folder names in folder strategy,
`container:cluster-N` in community strategy) and collide across
layers — API Contracts and Load Testing both produce
`container:cluster-0`. `setTourStep` / `nextTourStep` /
`prevTourStep` / `startTour` didn't reset the container caches the
way `drillIntoLayer` does, so when tour crossed a layer the new
layer's expanded containers hit the previous layer's cache, Stage 2
skipped its rerun, and children never showed. Extracted
`layerResetIfChanged` and applied it in all four tour actions.
Verified end-to-end against microservices-demo via headless Chrome:
all 15 tour steps now expand the right container(s), zoom onto the
referenced files, and collapse the previous step's auto-expansions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewer @Lum1104 (PR #112) caught a silent semantic regression: the new
`filterNodes` reads layer membership through `nodeIdToLayerId.get(node.id)`,
which is first-wins. The pre-#112 path was any-layer-wins —
`layers.some(layer => filters.layerIds.has(layer.id) && layer.nodeIds.includes(node.id))`.
For a node X listed in both L1 and L2 with only L2 selected, the old code
kept X; the new code dropped it. The schema permits multi-layer membership,
so this was a behavior change, not a bug fix.
Fix: keep two distinct indexes in the store. Both are rebuilt once on
`setGraph`, so the O(1)-per-node performance win from #112 is preserved.
- `nodeIdToLayerId: Map<string, string>` — first-matching-layer wins.
Drives navigation (drillIntoLayer / tour step → layer / sidebar
history) where one canonical layer is the right answer. Unchanged.
- `nodeIdToLayerIds: Map<string, Set<string>>` — every layer the node
belongs to. Drives `filterNodes` membership checks. Restores
any-layer-wins exactly.
`filterNodes` now iterates the (small) layer-id set per node looking for
intersection with `filters.layerIds`. ExportMenu reads
`nodeIdToLayerIds` from the store.
Verified locally:
- Added `filters.test.ts` regression: node in (L1, L2) with only L2
selected must pass. Failed against the first-wins implementation;
passes now.
- `pnpm --filter @understand-anything/dashboard test` — 42 / 42 pass
(was 41; +1 multi-layer regression test; perf-guard at 100 layers ×
100 nodes still <50 ms).
- `pnpm --filter @understand-anything/dashboard exec tsc --noEmit` — clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three hot paths in the dashboard ran `layer.nodeIds.includes(node.id)`,
which is O(K) per check. Combined with their enclosing loops they
collectively spent quadratic time per render of the overview / per
filter recompute / per node-selection event. On the 4.8 MB knowledge
graph reported in #102, the overview render alone took ~470 ms of
synchronous main-thread work before ELK / React Flow ran — long
enough for the page to register as unresponsive.
Fix: precompute two indexes once when a graph is loaded.
- `nodesById: Map<string, GraphNode>`
- `nodeIdToLayerId: Map<string, string>` (first layer wins, matching
prior `findNodeLayer` semantics)
Both live in `useDashboardStore` and are rebuilt by `setGraph`. The
three call sites:
1. `useOverviewGraph` (GraphView.tsx) — per-layer complexity aggregation
moved into a new `computeLayerStats(layer, nodesById)` helper that
walks `layer.nodeIds` instead of filtering all `graph.nodes`. Search
match counts now read straight from `nodeIdToLayerId` instead of
rebuilding a layer index on every searchResults change.
2. `filterNodes` (utils/filters.ts) — takes `nodeIdToLayerId` instead of
`Layer[]`; the layer-membership check is one Map.get() per node.
Updated `ExportMenu.tsx` caller to pass the store-level index.
3. `findNodeLayer` (store.ts) — replaced with `nodeIdToLayerId.get()` at
the four call sites. `navigateTourToLayer` helper updated to take the
index rather than the whole graph.
Behavior is preserved exactly:
- "First layer wins" semantics for nodes that appear in multiple
layers (#102 schema doesn't forbid this).
- 30 % aggregate-complexity threshold pinned by tests.
- Layer filter that excludes layer-less orphans, but ungated when
no layers are selected.
Verified locally:
Bench (`scripts/benchmark-aggregations.mjs`, node 22):
100 layers × 200 nodes (#102 shape): 475 ms → 2 ms (232× faster)
50 layers × 200 nodes: 116 ms → 0.6 ms (190× faster)
30 layers × 100 nodes: 12 ms → 0.2 ms (63× faster)
Tests: `pnpm --filter @understand-anything/dashboard test`
24 → 41 pass (+17 new tests across `layerStats.test.ts` and
`filters.test.ts`, including a #102 perf-regression guard at
100 layers × 100 nodes < 50 ms).
`pnpm --filter @understand-anything/core test` — 654 / 654 pass.
`pnpm --filter @understand-anything/dashboard exec tsc -b` — clean.
`pnpm --filter @understand-anything/dashboard build` — clean.
Pre-existing on master and not from this branch: `pnpm lint` errors
out with "eslint: command not found" — `eslint` isn't installed by any
package and the root `lint` script is bare `eslint .`. Out of scope here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P1: Anchor edges at the collapsed-side container atom when only one
side of an aggregated edge is expanded. The previous pass always
emitted file→file endpoints, but a collapsed container's children
aren't rendered as nodes — React Flow silently dropped those edges.
Now the collapsed endpoint stays as the container id, and the
expanded endpoint becomes the real file id; multiple files
collapsing onto the same (container → file) pair are deduped.
P2: Pending-fit ref + post-render trigger replaces the fixed 50ms
timer for layer-transition fitView. ELK takes ~125ms+ on medium
layers, so the timer was firing before positions arrived and the
viewport stayed on the old layer. Now we mark a pending fit on
navigation change and execute it once `nodes` populate, then
requestAnimationFrame so React Flow has placed the nodes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stage 1 stashes intra-folder file→file edges on `topo.intraContainer`
but `expandedEdges` only walked `topo.edges` (aggregated cross-
container only). When a user expanded a container the children
appeared but their internal wiring was invisible, even though the
NodeInfo sidebar listed the connections.
Now after the inflated cross-container pass, also push every
intra-container edge whose owning container is expanded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
setFocusNode was missed in the C1 cache-reset pass. Focus mode narrows
filteredGraphNodes to focus + 1-hop neighbors, so a container that
survives still has a subset of its children — the cache must drop
or it'll keep returning positions for filtered-out ids.
Mark applyDagreLayout @deprecated. The structural views all use ELK
now; the helper is retained for one release as a quick fallback path
and removed in the next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
C1 (cache leak across topology changes):
Container ids derive from folder names (`container:auth`, etc.) and
collide across layers, personas, and node-type filters. Without a
reset, navigating from Layer A to Layer B left Stage 2's cache and
size memory keyed by Layer A children, so Stage 2 short-circuited
and Layer B's children silently disappeared.
Reset containerLayoutCache, containerSizeMemory, and
expandedContainers in:
- drillIntoLayer (most visible — layer drill)
- navigateToOverview (drilling out)
- toggleNodeTypeFilter (filter change shifts container.nodeIds)
- setPersona (persona filters node types)
setGraph already resets all three on full graph reload — unchanged.
C2 (bundle regression):
Add elkjs and graphology rules to vite manualChunks. The main index
chunk drops from 525KB gzipped back to 62KB, with ELK split into a
parallel-loadable 439KB chunk. Restores baseline first-paint cost.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 14 stale TODO:
- Delete the TODO(Task 14) comment that this very task already
resolved (selection→container neighbor highlighting now flows
through isFocusedViaChild).
Task 15 visible container size:
- mergeElkPositions now propagates width/height from the ELK output
back onto the React Flow node, not just position. After a
stage1Tick-driven re-layout, the container atom resizes to match
the actual Stage 2 footprint instead of staying clipped at the
pre-expansion estimate. ELK echoes back the same width/height we
pass in for non-container nodes, so this is a no-op for them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 13 onMove auto-expand:
- Skip when event === null so React Flow's programmatic moves
(fitView at layer entry, zoomTo from search) don't cascade-expand
every container the moment the layer paints.
- Track previous zoom; only fire when zoom actually increased. Pans
and zoom-outs are now no-ops, so a user who manually collapses a
container while zoomed in can keep it that way.
Task 12 inflated edge ids:
- Append index suffix so parallel edges with identical
(source, target, type) — which the schema doesn't disallow — don't
collide in React Flow's edge map.
Other Task 12 concerns (per-container Stage 2 cancellation, O(K·E)
edge bucket pre-pass, extent:"parent" clipping pre-Task-15) are
known trade-offs documented in the plan; deferring to follow-ups.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Stable handleContainerToggle via useCallback so ContainerNode's
memo() actually short-circuits. Reading toggleContainer through
getState() keeps Stage 1 from accidentally subscribing to
expandedContainers.
- Cap pre-expansion container size at 800x600 so layers with many
files don't render as huge empty boxes during Stage 1. Stage 2
measurement and Task 15's reflow handle the real size.
- Update stale "dagre relayout" comment now that ELK is the layout
engine; flag the Task 14 follow-up for selection→container
neighbor highlighting via inline TODO.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cross-cutting fixes for the dagre→ELK migration pattern:
- nodesToElkInput accepts an optional layoutOptionsOverride so views
can override defaults (e.g. direction) without forking the helper.
- DomainGraphView restores its original LR layout by passing
{ "elk.direction": "RIGHT" } — the previous commit silently shifted
it to TB because the helper hardcoded DOWN.
- Both overview and DomainGraphView now .catch the ELK promise so
strict-mode (DEV) failures don't surface as unhandled rejections.
- Both also console.warn returned issues until Task 16 wires the
WarningBanner funnel; auto-corrected/dropped issues no longer
silently disappear in production.
- Minor: DRY the duplicate `as unknown as Node[]` cast in GraphView.
Ranksep label-aware spacing in DomainGraphView is intentionally
deferred — see Task 14 polish or follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>