diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4012b8..57be9f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: # PowerShell installer, which also runs on the platform it targets. - name: OMP extension test if: runner.os == 'Linux' - run: mise exec -c "bun test plugins/semctx/adapters/omp/index.test.ts" + run: mise exec -c "bun test plugins/semctx/adapters/omp" - name: Bootstrap installer test if: runner.os == 'Linux' run: python3 tests/bootstrap_installers.py diff --git a/README.md b/README.md index 3d179bf..6db5d06 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ `semctl` is the command-line client for the **semctx** code-intelligence server — semantic + exact code search and a precise symbol/call/value-flow graph over your repositories, exposed both as a CLI and as an [MCP](https://modelcontextprotocol.io) -stdio server for AI coding tools (Claude Code, Codex). +stdio server for AI coding tools (Claude Code, Codex, Oh My Pi). It's a thin HTTP+JSON client: all indexing and retrieval happen server-side, so `semctl` links no engine code and has an entirely public dependency graph. @@ -118,6 +118,184 @@ reversed with the hash-guarded `undo_edit` action. Read-only additions include reference/search/outline tools expose the richer occurrence, scope, and nesting options instead of gaining aliases. +`current_context` reports the selected checkout's opaque source identity and +lists the server's indexed copies with their exact identities, canonical flag, +revision and file count. A copy matches only when its source identity equals +the checkout's. Historical copies remain intact. The codebase's reported graph +state does not establish per-copy freshness. + +MCP results use ranked snippets, attributed batch tables, language-tagged source, +and phase-first sync snapshots. Source bytes, provenance, and full diagnostics +remain in the original result. Hybrid scores are query-relative, not confidence. +Search source binding requires a local byte snapshot to match the ready +content-plane full-file hash, plus exact snippet bytes and supplied byte/line +coordinates in that snapshot. It does not verify semantic symbol/focus identity +or guarantee that the file stays unchanged. Changed bytes, mismatched ranges, +and unavailable verification stay distinct. Use local Read/Grep for current +locations when binding is not established. Verification does not start a reindex. + +The semctl OMP adapter presents search, reference, source, and graph results in +native tool cards. Compact results show the first six matches. +The native tool header opens the complete result. +A muted badge in the native tool header shows the codebase identity. +The badge text is the display name. Canonical copies add `· canonical`. +The badge tooltip shows the codebase ID, checkout path, and copy. +A search with an index freshness warning adds an `index warning` badge to the +header. The badge tooltip keeps the original diagnostic. +Result bodies start with the match chips. The result-graph toggle trails the +chips on their last row. Match chips +use Read-style icons, path links, spacing, and independent previews. Scored +matches use small native relevance bars; unscored matches reserve no bar space. +Relevance rails sit 6 pixels from the location text. +They use a rounded track and an accent fill that eases when values change. +Native rails and MCP text bars use the same query-relative scale, not confidence. +A result set with only nonnegative scores uses the ratio to its highest score. +A result set with any negative score uses the range from its lowest to highest +score, with a 10% floor for the lowest score. +Range-bar tooltips describe position between the lowest and highest result +scores, not confidence. +When a result set has equal signed scores, a missing score, a non-finite score, +or no usable ratio, its scored matches keep a neutral native rail and `—` in the +MCP text table. +Match chips fade and rise in with a short stagger on first appearance. +Reduced-motion mode disables this result-card effect. +Hover shows rank, score, or the verification diagnostic. A fixed-size composer +indicator sits above the native composer controls. It shows one native progress +circle for producer-backed sync and embedding counts. Unknown active work uses +subtle motion that respects reduced-motion mode. Select the indicator for status, +or use `/semctl` and `/semctl details`. +`/semctl graph` opens a native OMP/Tern popup card. The default overview +includes indexed files, directories, projects, imports, and resolved symbol +bindings. Native dots support individual hover and selection. Group and node +controls expose supplied relationships and metadata without a model turn. +The overview title has the checkout name below it. `Partial graph` and +`Relationship layers loading` are native badges. The line under the map shows +group and route counts. A right-aligned hint tells you to select a dot. +Graph drawings sit in a soft canvas well and are centered in it. +Uncolored dots use a soft neutral tone. Every dot has a thin ring in the canvas +color that separates it from links. A hovered dot grows slightly. +Links are thin, low-contrast lines. +Containment and project-association routes stay faint until selected or hovered. +In the compact popup, arrowheads appear only on links of the selected or +hovered node. +The compact toolbar uses native icons. Grouped analysis tabs and diagnostics +share a stable viewing area. Switching views preserves the selected node. +Shared graph colors identify the same source consistently across graph views. +Validated concepts also color match chips. Source-directory colors fill gaps +without claiming semantic similarity. +Concept groups use Markdown documentation from the local checkout. +Document titles name the groups. Explicit file paths and distinctive identifiers +connect documentation to catalog files. Reference and folder evidence can extend +an assignment. Broad index documents and ambiguous mentions do not seed concepts. +The map keeps source-directory groups where the evidence is insufficient. +Group details name the source document. Group labels distinguish projects with +the same directory names. +This step reads local files. It makes no embedding requests or model calls. +The client cannot retrieve the server's stored embedding vectors. + +Concept map is the default layout for new profiles. +It combines documentation concepts, lexical term vectors, and recorded links. +Related concept families form soft islands when the drawing has enough space. +Force also uses shared terms and family cohesion. +Term frequency and inverse document frequency weight paths, labels, and resolved +symbol names. Classical multidimensional scaling supplies stable coordinates. +The vectors are not the server's semantic embeddings. +Position does not prove a relationship or indicate confidence. +Input order does not change geometry. +Activity, selection, indexing state, scores, and theme do not move nodes. +Concept map and Force can draw soft tinted territories. +Document folders name concept families. Without these families, shared terms +can name lexical families. A territory is omitted if it would cover an unrelated +node or most of the canvas. Compact popups hide territory labels. +Radial, Layers, and Components draw no territories. +Overview group dots scale with member count. +Aggregated group routes use four weight tiers. +Each higher tier makes a route thicker and more opaque. +In Radial, isolated nodes sit on one outer ring around the largest map when that +map has at least three nodes and every other component is smaller. +That ring does not show a distance. An isolated node has no recorded relation. +Ungrouped result graphs keep their previous dot sizes. +Nodes keep their identity across layout changes and refreshes. +They glide to new positions when geometry changes. +Routes and territories fade in after nodes settle. +Dots arrive with a short staggered scale-in. +Labels fade in after their dot. +Reduced-motion mode and Motion Off disable these graph effects. +Tern owns transitions. The graph does not use SVG images or a web viewer. +`/semctl graph live` toggles a native live overview popup. The popup shows a +compact project map, observed request counts, and producer-backed progress. +The popup title is `Project map`. A subline shows `N files · M groups`. +A warning icon after the subline marks a partial map. +A slim stacked indexing bar shows `N% ready`. +This value is the ready share of the files that report a catalog state. +The bar tooltip shows the exact counts. +The map sits in the canvas well. +With no selection, a one-line legend explains dot size (files) and line weight +(links). It identifies concept families or shared terms only when territories +are drawn. The `Busiest` chips select individual groups with many cross-group +links. The folded remainder does not compete with individual groups. +Small, medium, and large popups show up to three, four, and five chips. +Select the grip at the upper left to enlarge the popup. +Shift+click makes it smaller. Both controls wrap through the three sizes. +Double-click the title to switch between small and large. +Tern does not expose continuous drag-resize events. +The popup size is saved per OMP profile. +Group rows use small stacked indexing bars. +The popup footer uses one row for a fixed progress ring, a percentage, a separate +warning badge, and request counters. +The counters show `↑` for sent requests and `↓` for returned requests. +Percentage digits animate when motion is allowed. +Phase changes stay in tooltips. +The warning badge appears only for a current problem. +It sits beside the ring in its own space. +The live popup and graph page show a native spinner and a shimmer label while +the first map loads. Refresh buttons show a native spinner during a refresh. +The graph page footer uses a native spinner while loading or working. +Reduced-motion mode and Motion Off use static icons, labels, and percentage digits. +Available structure stays visible while embeddings or relationship layers are +incomplete. A small status pip at the lower right of a dot shows the catalog +embedding state. A failed pip uses the error color. A processing pip uses the +accent color. A pending pip is hollow. Status pips use catalog evidence; +aggregate progress does not invent per-file activity. +On an overview group dot, the failed pip gets stronger as the failed share of +members grows: below 25%, from 25% to 60%, and above 60%. +The group dot tooltip states the failed share. +A failed refresh retains the last available map. +The popup's `Diagnostics` disclosure retains the full request error. +Compact hover text does not replace the diagnostic. +Overview group rows show a compact native stacked meter for catalog indexing +states: ready, processing, pending, failed, and unknown. +Exact counts stay in the tooltip. +Hosts without meters retain the text summary. +Typing, refreshing, and closing preserve the composer draft and focus. +The popup uses the extension APIs in stock OMP. No OMP patch is required. +The appearance icon opens compact native controls for layout, colors, detail, +labels, link contrast, motion, and popup size. +Concept map, Force, Radial, Layers, and Components provide different views. +Changes update the live popup and project page immediately. +Color choices apply to all graph views. +Preferences are saved per OMP profile. Existing layout choices remain unchanged. +System reduced motion always takes priority. +Graph-based feed results expose compact diagrams inside the native disclosure. +Match lists offer a graph icon that groups supplied matches by file and codebase. +Result chips wrap into compact rows and retain file directories. +Linked filenames open the checkout file. Tooltips use compact identity, score, +and state summaries; existing details retain the complete evidence. +Expanded snippets highlight reported match locations without asserting that +the current checkout matches the indexed source. +The adapter reads bounded, typed `_meta.semctlDisplay` +metadata. It does not parse transcript Markdown or change model-facing content. +The original MCP transcript, errors, approvals, and cancellation remain intact. +The extension adapts the native presentation hooks on OMP's existing MCP tools. +Tern supplies native elements and surface-scoped styles. Plain terminals retain +the stock MCP output and compact text status. +A cached overview supplies shared concept colors. +Result-card presentation makes no extra source fetches. +Overview concept discovery reads bounded Markdown files from the local checkout. +It makes no extra network searches or embedding calls. +The adapter does not automatically combine calls or establish token savings. +See the [plugin documentation](plugins/semctx/README.md) for limits. + Every read-only MCP tool call ends within one deadline. The default is 25 seconds, below the 30 seconds after which Oh My Pi ends an MCP request. Set `SEMCTX_TOOL_DEADLINE_SECS` to change it. A value from 5 to 600 applies as @@ -132,11 +310,16 @@ changed would report a failed edit that was applied. The `index_codebase` tool does not wait for the first index. It registers the codebase, starts the scan and upload in the background, and returns at once with -the codebase id and the path. The `sync_status` tool then reports the first-index -phase: registering, syncing, embedding, ready, or failed. While a sync runs, it -also reports a `current sync:` line with the latest step, such as the number of -uploaded files. `semctl daemon status` reports the same phase and step for each -checkout. +the codebase id and the path. The `sync_status` tool reports the first-index +phase: registering, syncing, embedding, ready, or failed. It reports the latest +local scan/upload step when available. `semctl daemon status` exposes the same +local phase and step for each checkout. Do not depend on a fixed line layout. +Status separates current local work, persisted catalog totals, and the last +recorded server job. Missing server counters are unknown; named reported counts +are not a processed/total completion ratio. A completed or zero-file job does not +establish checkout freshness. Inspect and correct a first-index failure before +calling `index_codebase` for the same path to retry. Active local checkouts use +their watcher and periodic reconciliation. ### Coming from the old `semctx` CLI @@ -347,7 +530,9 @@ configured semctx server. When trusted Claude/Codex hooks or the native Oh My Pi extension are enabled, retrieval-shaped user prompts are also sent to `semctl hook` for a bounded, best-effort candidate search. The OMP extension runs in-process but delegates retrieval, authentication, and nudge state to the -`semctl` subprocess; it does not make network requests itself. Set +`semctl` subprocess. Graph views are native OMP/Tern components, with no graph +HTTP listener, browser launcher, or viewer asset route. The subprocess owns +requests to the configured server. Set `SEMCTX_HOOK_DISABLE=1` to disable all hook behavior or `SEMCTX_NUDGE_DISABLE=1` to disable only shell-search reminders. @@ -385,11 +570,13 @@ is installed. It skips a missing shell and reports the skip. CI runs `sh` on Linux and `pwsh` on Windows. Use `--shell all` to require both shells. `plugins/semctx/` is the shared plugin root for every supported coding agent. -Its `skills/codebase-retrieval/` has one physical source. Claude/Codex consume -the shared hook manifest; OMP loads `adapters/omp/index.ts`, which maps native +Its `.mcp.json` declares the MCP server once: Claude/Codex reference it, and OMP +discovers it in linked and npm-installed packages. Its +`skills/codebase-retrieval/` has one physical source. Claude/Codex consume the +shared hook manifest; OMP loads `adapters/omp/index.ts`, which maps native lifecycle events onto the same `semctl hook` wire contract. See -[`plugins/semctx/README.md`](plugins/semctx/README.md) before adding another -agent integration. Do not copy or symlink shared skills. +[`plugins/semctx/README.md`](plugins/semctx/README.md) for native MCP presentation +and host ownership. Do not copy or symlink shared skills. The deterministic hook and OMP-extension cases run under the normal Rust and Bun test commands. diff --git a/plugins/semctx/.claude-plugin/plugin.json b/plugins/semctx/.claude-plugin/plugin.json index 43e15ab..e6eafa8 100644 --- a/plugins/semctx/.claude-plugin/plugin.json +++ b/plugins/semctx/.claude-plugin/plugin.json @@ -10,10 +10,5 @@ "license": "MIT OR Apache-2.0", "keywords": ["search", "code-search", "rust", "dotnet", "mcp", "tree-sitter", "embeddings"], "skills": "./skills/", - "mcpServers": { - "semctx": { - "command": "semctl", - "args": ["mcp"] - } - } + "mcpServers": "./.mcp.json" } diff --git a/plugins/semctx/.codex-plugin/plugin.json b/plugins/semctx/.codex-plugin/plugin.json index ec4b6de..2819579 100644 --- a/plugins/semctx/.codex-plugin/plugin.json +++ b/plugins/semctx/.codex-plugin/plugin.json @@ -9,12 +9,7 @@ "repository": "https://github.com/napbat/semctl", "license": "MIT OR Apache-2.0", "keywords": ["search", "code-search", "rust", "dotnet", "mcp", "tree-sitter", "embeddings"], - "mcpServers": { - "semctx": { - "command": "semctl", - "args": ["mcp"] - } - }, + "mcpServers": "./.mcp.json", "skills": "./skills/", "interface": { "displayName": "semctx", diff --git a/plugins/semctx/.mcp.json b/plugins/semctx/.mcp.json new file mode 100644 index 0000000..246a600 --- /dev/null +++ b/plugins/semctx/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "semctx": { + "type": "stdio", + "command": "semctl", + "args": ["mcp"] + } + } +} diff --git a/plugins/semctx/README.md b/plugins/semctx/README.md index 95dbe3f..0c2d56a 100644 --- a/plugins/semctx/README.md +++ b/plugins/semctx/README.md @@ -4,6 +4,8 @@ This directory is the shared plugin package. Agent-neutral components live once at their conventional root locations: - `skills/` contains reusable agent skills. +- `.mcp.json` declares the shared `semctl mcp` server. Claude/Codex manifests + reference it; OMP discovers it for linked and npm-installed packages. - `hooks/hooks.json` contains the lifecycle-hook subset supported by every current host. @@ -28,6 +30,595 @@ repository content searches, so they map to `Grep` like `grep` does. A search of OMP internal URLs (`omp://`, `local://`, `skill://`) or web URLs is not repository discovery, so the adapter does not send it to `semctl hook`. + +The OMP adapter reads the live tool registry to distinguish a package server +(`mcp__semctx_*`) from a marketplace server (`mcp__semctx_semctx_*`). Rust owns +the corresponding guidance. When neither is mounted, hooks suppress MCP advice +but retain clear/compact resets, so guidance can recover after MCP reconnects. + +Tool presentation comes from the shared MCP server. Search results lead with a +match count, numbered ranks and raw hybrid scores. Scores are query-relative, +not confidence; weaker matches retain their separate heading. `batch_lookup` +uses one table in caller order, keeping unmatched symbols and every location. +Table cells escape untrusted delimiters. + +Source and outline bodies use language-tagged fences. Filename suffixes are +display hints, not claims about semantic indexing support; unknown grammars +remain readable as plain text. Mermaid and SVG source stays literal code rather +than becoming a diagram or image. `read_source` keeps the selected range and +source origin before the body, with its full revision and encoding afterward. +Source text retains its original bytes, including CRLF and embedded fences. + +`sync_status` leads with the current failure or phase. It separates persisted +catalog state, current local work, and the last recorded server job. Named +embedded/deleted/failed and planned counts are not a processed/total ratio. +Missing counters remain unknown. A completed or zero-file job does not prove +checkout freshness. Job labels use Done, Running, Pending, and Failed. Complete +native diagnostics remain in the original result. Status is a snapshot, not live +push progress or an inferred server percentage or elapsed-time measurement. +Correct the reported cause before retrying a failed first index. Local failures +retain the reported retry schedule when one exists. + +Search source binding compares a bounded local byte snapshot with the ready +content-plane full-file hash. Bound hits also require exact snippet bytes and +agreement with supplied byte/line coordinates in that snapshot. Binding does not +verify semantic symbol/focus identity or guarantee that bytes stay unchanged. +Changed local bytes, mismatched ranges, and unavailable verification remain +distinct, with native diagnostics preserved. Use host Read/Grep for current +locations when binding is not established. Verification does not start a reindex; +active local checkouts use their watcher and periodic reconciliation. +`current_context` lists exact indexed-copy identities, canonical status, and the +matching checkout. Codebase graph state is not a claim about each copy's +freshness. Tool names and argument schemas stay stable. Failed operations set +`isError` and retain their explanation. + +### OMP native inspector + +The adapter renders search, source, symbol lookup, reference, graph-analysis, +and sync-status results inside their original OMP tool cards. Calls and results +stay in the normal transcript, including calls routed through `xd://`. There is +no above-editor result widget, second history, or replacement MCP tool. Each +card owns its expansion state; concurrent calls cannot replace its results. + +The extension uses OMP's exported MCP manager to adapt native presentation hooks +on the existing semctx tools. It leaves ANSI renderers and execution unchanged. +Exact raw server and operation identities select the renderer. Tool refreshes +receive the same presentation. Retained cards keep their state after reconnects. +OMP still owns schemas, approvals, cancellation, status glyphs, elapsed time, +and full-result expansion. The original MCP text and result details are unchanged. +This integration does not require an OMP source patch or a custom executable. + +Plain-text search results put the exact query above a compact ranked table, +without per-match Markdown headings or a separate weak-match section. Source +previews, raw scores, copy/domain identity, and verification diagnostics remain +below the table. Text score bars use the same query-relative scale as native +relevance rails. A result set with only nonnegative scores uses the ratio to its +highest score. A result set with any negative score uses the range from its +lowest to highest score, with a 10% floor for the lowest score. +Equal scores in a signed result set, missing scores, and non-finite scores keep +an unknown scale. The MCP text table shows `—` when the scale is unavailable. + +Replacing the MCP executable does not reload an already-running OMP extension. +Use `/restart` in an idle session to load the updated adapter. Session-history +reload, `/reload-plugins`, and `/mcp reload` do not re-evaluate extension modules. +Finish or preserve active work before restarting. Verify the loaded backend +and live surface, not just configured paths. + +The renderer consumes versioned, typed `_meta.semctlDisplay` data, not parsed +Markdown or model-facing `structuredContent`. Each payload is limited to 32 KiB. +Match lists allow at most 32 total hits per call. Overflow omits their payload +rather than silently showing a partial result. Graph previews have separate, +explicit limits described below. Search metadata includes the nullable +`indexWarning`. A warning badge with the text `index warning` in the native tool +header shows it. The badge tooltip keeps the original freshness diagnostic. +A missing or malformed warning field falls back +to the complete native result, so older producer metadata cannot hide a warning. +Missing/invalid metadata, wrong origins, errors, partial results, and +cancellation also retain native rendering. + +The exact query appears in the native `Code search` header. Each match uses a +native file chip with a file icon, linked filename, line range, and verification +state. Rounded chips wrap across rows, with the same native small gap as Read +results. Chip labels retain the complete returned path, including directories. +Only the available row width can truncate a path. Hover or open Details to read +the full location. Filenames use native link styling when a safe checkout link is available. +Hover and expanded previews retain the symbol and +reference context. An open preview occupies its own row. +The native tool-header disclosure opens the entire result. +A muted badge in the native tool header shows the codebase identity. +The badge text is the display name. Canonical copies add `· canonical`. +The badge tooltip shows the codebase ID, checkout path, and copy. +The result body starts with the match chips. +No second result-list disclosure appears between chips. +Hosts without the required native primitives use the original result body. +ANSI and noninteractive output retain the original renderer. + +All match chips start folded. Select a chip to open or close its source preview +in place. Multiple chips can stay open for comparison. Each open chip has a +`Details` disclosure for its full location, qualified symbol, reference +context, and codebase identity. +Match chips fade and rise in with a short stagger on first appearance. +Reduced-motion mode disables this arrival effect. + +Every scored match has a small relevance bar. The adapter uses native meters. +Text bars appear only when meters are unavailable. +Match chips, graph controls, and selected sources use the same +40 by 4 pixel rail. Relevance rails sit 6 pixels from the location text. +They use a rounded track and an accent fill. +The fill eases when its value changes. +Reduced-motion mode disables this transition. +Hover the bar to see rank and score. Expanded results do not repeat +scores. A result set with only nonnegative scores uses the ratio to its highest +score. A result set with any negative score, such as reranker logits, uses each +score's position between the lowest and highest result scores. +The lowest score receives a 10% floor. +Neither scale indicates confidence. +Range-bar tooltips say +“Bar: position between lowest and highest result scores · not confidence”. +When a result set has equal signed scores, a missing score, or a non-finite +score, its scored matches keep a solid neutral rail, not a dotted or animated +progress indicator. An unavailable ratio also keeps a neutral rail. +Matches without scores have no bar or reserved score space. +The tooltip retains the raw score when available and explains a missing scale. + +Verification icons distinguish bound, changed-local, range-mismatch, and +unverified/not-probed states. Hover and accessible labels explain the state and +the available diagnostic. The reason appears before the verification limits. +Open chips do not repeat verification prose. Binding checks source bytes and +coordinates; it does not verify semantic symbol or focus identity. +A compact glyph replaces the icon when renderer icons are unavailable. +Definition, reference, caller, implementation, symbol-position, source-expansion, +and batch lookups use the same compact chips. Reference context remains +available without invented relevance scores. Batch results retain caller order +and misses. +Lookup source locations are not probed. +Filename links navigate to the current checkout file. No duplicate file action +appears below the chip. Links require matching, nonempty codebase identities and +a relative path inside the selected checkout. They do not use indexed line +fragments. Non-bound links explain that the indexed location remains unverified. +Source numbering uses supplied valid coordinates. Native code blocks highlight +the reported focus line, or the supplied match range when no focus is reported. +Highlights stay inside the returned snippet and do not verify the current +checkout. Invalid or missing snippet coordinates produce no highlight. + +Embedded source previews use bounded line windows and state when more lines +exist. Full tool-header expansion shows the complete returned source for +`read_source` and opened match chips. Compact results show the first six +matches. The native tool header reveals every returned match. Folding restores +the compact preview. Reopening restores each chip's source selection. +Result-card presentation makes no additional source requests. +It does not combine calls automatically or claim token savings. + +Nested Eval calls currently retain OMP's native trace presentation, not these +custom result cards. Eval stores summaries rather than canonical child result +metadata. A separate follow-up needs a bounded, per-run presentation contract at +`eval/js/context-manager.ts` / `tool-bridge.ts`, with explicit persistence for +restored cards, errors and cancellation. Do not infer parentage from whichever +Eval is active, parse scripts, or restore an above-editor result widget. Direct +`batch_lookup` and parallel MCP calls already use the normal-feed renderers. + +### OMP graph overview + +Use `/semctl graph`, or select `Project graph` in the status view. The overview, +analysis selector, relationship details, and source controls are native OMP/Tern +UI. There is no graph HTTP listener, browser launcher, or viewer asset route. + +The default indexed overview includes catalog files, directories, detected +projects, reported workspace membership, resolved file imports, and resolved +symbol bindings. Isolated files remain visible. This is the indexed structure, +not a guarantee that local source bytes or every runtime dependency are current. + +The graph uses native dots, labels, and link segments. Each dot has its own +hover tooltip and selection target. Select a group to inspect its members. +Select a node to focus its neighbors and supplied source preview. Crossing a +link updates the group context. All groups and nodes remain reachable when +the overview combines smaller groups. Relationship details retain cycles, +shared targets, parallel links, and self-links. + +The overview title has the checkout name below it. `Partial graph` and +`Relationship layers loading` are native badges. Their tooltips explain the +limit. The line under the map shows the group and route counts. A right-aligned +hint tells you to select a dot for details. + +Concept map is the default layout for profiles without a saved choice. +It combines documentation concepts, shared terms, and recorded links. +Concept families use folders of source documents, such as `Architecture`. +The layout separates families when space permits. +Force also uses lexical similarity and family cohesion. +Terms come from paths, labels, resolved symbol names, and concept headings. +Term frequency and inverse document frequency weight the terms. +Classical multidimensional scaling supplies stable coordinates. +These vectors are not the server's semantic embeddings. +The client has no endpoint for retrieving stored embedding vectors. +Position does not prove a relationship or indicate confidence. +Input order does not change geometry. +Activity, selection, indexing state, scores, and theme do not move nodes. + +Concept map and Force can draw soft tinted territories behind their members. +Document folders name concept families. When these families are absent, +shared identifier terms can name lexical families. +The compact live popup hides territory labels. +A territory is omitted if it would cover an unrelated node or most of the canvas. +Territory tints use the shared palette. They are not confidence indicators. +Radial, Layers, and Components do not draw territories. + +Overview group dots scale with member count. +Aggregated group routes use four weight tiers. +Each higher tier makes a route thicker and more opaque. +Ungrouped result graphs keep their previous dot sizes. + +Every graph drawing sits in a soft canvas well and is centered in it. +Uncolored dots use a soft neutral tone. Every dot has a thin ring in the canvas +color. The ring separates the dot from links. A hovered dot grows slightly. +Links are thin, low-contrast lines. In the compact live popup, arrowheads appear +only on links of the selected or hovered node. +Containment and project-association routes stay faint until selected or hovered. + +Documentation concepts take precedence over reference communities. +The adapter reads catalog Markdown files from the local checkout. +Document titles name concepts. Explicit paths and distinctive identifiers connect +the documents to code files. Supported reference and folder evidence can extend +an assignment. Broad index documents and ambiguous mentions do not seed concepts. +At least three code files must support a concept. +Source-directory groups remain where concept evidence is insufficient. +Duplicate structure labels include their project or path context. +Group rows show the source document. +Concept cues distinguish direct mentions, reference support, and folder support. +Assignments are map aids, not dependency declarations or semantic guarantees. +Documentation concepts remain available when embedding states are pending or +failed. Reference-only communities still require a complete overview. +The reader uses at most 256 catalog Markdown files, 1 MiB per file, and 16 MiB +in total. It skips unreadable files and paths outside the checkout. +It does not read files through links that leave the checkout. +This step makes no embedding requests, network searches, or model calls. +Tern owns arrival, movement, and hover transitions. +Nodes keep their identity across layout changes and refreshes. +They glide to new positions when geometry changes. +Routes and territories fade in after nodes settle. +Dots arrive with a short staggered scale-in. +Labels fade in after their dot. +Observed activity fades in and out without moving the graph. +Idle graphs have no animation loop. +Reduced-motion mode and Motion Off disable these effects without hiding +observations. + +The analysis selector also provides projects, calls and paths, symbol +neighborhoods, value-flow boundaries and witnesses, type hierarchies, cycles, +duplicate groups, unused definitions, file imports, symbol bindings, external +links, and file outlines. Lookup views use compact, independently expandable +matches with the same score bars and diagnostic tooltips as the feed. + +The compact toolbar opens analyses, appearance, refresh, diagnostics, and close. +Analyses use native tabs for Project, Symbols, Calls, Flow & types, Quality, +and Source. The map, chooser, and diagnostics share a stable viewing area. +Switching views keeps the selected node and source preview. +Use A for analyses, S for appearance, I for diagnostics, and R to refresh. +Escape returns from the chooser or diagnostics to the map. Escape on the map +closes the page. Raw CLI output and producer diagnostics stay in the details view. + +The live popup and graph page show a native spinner and a shimmer label while +the first map loads. Refresh buttons show a native spinner during a refresh. +The graph page footer uses a native spinner while loading or working. +Reduced-motion mode and Motion Off use static icons and labels instead. + +Graph-based feed results show a compact native dot graph directly. +Node and link counts remain in the tool header. Disconnected and single-node +results retain their native node controls. `Relationships` exposes the complete +supplied link list. Hover graph notes for explanatory limits. Partial graphs +remain visibly marked. Ordinary links are solid, imports are dashed, and +project associations are dotted. Curved links share one continuous pattern. +Import dashes stay at least 8 pixels long. Association marks stay at least +4 pixels long. Short links remain single connectors instead of dense fragments. +The saved layout choice also applies to these graphs. + +Search and lookup results keep their compact match chips. A separate graph icon +appears when at least two distinct supplied locations can be shown. +This includes references, callers, definitions, implementations, declaration +search, batch lookups, and expanded source context. +The icon trails the match chips on their last row. The graph groups returned +matches by file and codebase. These links show locations, not inferred calls, +dependencies, or embedding similarity. Graph selection and open match chips +survive collapse and reopen. Opening a source preview does not fetch more data. + +Graphs use Tern's native `el` elements and surface-scoped stylesheets. +The extension checks the terminal's advertised capabilities and sends its styles +through OMP's public terminal writer. Tern draws the elements and owns pointer +targets, tooltips, and transitions. No patched node or status API is required. +Tooltips use short headings and compact facts. +Most hovers have at most three lines of 88 characters each. +Progress and diagnostic summaries can use four lines. +Dot hovers show identity and state instead of every neighboring link. +Route hovers show up to two evidence entries and report omitted entries. +Group hovers show counts, catalog state, and the source document when available. +The existing node and group controls retain the complete supplied details. +Terminals without these capabilities keep native node navigation. +There are no SVG graph images, extension-driven animation frames, or web +viewer assets. The current pointer protocol supports selection, not free +dragging of graph nodes. + +Concept colors stay consistent across match chips, graph nodes, and the live +popup. Colors require a validated overview with the same codebase and exact +path. Source-directory colors fill unsupported assignments. +The adapter loads one cached overview after the first observed semctx call. +Manual refresh and changed producer job observations can refresh that catalog. +Requests share one in-flight lookup. Local document reads run after the first +map paint. Stale or cancelled reads do not replace the active concept snapshot. +Placement does not use the server's semantic embeddings. + +Ordinary MCP graph metadata retains at most 64 nodes, 128 edges, 32 source +previews, and 32 KiB. It never retains an edge without both endpoints. Large +previews can be omitted with a caveat. The explicit local indexed overview uses +the same graph shape with limits of 12,000 nodes, 48,000 edges, and 3 MiB. +Import and symbol-binding retrieval each have a 20,000-edge limit. Invalid +structure and responses above the display limits fail explicitly. Available +layers and valid pages remain visible when another layer is loading or a page +is incomplete. The overview marks that snapshot as partial and lists the +unavailable layers. The native navigator pages large node lists. + +Opening or refreshing an analysis executes `semctl graph --json ` in +the current directory. This read-only CLI mode returns `{text, display}`. +`graph overview` combines catalog, project, import, and resolved symbol-binding +queries. Pending or failed embeddings do not prevent it from showing available +structure. A typed `GraphLoading` or `FileLoading` response marks an unavailable +layer without waiting for that layer to finish. Authentication, transport, and +invalid-response errors still fail the request. The other analyses use the +existing dispatcher. The configured CLI server or tenant can differ from a +custom MCP configuration. + +Requests cancel on close, replacement, or checkout change. Errors and original +CLI text remain accessible in the native surface. Opening a node does not fetch +source. The overview does not start a model turn or indexing job. + +Observed semctx requests with a matching explicit codebase ID can highlight +exact node identifiers, symbols, or paths. Default and directory-scoped requests +can highlight targets after returned metadata establishes a matching codebase +for that MCP server and checkout. Other requests remain in the activity summary. +They cannot imply activity in a differently configured CLI. +The active highlight ends when the correlated request finishes. Embedding +activity is aggregate producer evidence, not a per-file animation: the +notification does not identify active files. +The catalog can separately report a file as pending, embedded, or failed. +A small status pip at the lower right of a dot shows that evidence without +replacing concept colors. A failed pip uses the error color. A processing pip +uses the accent color. A pending pip is hollow. +On an overview group dot, the failed pip gets stronger as the failed share of +members grows. The three steps are below 25%, from 25% to 60%, and above 60%. +The group dot tooltip states how many members failed. +Overview group rows use a compact native stacked meter for catalog indexing +states: ready, processing, pending, failed, and unknown. +Exact counts remain in the tooltip. +Hosts without meters retain the text summary. +Unknown values remain unknown; +missing legacy state remains unreported. Aggregate job progress never assigns +a state to individual files. +The native composer status opens the same status view when selected. The +`Project graph` action opens the overview without starting a model turn. + +#### Live overview popup + +Use `/semctl graph live` or select `Live popup` in the status view. +The command toggles a non-modal native popup. The composer stays usable. +Refresh and close preserve the current draft and keyboard focus. +Native icon buttons open the full overview, configure appearance, refresh the +map, and close the popup. +Each button has a hover tooltip and an accessible name. +The grip at the upper left changes the popup size. +Click to enlarge it. Shift+click to shrink it. +Both controls wrap through Small, Medium, and Large. +Double-click the title to switch between Small and Large. +The appearance card also provides a Popup size row. +Tern does not expose continuous drag-resize events. +The size persists in the OMP profile. + +| Size | Width | Maximum height | +| --- | ---: | ---: | +| Small | 320 px | 420 px | +| Medium | 480 px | 560 px | +| Large | 680 px | 720 px | + +The viewport limits both dimensions. +The drawing scales with the popup. Larger popups expose more labels and chips. + +The popup title is `Project map`. A subline shows `N files · M groups`. +Hover the subline for node, link, and drawn-route counts. +A warning icon after the subline marks a partial map. Hover it for the +partial-map limits. +A slim stacked indexing bar sits below the header. Its label shows `N% ready`. +This value is the ready share of the files that report a catalog state. +Files without a reported state do not lower it. +The bar tooltip shows the exact counts. The map sits in the canvas well below +the bar. +The refresh button stays disabled while a map request is active. +The toolbar and status footer stay visible while you scroll the graph and group list. +The extension mounts the popup through stock OMP's widget API. It does not take +keyboard focus or create a separate terminal page. Status, details, and the full +graph use bounded native OMP popup cards on the same surface. +After updating the extension, finish active work and use `/restart` to reload it. +An MCP reconnect does not reload extension code. + +The popup groups the current project map into bounded displayed groups. +Each supplied node belongs to one group. An explicit aggregate holds smaller +groups. The detail setting controls the number of displayed groups and routes: + +| Detail | Maximum groups | Maximum cross-group routes | +| --- | ---: | ---: | +| Calm | 12 | 24 | +| Balanced (default) | 16 | 48 | +| Detailed | 24 | 72 | + +The preview retains direction, relation kind, internal link counts, and omission counts. +Select a dot or group to open its compact inspector below the drawing. +The inspector keeps a fixed height. It shows member counts and incoming, +outgoing, and internal link counts from the full supplied map, including parallel +links and routes omitted from the drawing. Partial or loading maps stay qualified. +With no selection, the inspector shows a one-line legend. It explains dot size +(files) and line weight (links). A tint entry appears only when territories are +drawn. It distinguishes documented concept families from shared terms. +Hover a legend item for its meaning. +The row below the legend shows `Busiest` chips for individual groups. +The folded remainder is excluded from this ranking. +Small, Medium, and Large show up to three, four, and five chips. +Select a chip to select its group. +With a selection, the chips show neighboring groups with the most observed links. +The neighbor count includes both directions. Hover shows each direction separately. +The group list separates names, member counts, indexing states, and request activity. +Each group row shows its catalog indexing states as a small stacked bar. +Hosts without meters show a badge with the count of the most urgent state. +Hover a group for counts and its source document. Use the expand icon for the full map. +Selection follows group identity across refreshes. A delayed action cannot select +a different group because the list order changed. +When a map request fails, the `Diagnostics` disclosure retains the full error. +The hover summary stays short. + +The popup footer uses one row below the group list. +The row contains a fixed progress ring, a percentage, a separate warning badge, +and request counters. Percentage digits animate when motion is allowed. +A dash means that the producer has not reported a current percentage. +Scanning, planning, and failure labels appear only on hover, not as changing +footer text. The warning badge appears only for a current problem. +The badge sits beside the ring in its own space. +Unknown and pending progress stay static. +Small `↑` and `↓` counters show observed requests sent and returned in this +session. Hover the counters for request totals. +Hover the circle or warning badge for the current state and +diagnostic. Long producer errors use a bounded preview; the full error remains +in the status or overview diagnostics view. +Map refresh errors do not erase separately reported producer progress. +Current requests take precedence over older request receipts, including their +halos and hover descriptions. Exact, codebase-matched request targets use the +native positive green cue. Their halos use a slow, subtle opacity change after +a short delay. Validated returned sources receive a brief green halo. +A failed targeted request receives an error halo. Receipt halos fade without +growing. Status pips do not spin. +Activity does not change category colors, node positions, or the settled labels. +Only deliberate selection can change label priority. +Reduced-motion mode uses static temporary halos. +Status and counter updates do not rebuild the graph. +The popup retains producer history so an old completed embedding job cannot +fill the progress ring after a newer upload. It does not invent per-file +embedding activity or a reranking phase. +Refresh keeps the last map visible, including when the refresh fails. +Reopening the popup retries an initial failed lookup. Changed embedding job +observations can refresh the map before the job completes. While the popup is +open, a typed loading response schedules another map read after 10 seconds. +These reads stop when loading clears, a request fails, or the popup closes. +Pending or failed file states alone do not start a polling loop. +Closing the popup does not start a model turn. Checkout and session changes +close it and discard the previous context. + +##### Graph appearance + +Select the appearance icon in the live popup or project page. The compact native +settings card keeps the graph visible. Changes update the preview immediately +without another graph request or a model turn. Color applies to every graph view. +Layout, detail, labels, links, and motion apply to the live popup and project page. +Popup size applies only to the live popup. + +- **Layout:** Concept map, Force, Radial, Layers, or Components. + Concept map places documented concept families and related terms near each + other. Force uses lexical identifier similarity and recorded links. + Radial uses graph-distance rings. When the largest map has at least three + nodes and every other component is smaller, isolated nodes sit on one outer + ring around that map. That ring does not show a distance. An isolated node has + no recorded relation. Layers uses directed ranks, keeps cycles + adjacent, and reduces link crossings. Crowded ranks wrap to fit. + Components separates weakly connected sets into blocks. Rows within each + block do not imply direction or hierarchy. Layout does not add relationships. + Nodes keep their identity and glide to new positions across layout changes + and refreshes. Routes and territories fade in after nodes settle. + Dots arrive with a short staggered scale-in. + Labels fade in after their dot. +- **Color:** Shared uses one codebase palette for overview and tool-result + graphs. Validated concepts take precedence. Source directories supply stable + fallback colors, including while concept evidence is unavailable. + Structure uses the shared directory palette without concept assignments. + Neutral removes category colors but retains status and selection cues. + Mixed or unidentified groups stay neutral. Color is not a similarity or + confidence score. Palette changes do not move nodes. +- **Detail:** Calm, Balanced, or Detailed. Folded groups retain their member + counts. The full overview still exposes every supplied node. +- **Labels:** Key, More, or On hover. Label placement avoids collisions. + Documented concepts and large groups take priority over incidental hubs. + Tooltips and group exploration remain available in every mode. +- **Links:** Soft or Clear contrast. Direction and relation styles do not change. +- **Motion:** System follows the terminal's reduced-motion preference. + Off disables graph animation, spinner rotation, shimmer, and animated + percentage digits. Reduced-motion mode also disables these effects. + Activity updates do not move nodes. +- **Popup size:** Small, Medium, or Large. The header grip changes the same setting. + +Use the arrow keys or Enter to change choices. Contextual help explains the +selected choice. Escape closes the settings card. +Closing the card preserves the composer draft. +Preferences are saved in `/semctl/graph-appearance.json`. +The default path is `~/.omp/agent/semctl/graph-appearance.json`. +Named OMP profiles use their own agent directory. Save failures keep the active +choices in memory and expose a Retry action. Reset defaults restores the default +appearance; it does not change indexed state. +Appearance files use version 2. Version 1 files retain all saved choices and +start with the Small popup. Loading an old file does not rewrite it. + +### OMP layout sync indicator + +A persistent composer widget reports producer-backed coordinator snapshots +from `notifications/semctl/sync_status`. The version-1 notification carries the +existing coordinator status, an initial reset, or a detached-root reset. It does +not introduce another indexing state machine, polling timer, or progress owner. +The deepest observed checkout containing the session directory supplies the +indicator; unrelated checkouts do not replace it. + +The composer indicator sits above the native composer controls. Its compact +button uses the native interface font and keeps a fixed width and height. +It does not consume space in the host's status row. +The widget, progress circle, and `semctl` label stay mounted across notifications. +Reported percentages update directly from producer counts. +Active work with an unknown total uses a slow, subtle opacity change. +Queued work, warnings, errors, and idle states do not animate continuously. +Completion returns the button to a quieter appearance with a brief fade. +Reduced-motion mode disables these effects. No animation invents progress. +Plain terminals retain one Unicode progress mark. +Hover and status details retain the exact producer counts. +The indicator's context menu also opens details, the project graph, or the live popup. + +A full circle requires observed embedding completion and an active watcher. +Routine preparing, scanning, and planning retain the last completed-job ring, +with an explicit checking description. New uploads invalidate that completion +until a newer job is observed. Pending work, errors, and unavailable evidence +do not invent success. This is not a live source-freshness guarantee. + +Select the native composer indicator, or use `/semctl` or `/semctl status`, to +inspect the latest received snapshot in a bounded, read-only native popup. +The view shows the phase, checkout, watcher, available diagnostics, and next step. +`/semctl details` also includes the retained +coordinator JSON. +Scroll with the arrow or page keys. Close with Enter, Escape, or `Close`. +Select `Project graph` or press G to open the relationship overview. +Status inspection does not start a model turn, fetch source, request indexing, +or retry. +Hosts without the custom view use a notification. Hosts without command +registration keep the indicator alone. + +Exact upload and embedding counts appear in the inspection view when the +producer supplies valid completed/total counts. No percentage is invented. +A producer retry delay appears as paused, not as a successful index. +The coordinator reuses its existing 500 ms job waiter for later meaningful sync +jobs. One replaceable later-job observer reports current embedding progress; +superseded jobs cannot publish completion for a newer job. No-op syncs add no +job polling. The adapter has no polling timer. + +Idle is informational, not graph freshness or a completed index. Search +freshness warnings and `sync_status` remain separate evidence. Producer resets +clear completion and progress evidence. Previously visible checkouts retain a +static unknown mark instead of flashing out of the composer. Session shutdown +clears the slot. An abrupt transport loss cannot deliver a reset; its last +snapshot may remain until reconnect or shutdown. The indicator does not establish +transport liveness. + +Native layout, hover details, expansion, narrow widths, and lifecycle behavior +need acceptance in the actual OMP/Tern host. Source support alone is not runtime +acceptance. MCP stdout remains JSON-RPC, never ANSI escapes or Tern +surface-protocol commands. + Do not manually copy shared skills; when a host requires a projected format, generate it from the canonical skill and verify parity in CI. diff --git a/plugins/semctx/adapters/omp/concept-colors.test.ts b/plugins/semctx/adapters/omp/concept-colors.test.ts new file mode 100644 index 0000000..b6574d4 --- /dev/null +++ b/plugins/semctx/adapters/omp/concept-colors.test.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { readDisplay, type Display } from "./inspector"; +import { conceptRevision, groupConcept, lookupConcept, resetConcepts, setConceptSnapshot, subscribeConcepts } from "./concept-colors"; +import { inferConcepts } from "./graph-data/concepts.js"; +import { docConcepts, type DocSource } from "./graph-data/doc-concepts.js"; +import { conceptPalette } from "./graph-data/palette.js"; + +type Overview = Extract; + +function overview(codebaseId = "concept-local", prefix = "src"): Overview { + return { version: 1, kind: "graph", identity: { codebaseId, name: null, localRoot: "D:/repo", copy: "checkout" }, + title: "Project overview", query: null, roots: [], caveats: [], truncated: false, + nodes: ["a", "b", "unsupported"].map(id => ({ id, label: id, detail: null, hit: null, category: "file" as const, path: `${prefix}/${id}.ts` })), + edges: [{ from: "a", to: "b", kind: "reference", label: "references", symbol: "ResolveSymbol", moniker: "actual::ResolveSymbol" }] }; +} +function validated(value: Overview = overview()): Display { + const display = readDisplay(value, "overview", true); + if (!display) throw new Error("Invalid overview fixture"); + return display; +} +const DOCS: readonly DocSource[] = [{ path: "docs/dispatch.md", text: "# Dispatch\n\nQueue in `src/dispatch/queue.ts`; workers live in `src/dispatch/`.\n" }]; +/** The doc names the `src/dispatch` files and folder. The `lib` pair shares only a reference. */ +function docOverview(codebaseId: string, extra: { truncated?: boolean; loading?: boolean } = {}): Overview { + const entries = [["docs/dispatch.md", "file"], ["src/dispatch", "directory"], ["src/dispatch/queue.ts", "file"], ["src/dispatch/worker.ts", "file"], + ["src/dispatch/pool.ts", "file"], ["lib/other.ts", "file"], ["lib/peer.ts", "file"]] as const; + return { version: 1, kind: "graph", identity: { codebaseId, name: null, localRoot: "D:/repo", copy: "checkout" }, + title: "Project overview", query: null, roots: [], caveats: [], truncated: true, ...extra, + nodes: entries.map(([path, category], index) => ({ id: String(index), label: path, detail: null, hit: null, category, path })), + edges: [{ from: "2", to: "3", kind: "reference", label: "references", symbol: "Job", moniker: "dispatch::Job" }, + { from: "5", to: "6", kind: "reference", label: "references", symbol: "Peer", moniker: "lib::Peer" }] }; +} + +describe("shared evidence-backed concept colors", () => { + afterEach(resetConcepts); + test("scopes exact normalized relative paths to their codebase and shares the map palette", () => { + const display = overview(); setConceptSnapshot(validated(display)); + const color = lookupConcept("concept-local", "src/a.ts"); + expect(color).toBeDefined(); expect(lookupConcept("concept-local", ".\\src\\a.ts")).toEqual(color); + expect(lookupConcept("concept-local", "src/b.ts")).toEqual(color); + const inferred = inferConcepts(display).assignments.get("a")!; + expect(color).toMatchObject({ key: inferred.key, kind: "reference", ...conceptPalette(inferred.key) }); + for (const path of ["src/unsupported.ts", "src/A.ts", "src/a.ts/extra", "../src/a.ts", "/src/a.ts", "D:/repo/src/a.ts", "file://src/a.ts"]) expect(lookupConcept("concept-local", path)).toBeUndefined(); + for (const id of ["foreign", null, undefined]) expect(lookupConcept(id, "src/a.ts")).toBeUndefined(); + setConceptSnapshot(validated(overview("foreign", "other"))); + expect(lookupConcept("concept-local", "src/a.ts")).toEqual(color); + expect(lookupConcept("foreign", "src/a.ts")).toBeUndefined(); + }); + test("replaces assignments, clears unsupported evidence, and resets every codebase", () => { + setConceptSnapshot(validated()); const revision = conceptRevision(); + setConceptSnapshot(validated(overview("concept-local", "replacement"))); + expect(conceptRevision()).toBeGreaterThan(revision); + expect(lookupConcept("concept-local", "src/a.ts")).toBeUndefined(); + expect(lookupConcept("concept-local", "replacement/a.ts")).toBeDefined(); + setConceptSnapshot(validated({ ...overview("concept-local", "replacement"), edges: [] })); + expect(lookupConcept("concept-local", "replacement/a.ts")).toBeUndefined(); + setConceptSnapshot(validated()); setConceptSnapshot(validated(overview("foreign"))); resetConcepts(); + expect(lookupConcept("concept-local", "src/a.ts")).toBeUndefined(); + expect(lookupConcept("foreign", "src/a.ts")).toBeUndefined(); + }); + test("requires validated full local overview evidence, not arbitrary graph carriers", () => { + const raw = overview(); + setConceptSnapshot(raw); expect(lookupConcept("concept-local", "src/a.ts")).toBeUndefined(); + const ordinary = readDisplay(raw, "imports")!; + setConceptSnapshot(ordinary); expect(lookupConcept("concept-local", "src/a.ts")).toBeUndefined(); + for (const value of [{ ...overview(), truncated: true }, { ...overview(), loading: true }, + { ...overview(), identity: { ...overview().identity, copy: "canonical" as const, localRoot: null } }]) { + setConceptSnapshot(validated(value)); expect(lookupConcept("concept-local", "src/a.ts")).toBeUndefined(); + } + const imports = overview(); imports.edges = [{ from: "a", to: "b", kind: "import", label: "imports" }]; + setConceptSnapshot(validated(imports)); expect(lookupConcept("concept-local", "src/a.ts")).toBeUndefined(); + }); + test("an incomplete refresh preserves the last complete codebase palette", () => { + setConceptSnapshot(validated()); + const color = lookupConcept("concept-local", "src/a.ts"), revision = conceptRevision(); + setConceptSnapshot(validated({ ...overview("concept-local", "replacement"), loading: true })); + expect(lookupConcept("concept-local", "src/a.ts")).toBe(color); + expect(lookupConcept("concept-local", "replacement/a.ts")).toBeUndefined(); + expect(conceptRevision()).toBe(revision); + }); + test("keeps keys and palette stable across transport order and unrelated ordinal shifts", () => { + const original = overview(); setConceptSnapshot(validated(original)); + const before = lookupConcept("concept-local", "src/a.ts"); + const shifted = overview(); shifted.nodes = shifted.nodes.reverse().map((node, index) => ({ ...node, id: `shifted-${index}` })); + shifted.nodes.unshift({ id: "context", label: "Context", detail: null, hit: null, category: "directory", path: "unrelated" }); + shifted.edges = original.edges.map(edge => ({ ...edge, from: shifted.nodes.find(node => node.path === "src/a.ts")!.id, to: shifted.nodes.find(node => node.path === "src/b.ts")!.id })).reverse(); + setConceptSnapshot(validated(shifted)); expect(lookupConcept("concept-local", "src/a.ts")).toEqual(before); + }); + test("repaints all reachable subscribers without a historical-card eviction cap", () => { + const owners = Array.from({ length: 300 }, () => ({ paints: 0 })); + const stops = owners.map(owner => subscribeConcepts(owner, value => { value.paints++; })); + setConceptSnapshot(validated()); expect(owners.every(owner => owner.paints === 1)).toBe(true); + for (const stop of stops) stop(); resetConcepts(); + expect(owners.every(owner => owner.paints === 1)).toBe(true); + }); + test("doc concepts color truncated displays, including directories, but never loading displays", () => { + const value = docOverview("doc-local"), result = docConcepts(value, DOCS), concept = result.concepts.find(entry => entry.key === "doc:docs/dispatch.md"); + expect(concept).toBeDefined(); + setConceptSnapshot(validated(value), DOCS); + const doc = lookupConcept("doc-local", "docs/dispatch.md"); + expect(doc).toMatchObject({ kind: "doc", key: concept!.key, label: concept!.label, title: concept!.title, source: "docs/dispatch.md", via: "doc", + terms: concept!.terms, ...conceptPalette(concept!.key) }); + expect(lookupConcept("doc-local", "src/dispatch/queue.ts")).toMatchObject({ key: concept!.key, via: "mention" }); + expect(lookupConcept("doc-local", "src/dispatch")).toMatchObject({ key: concept!.key, via: "folder" }); + const paths = new Map(value.nodes.map(node => [node.id, node.path])); + for (const [id, { key, via }] of result.assignments) expect(lookupConcept("doc-local", paths.get(id))).toMatchObject({ key, via }); + // A truncated display has no reference communities. + expect(lookupConcept("doc-local", "lib/other.ts")).toBeUndefined(); + const revision = conceptRevision(); + setConceptSnapshot(validated(docOverview("doc-local"))); + setConceptSnapshot(validated(docOverview("doc-local", { loading: true })), DOCS); + expect(lookupConcept("doc-local", "docs/dispatch.md")).toBe(doc!); + expect(conceptRevision()).toBe(revision); + setConceptSnapshot(validated(docOverview("doc-loading", { loading: true, truncated: false })), DOCS); + expect(lookupConcept("doc-loading", "docs/dispatch.md")).toBeUndefined(); + setConceptSnapshot(docOverview("doc-raw"), DOCS); + expect(lookupConcept("doc-raw", "docs/dispatch.md")).toBeUndefined(); + const canonical = docOverview("doc-canonical"); + setConceptSnapshot(validated({ ...canonical, identity: { ...canonical.identity, copy: "canonical", localRoot: null } }), DOCS); + expect(lookupConcept("doc-canonical", "docs/dispatch.md")).toBeUndefined(); + }); + test("doc concepts take precedence; reference communities fill only the remaining files of complete displays", () => { + const value = docOverview("doc-precedence", { truncated: false }), display = validated(value); + const docs = docConcepts(value, DOCS), references = inferConcepts(value); + expect(docs.assignments.has("2") && references.assignments.has("2") && references.assignments.has("5")).toBe(true); + setConceptSnapshot(display, DOCS); + for (const node of value.nodes) { + const expected = docs.assignments.get(node.id)?.key ?? (node.category === "file" ? references.assignments.get(node.id)?.key : undefined); + expect(lookupConcept("doc-precedence", node.path)?.key).toBe(expected); + } + expect(lookupConcept("doc-precedence", "src/dispatch/queue.ts")?.kind).toBe("doc"); + expect(lookupConcept("doc-precedence", "lib/other.ts")).toMatchObject({ kind: "reference", key: references.assignments.get("5")!.key }); + setConceptSnapshot(display); + expect(lookupConcept("doc-precedence", "src/dispatch/queue.ts")).toMatchObject({ kind: "reference", key: references.assignments.get("2")!.key }); + expect(lookupConcept("doc-precedence", "docs/dispatch.md")).toBeUndefined(); + }); + test("each file keeps its own route while every group shares one route-free concept color", () => { + setConceptSnapshot(validated(docOverview("doc-routes", { truncated: false })), DOCS); + const doc = lookupConcept("doc-routes", "docs/dispatch.md")!, mention = lookupConcept("doc-routes", "src/dispatch/queue.ts")!; + const folder = lookupConcept("doc-routes", "src/dispatch")!, reference = lookupConcept("doc-routes", "lib/other.ts")!; + expect([doc.via, mention.via, folder.via]).toEqual(["doc", "mention", "folder"]); + expect(lookupConcept("doc-routes", "src/dispatch/worker.ts")).toBe(mention); + const shared = groupConcept(doc); + expect("via" in shared).toBe(false); + expect(shared).toMatchObject({ kind: "doc", key: doc.key, label: doc.label, title: doc.title, source: doc.source }); + expect(groupConcept(mention)).toBe(shared); expect(groupConcept(folder)).toBe(shared); + expect(Object.isFrozen(doc) && Object.isFrozen(shared)).toBe(true); + expect(groupConcept(reference)).toBe(reference); + }); + test("doc evidence stays scoped to its codebase and a new snapshot replaces it", () => { + setConceptSnapshot(validated(docOverview("doc-a")), DOCS); + setConceptSnapshot(validated(docOverview("doc-b")), DOCS); + for (const id of ["doc-a", "doc-b"]) expect(lookupConcept(id, "src/dispatch/queue.ts")?.kind).toBe("doc"); + expect(lookupConcept("doc-c", "src/dispatch/queue.ts")).toBeUndefined(); + const revision = conceptRevision(); + // A doc counts only as a catalog file, so this snapshot has no concept. + setConceptSnapshot(validated(docOverview("doc-a")), [{ ...DOCS[0], path: "docs/absent.md" }]); + expect(conceptRevision()).toBeGreaterThan(revision); + expect(lookupConcept("doc-a", "src/dispatch/queue.ts")).toBeUndefined(); + expect(lookupConcept("doc-a", "src/dispatch")).toBeUndefined(); + expect(lookupConcept("doc-b", "src/dispatch/queue.ts")?.kind).toBe("doc"); + }); + test("new overview acceptance keeps committed colors but blocks older snapshots per codebase", () => { + const initial = validated(docOverview("ordered-docs")); + setConceptSnapshot(initial, DOCS); + const color = lookupConcept("ordered-docs", "src/dispatch/queue.ts"), revision = conceptRevision(); + const old = validated(docOverview("ordered-docs")), latest = validated(docOverview("ordered-docs")); + const foreign = validated(docOverview("ordered-foreign")); + // New acceptance alone does not remove colors while its docs are still being read. + expect(lookupConcept("ordered-docs", "src/dispatch/queue.ts")).toBe(color!); + expect(conceptRevision()).toBe(revision); + setConceptSnapshot(old, DOCS); + setConceptSnapshot(initial, DOCS); + expect(conceptRevision()).toBe(revision); + expect(lookupConcept("ordered-docs", "src/dispatch/queue.ts")).toBe(color!); + const updated = [{ ...DOCS[0], text: DOCS[0].text.replace("# Dispatch", "# Updated dispatch") }]; + setConceptSnapshot(latest, updated); + expect(lookupConcept("ordered-docs", "src/dispatch/queue.ts")?.title).toBe("Updated dispatch"); + // Another codebase's acceptance does not block this codebase's latest snapshot. + setConceptSnapshot(foreign, DOCS); + expect(lookupConcept("ordered-foreign", "src/dispatch/queue.ts")?.title).toBe("Dispatch"); + }); + test("reset invalidates every accepted generation, including numbers reused by a fresh codebase", () => { + const old = validated(docOverview("reset-order")); + resetConcepts(); + const latest = validated(docOverview("reset-order")), revision = conceptRevision(); + setConceptSnapshot(old, DOCS); + expect(conceptRevision()).toBe(revision); + expect(lookupConcept("reset-order", "src/dispatch/queue.ts")).toBeUndefined(); + setConceptSnapshot(latest, DOCS); + expect(lookupConcept("reset-order", "src/dispatch/queue.ts")?.kind).toBe("doc"); + }); +}); diff --git a/plugins/semctx/adapters/omp/concept-colors.ts b/plugins/semctx/adapters/omp/concept-colors.ts new file mode 100644 index 0000000..f9a3e6b --- /dev/null +++ b/plugins/semctx/adapters/omp/concept-colors.ts @@ -0,0 +1,124 @@ +import type { Display } from "./inspector"; +import { inferConcepts } from "./graph-data/concepts.js"; +import { docConcepts, type DocConceptFamily, type DocConceptVia, type DocSource } from "./graph-data/doc-concepts.js"; +import type { TermVector } from "./graph-data/embedding.js"; +import { conceptPalette, normalizeConceptPath } from "./graph-data/palette.js"; + +/** `via` is per file; the other fields belong to the concept. */ +export interface ConceptColor { + key: string; + label: string; + light: string; + dark: string; + reason: string; + kind: "doc" | "reference"; + title?: string; + source?: string; + via?: DocConceptVia; + terms?: TermVector; + family?: DocConceptFamily; +} +let validatedOverviews = new WeakMap(); +const acceptedGenerations = new Map(); +const assignments = new Map>(); +const groupColors = new WeakMap(); +let revision = 0; +interface Listener { owner: WeakRef; repaint: (owner: object) => void } +const listeners = new Set(); +const finalizedOwners = new FinalizationRegistry(entry => { listeners.delete(entry); }); + +/** The display boundary calls this only after full local overview validation. Acceptance orders all request instances. */ +export function acceptConceptOverview(display: Display): void { + const codebase = display.identity.codebaseId; + if (!codebase) return; + const generation = (acceptedGenerations.get(codebase) ?? 0) + 1; + acceptedGenerations.set(codebase, generation); + validatedOverviews.set(display, generation); +} +export function conceptRevision(): number { return revision; } +export function lookupConcept(codebaseId: string | null | undefined, path: string | null | undefined): ConceptColor | undefined { + const normalized = normalizeConceptPath(path); + return codebaseId && normalized ? assignments.get(codebaseId)?.get(normalized) : undefined; +} +/** The color that a group of the concept shows. It has no per-file `via`. */ +export function groupConcept(color: ConceptColor): ConceptColor { return groupColors.get(color) ?? color; } + +/** Callbacks must not capture their owner. Owners and their registrations can be collected. */ +export function subscribeConcepts(owner: T, repaint: (owner: T) => void): () => void { + for (const entry of listeners) if (!entry.owner.deref()) listeners.delete(entry); + const entry = { owner: new WeakRef(owner), repaint: repaint as (owner: object) => void }; + listeners.add(entry); + finalizedOwners.register(owner, entry, entry); + return () => { listeners.delete(entry); finalizedOwners.unregister(entry); }; +} +/** Repaint category cues without changing evidence/grouping revisions (for profile palette changes). */ +export function notifyColorObservers(): void { + for (const entry of listeners) { + const owner = entry.owner.deref(); + if (owner) entry.repaint(owner); else listeners.delete(entry); + } +} +function changed(): void { + revision++; + notifyColorObservers(); +} + +/** Doc concepts assign the files and directories that they name; reference communities fill only the files that remain. */ +function conceptAssignments(display: Extract, docs: readonly DocSource[]): Map { + const paths = new Map(), files = new Set(); + for (const node of display.nodes) { + const path = normalizeConceptPath(node.path); + if (!path || (node.category !== "file" && node.category !== "directory")) continue; + paths.set(node.id, path); + if (node.category === "file") files.add(node.id); + } + const next = new Map(); + if (docs.length) { + const result = docConcepts(display, docs), concepts = new Map(result.concepts.map(concept => [concept.key, Object.freeze({ + key: concept.key, label: concept.label, ...conceptPalette(concept.key), + reason: `Named by ${concept.source}. Docs and catalog-path evidence, not confidence or semantic similarity.`, + kind: "doc", title: concept.title, source: concept.source, terms: concept.terms, ...(concept.family ? { family: concept.family } : {}), + })])); + const routes = new Map(); + for (const [id, assignment] of result.assignments) { + const path = paths.get(id), shared = concepts.get(assignment.key); + if (!path || !shared) continue; + const route = JSON.stringify([assignment.key, assignment.via]); + let color = routes.get(route); + if (!color) { + color = Object.freeze({ ...shared, via: assignment.via }); + groupColors.set(color, shared); + routes.set(route, color); + } + next.set(path, color); + } + } + // Indexing gaps truncate the overview without removing catalog paths, but they remove reference evidence. + if (!display.truncated) { + const concepts = inferConcepts(display); + for (const group of concepts.groups) { + const color = Object.freeze({ key: group.key, label: group.label, ...conceptPalette(group.key), reason: concepts.reason, kind: "reference" as const }); + for (const id of group.members) { + const path = files.has(id) ? paths.get(id) : undefined; + if (path && !next.has(path)) next.set(path, color); + } + } + } + return next; +} + +/** Replace one codebase's evidence. Unsupported nodes receive no assignment. A truncated display without docs keeps the last evidence. */ +export function setConceptSnapshot(display: Display, docs: readonly DocSource[] = []): void { + const generation = validatedOverviews.get(display), codebase = display.identity.codebaseId; + if (generation === undefined || !codebase || generation !== acceptedGenerations.get(codebase) || + display.kind !== "graph" || display.loading || (display.truncated && !docs.length) || + display.identity.copy !== "checkout" || !display.identity.localRoot) return; + assignments.set(codebase, conceptAssignments(display, docs)); + changed(); +} +export function resetConcepts(): void { + assignments.clear(); + acceptedGenerations.clear(); + validatedOverviews = new WeakMap(); + changed(); +} diff --git a/plugins/semctx/adapters/omp/doc-sources.test.ts b/plugins/semctx/adapters/omp/doc-sources.test.ts new file mode 100644 index 0000000..e6d29f1 --- /dev/null +++ b/plugins/semctx/adapters/omp/doc-sources.test.ts @@ -0,0 +1,67 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import { readDocSources } from "./doc-sources"; +import { DOC_SOURCE_LIMITS } from "./graph-data/doc-concepts.js"; +import type { Display } from "./inspector"; + +type Overview = Extract; +const roots: string[] = []; +async function directory(): Promise { + const root = await mkdtemp(path.join(tmpdir(), "semctl-doc-sources-")); + roots.push(root); + return root; +} +async function files(root: string, entries: Record): Promise { + for (const [file, text] of Object.entries(entries)) { + await mkdir(path.dirname(path.join(root, file)), { recursive: true }); + await writeFile(path.join(root, file), text); + } +} +function overview(localRoot: string | null, paths: readonly string[], copy: "checkout" | "canonical" = "checkout"): Overview { + return { version: 1, kind: "graph", identity: { codebaseId: "docs", name: null, localRoot, copy }, title: "Project overview", query: null, + nodes: paths.map((file, index) => ({ id: String(index), label: file, detail: null, hit: null, category: "file", path: file })), + edges: [], roots: [], caveats: [], truncated: true }; +} +const read = (display: Display) => readDocSources(display, new AbortController().signal); + +describe("checkout doc sources", () => { + afterAll(async () => { for (const root of roots) await rm(root, { recursive: true, force: true }); }); + test("reads catalog Markdown files in path order and skips other, missing, escaping and linked files", async () => { + const root = await directory(), outside = await directory(); + await files(root, { "docs/b.md": "# B\n", "docs/a.MD": "\uFEFF# A\n", "README.markdown": "# Read me\n", "src/code.ts": "export {};\n" }); + await files(outside, { "secret.md": "# Secret\n" }); + await symlink(outside, path.join(root, "linked"), "junction"); + // A file link needs extra rights on some Windows hosts; a missing link is still skipped. + await symlink(path.join(outside, "secret.md"), path.join(root, "docs", "link.md")).catch(() => undefined); + const display = overview(root, ["docs/b.md", "src/code.ts", "docs/missing.md", "linked/secret.md", "docs/link.md", "README.markdown", "docs/a.MD"]); + expect(await read(display)).toEqual([ + { path: "README.markdown", text: "# Read me\n" }, { path: "docs/a.MD", text: "# A\n" }, { path: "docs/b.md", text: "# B\n" }, + ]); + expect(await read({ ...display, nodes: display.nodes.map(node => ({ ...node, category: "directory" as const })) })).toEqual([]); + expect(await read(overview(root, ["docs/b.md"], "canonical"))).toEqual([]); + expect(await read(overview(null, ["docs/b.md"]))).toEqual([]); + }); + test("keeps the file count, file size and total size limits", async () => { + const root = await directory(), count = DOC_SOURCE_LIMITS.files + 1; + const small = Object.fromEntries(Array.from({ length: count }, (_, index) => [`notes/${String(index).padStart(4, "0")}.md`, `# ${index}\n`])); + await files(root, small); + const listed = await read(overview(root, Object.keys(small))); + expect(listed.map(doc => doc.path)).toEqual(Object.keys(small).sort().slice(0, DOC_SOURCE_LIMITS.files)); + const full = Buffer.alloc(DOC_SOURCE_LIMITS.fileBytes, 0x61), fits = DOC_SOURCE_LIMITS.totalBytes / DOC_SOURCE_LIMITS.fileBytes; + const large = Object.fromEntries(Array.from({ length: fits + 1 }, (_, index) => [`large/${String(index).padStart(2, "0")}.md`, full])); + await files(root, { ...large, "large/0-oversized.md": Buffer.alloc(DOC_SOURCE_LIMITS.fileBytes + 1, 0x61) }); + const bounded = await read(overview(root, ["large/0-oversized.md", ...Object.keys(large)])); + expect(bounded.map(doc => doc.path)).toEqual(Object.keys(large).sort().slice(0, fits)); + expect(bounded.reduce((sum, doc) => sum + doc.text.length, 0)).toBe(DOC_SOURCE_LIMITS.totalBytes); + }); + test("an aborted read rejects", async () => { + const root = await directory(); + await files(root, { "docs/a.md": "# A\n" }); + const controller = new AbortController(); + controller.abort(); + await expect(readDocSources(overview(root, ["docs/a.md"]), controller.signal)).rejects.toThrow(); + expect(await readDocSources(overview(root, []), controller.signal)).toEqual([]); + }); +}); diff --git a/plugins/semctx/adapters/omp/doc-sources.ts b/plugins/semctx/adapters/omp/doc-sources.ts new file mode 100644 index 0000000..c1ba856 --- /dev/null +++ b/plugins/semctx/adapters/omp/doc-sources.ts @@ -0,0 +1,49 @@ +import { lstat, readFile, realpath } from "node:fs/promises"; +import * as path from "node:path"; +import type { Display } from "./inspector"; +import { DOC_SOURCE_LIMITS, isDocPath, type DocSource } from "./graph-data/doc-concepts.js"; +import { normalizeConceptPath } from "./graph-data/palette.js"; + +const compare = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0; +const decoder = new TextDecoder(); + +function inside(root: string, file: string): boolean { + const relative = path.relative(root, file); + return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); +} + +/** Read the catalog Markdown files of a local checkout overview in path order, within `DOC_SOURCE_LIMITS`. */ +export async function readDocSources(display: Display, signal: AbortSignal): Promise { + if (display.kind !== "graph" || display.identity.copy !== "checkout" || !display.identity.localRoot) return []; + const selected = [...new Set(display.nodes.flatMap(node => { + const relative = node.category === "file" ? normalizeConceptPath(node.path) : undefined; + return relative && isDocPath(relative) ? [relative] : []; + }))].sort(compare).slice(0, DOC_SOURCE_LIMITS.files); + if (!selected.length) return []; + signal.throwIfAborted(); + const root = await realpath(path.resolve(display.identity.localRoot)); + const docs: DocSource[] = []; + let total = 0; + for (const relative of selected) { + signal.throwIfAborted(); + const file = path.resolve(root, relative); + if (!inside(root, file)) continue; + try { + const stats = await lstat(file); + if (!stats.isFile() || stats.size > DOC_SOURCE_LIMITS.fileBytes) continue; + if (total + stats.size > DOC_SOURCE_LIMITS.totalBytes) break; + // A linked parent directory can lead out of the checkout. + if (!inside(root, await realpath(file))) continue; + const bytes = await readFile(file, { signal }); + // The file can change after `lstat`. + if (bytes.byteLength > DOC_SOURCE_LIMITS.fileBytes) continue; + if (total + bytes.byteLength > DOC_SOURCE_LIMITS.totalBytes) break; + total += bytes.byteLength; + docs.push({ path: relative, text: decoder.decode(bytes) }); + } catch { + signal.throwIfAborted(); + // A missing or unreadable doc removes only concept evidence. The overview stays complete without it. + } + } + return docs; +} diff --git a/plugins/semctx/adapters/omp/extension.ts b/plugins/semctx/adapters/omp/extension.ts new file mode 100644 index 0000000..a1d4cf6 --- /dev/null +++ b/plugins/semctx/adapters/omp/extension.ts @@ -0,0 +1,271 @@ +import * as path from "node:path"; +import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent"; +import { createSemctlHookInvoker } from "./hook-process"; +import { readDisplay, SemctlInspector } from "./inspector"; +import { createGraphOverview } from "./overview"; +import { installSyncStatus, readStatusUpdate } from "./status"; +import type { GraphAppearanceStore } from "./graph-appearance"; +import type { GraphAppearanceKeyMatcher } from "./graph-appearance-panel"; +import { setGraphColorMode } from "./graph-colors"; + +export { extractAdditionalContext } from "./hook-process"; + +const HOST = "omp"; +const OMP_SEMCTX_TOOL_PREFIX = "mcp__semctx_"; +const OMP_MARKETPLACE_TOOL_PREFIX = "mcp__semctx_semctx_"; +const SESSION_TIMEOUT_MS = 8_000; +const PROMPT_TIMEOUT_MS = 12_000; +const TOOL_TIMEOUT_MS = 6_000; + +const ORIENTATION_MESSAGE = "ca.napbat.semctx.orientation"; +const PROMPT_CONTEXT_MESSAGE = "ca.napbat.semctx.prompt-context"; + +// OMP internal URLs (`omp://`, `local://`, `skill://`, `artifact://`, ...) and +// web URLs name harness or remote resources, not repository files. OMP also +// accepts the single-slash alias (`omp:/tools`). The scheme has two or more +// characters, so a Windows drive path (`C:/repo`) never matches. +const URL_PATH = /^[a-z][a-z0-9+.-]+:\//i; +// OMP accepts a trailing line selector on one file (`src/lib.rs:10-20`). +const LINE_SELECTOR = /:(?=[^:]*\d)[\d,+-]+$/; +// OMP `glob` treats a slash-only path as the session cwd. +const ROOT_ALIAS = /^[\\/]+$/; + +type SessionSource = "startup" | "resume" | "clear" | "compact"; +type HookEventName = "SessionStart" | "UserPromptSubmit" | "PreToolUse"; +type HookContext = Pick & { + sessionManager: Pick; +}; +type OmpMcpTools = "native" | "marketplace" | "unavailable"; + +export interface SemctlHookInput { + host: typeof HOST; + omp_mcp_tools?: OmpMcpTools; + hook_event_name: HookEventName; + cwd: string; + session_id: string; + prompt_id?: string; + prompt?: string; + source?: SessionSource; + tool_name?: string; + tool_input?: Record; +} + +export interface HookInvoker { + invoke(input: SemctlHookInput, ctx: HookContext, timeoutMs: number): Promise; + shutdown(): void; +} + +function hiddenMessage(customType: string, content: string) { + return { + customType, + content, + display: false, + attribution: "agent" as const, + }; +} + +/** A `PreToolUse` call in the Claude-shaped wire contract that `semctl hook` reads. */ +interface HookToolCall { + tool_name: string; + tool_input: Record; + cwd: string; +} + +/** The non-empty entries of an OMP semicolon-delimited `path` list. */ +function pathEntries(value: unknown): string[] { + if (typeof value !== "string") return []; + return value + .split(";") + .map(entry => entry.trim()) + .filter(entry => entry.length > 0); +} + +/** + * OMP content searches and the input field that holds each search pattern. + * `ast_grep` matches code structure and `find` is a natural-language discovery + * query; both are repository discovery that semctl serves, so each maps to the + * wire contract's `Grep`. + */ +const CONTENT_SEARCH_PATTERN: Record = { + grep: "pattern", + ast_grep: "pat", + find: "query", +}; + +const isObject = (input: unknown): input is Record => + input !== null && typeof input === "object" && !Array.isArray(input); + +/** + * Map an OMP tool call onto the wire contract. OMP input shapes differ from + * Claude's: `glob` carries its pattern in `path`, `grep` accepts a line + * selector on one file, and `bash` can run in its own `cwd`. A search of + * internal or web URLs is not repository discovery, so it is not sent. + */ +function hookToolCall(toolName: string, input: unknown, cwd: string): HookToolCall | undefined { + if (!isObject(input)) return undefined; + if (toolName.startsWith(OMP_SEMCTX_TOOL_PREFIX)) return { tool_name: toolName, tool_input: input, cwd }; + const patternField = CONTENT_SEARCH_PATTERN[toolName]; + if (patternField !== undefined) { + const entries = pathEntries(input.path); + if (entries.some(entry => URL_PATH.test(entry))) return undefined; + const pattern = input[patternField]; + if (entries.length === 0) return { tool_name: "Grep", tool_input: { pattern }, cwd }; + const target = entries.length === 1 ? entries[0].replace(LINE_SELECTOR, "") : input.path; + return { tool_name: "Grep", tool_input: { pattern, path: target }, cwd }; + } + switch (toolName) { + case "glob": { + // OMP `glob` carries its pattern in `path`. The wire contract follows + // Claude's `Glob`, which carries it in `pattern`. + const entries = pathEntries(input.path); + if (entries.some(entry => URL_PATH.test(entry))) return undefined; + const wholeTree = entries.length === 0 || (entries.length === 1 && ROOT_ALIAS.test(entries[0])); + return { tool_name: "Glob", tool_input: { pattern: wholeTree ? "**/*" : entries.join(";") }, cwd }; + } + case "bash": { + const shellCwd = typeof input.cwd === "string" ? input.cwd.trim() : ""; + if (URL_PATH.test(shellCwd)) return undefined; + const commandCwd = shellCwd.length === 0 ? cwd : path.resolve(cwd, shellCwd); + return { tool_name: "Bash", tool_input: input, cwd: commandCwd }; + } + default: + return undefined; + } +} + +/** Select the mounted server identity without rewriting Rust-owned guidance. */ +function ompMcpTools(pi: ExtensionAPI): OmpMcpTools { + const tools = pi.getAllTools(); + if (tools.some(tool => tool.name === `${OMP_SEMCTX_TOOL_PREFIX}search_codebase`)) return "native"; + if (tools.some(tool => tool.name === `${OMP_MARKETPLACE_TOOL_PREFIX}search_codebase`)) return "marketplace"; + return "unavailable"; +} + +export function createSemctxExtension(invoker: HookInvoker = createSemctlHookInvoker(), appearance?: GraphAppearanceStore, appearanceKey?: GraphAppearanceKeyMatcher) { + return function semctxExtension(pi: ExtensionAPI): void { + const stopAppearance = appearance?.subscribe(() => setGraphColorMode(appearance.state.value.color)); + if (appearance) { + setGraphColorMode(appearance.state.value.color); + void appearance.load(); + } + const overview = createGraphOverview(pi, { + readDisplay, readStatusUpdate, appearance, appearanceKey, + createInspector: (display, requestRender, rawText, presentation) => new SemctlInspector(display, requestRender, + () => [{ k: "text", p: { text: rawText.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "") } }], rawText, "body", presentation ?? "inline"), + }); + const instanceId = `${process.pid}-${Date.now().toString(36)}`; + let promptGeneration = 0; + let sessionGeneration = 0; + let activePromptId = ""; + // The prompt of a submission that has not started a turn yet. OMP can run + // `before_agent_start` again for the same submission before its first + // turn. Reusing the prompt id keeps semctl's per-turn dedup intact. + let preparingPrompt: string | undefined; + + pi.setLabel("semctx"); + + const allocatePromptId = (ctx: HookContext) => { + promptGeneration += 1; + return `${ctx.sessionManager.getSessionId()}:${instanceId}:${promptGeneration}`; + }; + const safeInvoke = async (input: SemctlHookInput, ctx: HookContext, timeoutMs: number) => { + try { + return await invoker.invoke({ ...input, omp_mcp_tools: ompMcpTools(pi) }, ctx, timeoutMs); + } catch { + return undefined; + } + }; + const resetTurnState = () => { + sessionGeneration += 1; + activePromptId = ""; + preparingPrompt = undefined; + }; + const sendOrientation = async (source: SessionSource, ctx: ExtensionContext) => { + if (source !== "compact") overview.reset(); + resetTurnState(); + const generation = sessionGeneration; + const context = await safeInvoke( + { + host: HOST, + hook_event_name: "SessionStart", + cwd: ctx.cwd, + session_id: ctx.sessionManager.getSessionId(), + source, + }, + ctx, + SESSION_TIMEOUT_MS, + ); + if (generation === sessionGeneration && context !== undefined) { + pi.sendMessage(hiddenMessage(ORIENTATION_MESSAGE, context), { + deliverAs: "nextTurn", + }); + } + }; + + pi.on("session_start", async (_event, ctx) => sendOrientation("startup", ctx)); + pi.on("session_switch", async (event, ctx) => + sendOrientation(event.reason === "new" ? "startup" : "resume", ctx), + ); + pi.on("session_branch", async (_event, ctx) => sendOrientation("clear", ctx)); + pi.on("session_tree", async (_event, ctx) => sendOrientation("clear", ctx)); + pi.on("session_compact", async (_event, ctx) => sendOrientation("compact", ctx)); + + pi.on("before_agent_start", async (event, ctx) => { + const generation = sessionGeneration; + if (activePromptId === "" || preparingPrompt !== event.prompt) { + activePromptId = allocatePromptId(ctx); + } + preparingPrompt = event.prompt; + const context = await safeInvoke( + { + host: HOST, + hook_event_name: "UserPromptSubmit", + cwd: ctx.cwd, + session_id: ctx.sessionManager.getSessionId(), + prompt_id: activePromptId, + prompt: event.prompt, + }, + ctx, + PROMPT_TIMEOUT_MS, + ); + return generation !== sessionGeneration || context === undefined + ? undefined + : { message: hiddenMessage(PROMPT_CONTEXT_MESSAGE, context) }; + }); + pi.on("turn_start", () => { + preparingPrompt = undefined; + }); + + pi.on("tool_call", async (event, ctx) => { + overview.observe(event, ctx); + const generation = sessionGeneration; + const call = hookToolCall(event.toolName, event.input, ctx.cwd); + if (call === undefined) return undefined; + activePromptId ||= allocatePromptId(ctx); + const context = await safeInvoke( + { + host: HOST, + hook_event_name: "PreToolUse", + session_id: ctx.sessionManager.getSessionId(), + prompt_id: activePromptId, + ...call, + }, + ctx, + TOOL_TIMEOUT_MS, + ); + return generation !== sessionGeneration || context === undefined + ? undefined + : { additionalContext: context }; + }); + pi.on("session_shutdown", async () => { + stopAppearance?.(); + overview.reset(); + resetTurnState(); + invoker.shutdown(); + await appearance?.flush(); + }); + pi.on("tool_result", (event, ctx) => overview.observe(event, ctx)); + pi.on("mcp_notification", (event, ctx) => overview.observe(event, ctx)); + installSyncStatus(pi, (ctx, mode) => mode === "live" ? overview.togglePopup(ctx) : overview.open(ctx)); + }; +} diff --git a/plugins/semctx/adapters/omp/graph-activity.test.ts b/plugins/semctx/adapters/omp/graph-activity.test.ts new file mode 100644 index 0000000..e7b0b4a --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-activity.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, test } from "bun:test"; +import * as path from "node:path"; +import { GraphActivityTracker, type GraphActivityClock } from "./graph-activity"; +import { readDisplay, type Display, type Hit } from "./inspector"; +import { graphRoot } from "./overview"; + +const cwd = graphRoot(path.resolve("activity-checkout"))!; +const identity: Display["identity"] = { codebaseId: "activity-current", localRoot: cwd, copy: "checkout", name: null }; +function overview(): Display { + return { version: 1, kind: "graph", identity, title: "Indexed overview", query: null, + nodes: [{ id: "file", label: "a.ts", category: "file", path: "src/a.ts", detail: null, hit: null }], + edges: [], roots: ["file"], caveats: [], truncated: false }; +} +function match(overrides: Partial = {}): Hit { + return { path: "src/a.ts", symbol: "run", qualifiedName: "app.run", language: "typescript", kind: "function", score: 2, + startLine: 1, endLine: 1, startByte: 0, endByte: 10, snippet: "run()", + snippetStartLine: 1, snippetEndLine: 1, snippetStartByte: 0, snippetEndByte: 10, focusLine: 1, focusByte: 0, + codebaseId: identity.codebaseId, binding: { state: "bound", reason: null }, ...overrides }; +} +function search(hits = [match()]): Display { + return { version: 1, kind: "search", identity, query: "run", hits, indexWarning: null }; +} +function source(sourcePath = "src/a.ts"): Display { + return { version: 1, kind: "source", identity, path: sourcePath, text: "run()", language: "typescript", + sourceOrigin: "content-plane", contentHash: "hash", encoding: "utf-8", binary: false, truncated: false, + byteStart: 0, byteEnd: 5, fileSize: 5, lineStart: 1, lineEnd: 1 }; +} +function clockHarness() { + let now = 0; + const callbacks: { callback(): void; at: number; active: boolean }[] = []; + const clock: GraphActivityClock = { + now: () => now, + schedule(callback, delay) { + const entry = { callback, at: now + delay, active: true }; + callbacks.push(entry); + return () => { entry.active = false; }; + }, + }; + return { clock, callbacks, get pending() { return callbacks.filter(entry => entry.active).length; }, + advance(milliseconds: number) { + now += milliseconds; + for (const entry of callbacks) if (entry.active && entry.at <= now) { entry.active = false; entry.callback(); } + } }; +} +function trackerHarness() { + const clock = clockHarness(); let renders = 0; + const tracker = new GraphActivityTracker(graphRoot, readDisplay, () => renders++, clock.clock); + return { tracker, clock, get renders() { return renders; } }; +} +function call(tracker: GraphActivityTracker, id: string, input: Record = {}, operation = "read_source", root = cwd): void { + tracker.observeCall({ type: "tool_call", toolName: `mcp__semctx_${operation}`, toolCallId: id, input }, root); +} +function completed(id: string, display: unknown = source(), operation = "read_source", extra: Record = {}): Record { + return { type: "tool_result", toolName: `mcp__semctx_${operation}`, toolCallId: id, input: {}, content: [{ type: "text", text: "Original text is not a metadata carrier" }], + isError: false, details: { serverName: "semctx", mcpToolName: operation, mcpMeta: { semctlDisplay: display } }, ...extra }; +} + +describe("fail-closed checkout activity attribution", () => { + test("learns default and directory scopes from trusted immutable IDs, while explicit matching IDs map immediately", () => { + for (const selector of [undefined, null, identity.codebaseId, cwd, path.join(cwd, ".")]) { + const h = trackerHarness(), nested = graphRoot(path.join(cwd, "nested"))!; + call(h.tracker, "read", { path: "src/a.ts", codebase: selector }, "read_source", nested); + expect(h.tracker.snapshot(nested, overview()).targets).toEqual(selector === identity.codebaseId ? ["src/a.ts"] : []); + expect(h.tracker.snapshot(nested, overview()).operations).toEqual(["read_source"]); + h.tracker.observeResult(completed("read"), overview(), true); + expect(h.tracker.snapshot(nested, overview()).recent?.[0].targets).toEqual(["src/a.ts"]); + expect(h.clock.pending).toBe(1); + call(h.tracker, "subsequent", { path: "src/a.ts", codebase: selector }, "read_source", nested); + expect(h.tracker.snapshot(nested, overview()).targets).toEqual(["src/a.ts"]); + h.tracker.reset(); + } + }); + test("rejects foreign and ambiguous selectors, missing checkout binding and escaping paths without losing observations", () => { + const inputs = [ + { path: "src/a.ts", codebase: "foreign" }, { path: "src/a.ts", codebase: path.resolve("foreign-checkout") }, + { path: "src/a.ts", codebase: "activity-checkout" }, { path: "src/a.ts", codebase: 4 }, + { path: "src/a.ts", scope: "any" }, { path: "src/a.ts", codebase_ids: [identity.codebaseId, "foreign"] }, + { path: "../escape.ts" }, { path: "..\\escape.ts" }, { path: path.resolve(cwd, "../escape.ts") }, { path: "Z:/foreign/a.ts" }, + ]; + for (const input of inputs) { + const h = trackerHarness(); + call(h.tracker, "bad", { codebase: identity.codebaseId, ...input }); + expect(h.tracker.snapshot(cwd, overview()).targets).toEqual([]); + h.tracker.observeResult(completed("bad", undefined, "read_source", { isError: true }), overview(), true); + expect(h.tracker.snapshot(cwd, overview()).recent).toEqual([]); + expect(h.tracker.snapshot(cwd, overview()).requests).toEqual({ sent: 1, received: 1, failed: 1 }); + expect(h.clock.pending).toBe(0); + } + const h = trackerHarness(); + call(h.tracker, "local", { path: "src/a.ts", symbol: "run", codebase: identity.codebaseId }); + for (const display of [undefined, { ...overview(), identity: { ...identity, localRoot: null } }, + { ...overview(), identity: { ...identity, localRoot: path.resolve("foreign") } }, + { ...overview(), identity: { ...identity, localRoot: null, copy: "canonical" as const } }]) { + expect(h.tracker.snapshot(cwd, display).targets).toEqual([]); + expect(h.tracker.snapshot(cwd, display).operations).toEqual(["read_source"]); + } + }); + test("keeps all correlated counters when display mapping is unavailable and bounds exact input targets", () => { + const h = trackerHarness(); + for (let i = 0; i < 160; i++) call(h.tracker, String(i), { path: "src/a.ts" }); + expect(h.tracker.snapshot(cwd, undefined).requests?.sent).toBe(160); + for (let i = 0; i < 160; i++) h.tracker.observeResult(completed(String(i)), undefined, true); + expect(h.tracker.snapshot(cwd, undefined).requests).toEqual({ sent: 160, received: 160, failed: 0 }); + expect(h.clock.pending).toBe(0); + call(h.tracker, "batch", { symbols: Array.from({ length: 400 }, (_, i) => `exact-${i}`), query: "not a target", symbol: "run", codebase: identity.codebaseId }, "batch_lookup"); + const targets = h.tracker.snapshot(cwd, overview()).targets; + expect(targets).toHaveLength(256); + expect(targets).not.toContain("not a target"); + expect(targets).toContain("run"); + }); + test("learned bindings are per raw server and selector, invalidate on foreign metadata, and clear on reset", () => { + for (const selector of [undefined, cwd]) for (const invalid of [ + completed("active", { ...source(), identity: { ...identity, codebaseId: "foreign" } }), + completed("active", source(), "read_source", { details: { serverName: "foreign", mcpToolName: "read_source", mcpMeta: { semctlDisplay: source() } } }), + completed("active", source(), "read_source", { details: { serverName: "semctx", mcpToolName: "search_codebase", mcpMeta: { semctlDisplay: source() } } }), + ]) { + const h = trackerHarness(); + call(h.tracker, "learn", { codebase: selector }); + h.tracker.observeResult(completed("learn"), overview(), false); + call(h.tracker, "active", { path: "src/a.ts", codebase: selector }); + expect(h.tracker.snapshot(cwd, overview()).targets).toEqual(["src/a.ts"]); + h.tracker.observeCall({ toolCallId: "namespace", toolName: "mcp__semctx_semctx_read_source", input: { path: "src/namespaced.ts", codebase: selector } }, cwd); + call(h.tracker, "other-selector", { path: "src/other.ts", codebase: selector === undefined ? cwd : undefined }); + expect(h.tracker.snapshot(cwd, overview()).targets).toEqual(["src/a.ts"]); + h.tracker.observeResult(invalid, overview(), true); + call(h.tracker, "after-foreign", { path: "src/a.ts", codebase: selector }); + expect(h.tracker.snapshot(cwd, overview()).targets).toEqual([]); + expect(h.tracker.snapshot(cwd, overview()).recent).toEqual([]); + h.tracker.reset(); + call(h.tracker, "after-reset", { path: "src/a.ts", codebase: selector }); + expect(h.tracker.snapshot(cwd, overview()).targets).toEqual([]); + } + }); +}); + +describe("typed completion receipts", () => { + test("only the host's exact trusted carrier can highlight returned paths, never text or request echoes", () => { + const variants: Record[] = [ + { details: undefined, content: [{ type: "text", text: JSON.stringify({ semctlDisplay: source() }) }] }, + { details: { serverName: "foreign", mcpToolName: "read_source", mcpMeta: { semctlDisplay: source() } } }, + { details: { serverName: "semctx:semctx", mcpToolName: "read_source", mcpMeta: { semctlDisplay: source() } } }, + { details: { serverName: "semctx", mcpToolName: "search_codebase", mcpMeta: { semctlDisplay: source() } } }, + { details: { serverName: "semctx", mcpToolName: "read_source", mcpMeta: { semctlDisplay: search() } } }, + { details: { serverName: "semctx", mcpToolName: "read_source", mcpMeta: { semctlDisplay: { ...source(), version: 2 } } } }, + { details: { result: { details: { serverName: "semctx", mcpToolName: "read_source", mcpMeta: { semctlDisplay: source() } } } } }, + { toolName: "mcp__other_read_source" }, + ]; + for (const extra of variants) { + const h = trackerHarness(); call(h.tracker, "request", { path: "src/request.ts" }); + h.tracker.observeResult(completed("request", source(), "read_source", extra), overview(), true); + expect(h.tracker.snapshot(cwd, overview()).recent).toEqual([]); + expect(h.clock.pending).toBe(0); + } + const h = trackerHarness(); call(h.tracker, "request", { path: "src/request.ts" }); + h.tracker.observeResult(completed("request", source("src/returned.ts")), overview(), true); + expect(h.tracker.snapshot(cwd, overview()).recent?.[0]).toEqual({ id: 1, operation: "read_source", outcome: "returned", targets: ["src/returned.ts"] }); + expect(h.tracker.snapshot(cwd, overview()).targets).toEqual([]); + h.tracker.reset(); + }); + test("supports xd inner details and raw namespaced MCP identity, deduplicating nested host events", () => { + const h = trackerHarness(), name = "mcp__semctx_semctx_search_codebase"; + const event = { type: "tool_call", toolName: "write", toolCallId: "xd", input: { path: `xd://${name}`, content: JSON.stringify({ query: "run" }) } }; + expect(h.tracker.observeCall(event, cwd)).toBe(true); + expect(h.tracker.observeCall({ ...event, toolName: name, input: { query: "run" } }, cwd)).toBe(false); + const details = { xdev: { tool: name, mode: "execute", args: { query: "run" }, inner: { + serverName: "semctx:semctx", mcpToolName: "search_codebase", mcpMeta: { semctlDisplay: search() }, + } } }; + expect(h.tracker.observeResult(completed("xd", undefined, "search_codebase", { toolName: "write", details }), overview(), true)).toBe(true); + expect(h.tracker.observeResult(completed("xd"), overview(), true)).toBe(false); + expect(h.tracker.snapshot(cwd, overview()).requests).toEqual({ sent: 1, received: 1, failed: 0 }); + expect(h.tracker.snapshot(cwd, overview()).recent?.[0].targets).toEqual(["src/a.ts"]); + h.tracker.reset(); + for (const wrong of [{ ...details.xdev, mode: "help" }, { ...details.xdev, tool: "mcp__other_search_codebase" }]) { + h.tracker.observeCall(event, cwd); + h.tracker.observeResult(completed("xd", undefined, "search_codebase", { toolName: "write", details: { xdev: wrong } }), overview(), true); + expect(h.tracker.snapshot(cwd, overview()).recent).toEqual([]); + } + }); + test("extracts search, batch, source and graph paths while honoring per-hit codebase identities", () => { + const hits = [match(), match({ path: "src/foreign.ts", codebaseId: "foreign" }), match({ path: "../escape.ts" }), match({ path: "src/inherited.ts", codebaseId: null })]; + const outputs: { operation: string; display: Display; targets: string[] }[] = [ + { operation: "search_codebase", display: search(hits), targets: ["src/a.ts", "src/inherited.ts"] }, + { operation: "batch_lookup", display: { version: 1, kind: "batch", identity, symbols: [{ symbol: "run", hits }] }, targets: ["src/a.ts", "src/inherited.ts"] }, + { operation: "read_source", display: source(path.join(cwd, "src/a.ts")), targets: ["src/a.ts"] }, + { operation: "imports", display: { ...overview(), kind: "graph", nodes: [ + { id: "local", label: "run", detail: null, hit: match() }, + { id: "foreign", label: "foreign", detail: null, hit: match({ path: "src/foreign.ts", codebaseId: "foreign" }) }, + { id: "file", label: "b.ts", path: "src/b.ts", category: "file", detail: null, hit: null }, + ], roots: ["local", "foreign", "file"] } as Display, targets: ["src/a.ts", "src/b.ts"] }, + { operation: "search_codebase", display: { ...search(hits), identity: { ...identity, codebaseId: null, localRoot: null } }, targets: ["src/a.ts"] }, + ]; + for (const { operation, display, targets } of outputs) { + const h = trackerHarness(); call(h.tracker, operation, {}, operation); + h.tracker.observeResult(completed(operation, display, operation), overview(), true); + expect(h.tracker.snapshot(cwd, overview()).recent?.[0]?.targets).toEqual(targets); + h.tracker.reset(); + } + for (const foreign of [ + { ...identity, codebaseId: "foreign" }, { ...identity, localRoot: path.resolve("foreign-checkout") }, + ]) { + const h = trackerHarness(); call(h.tracker, "foreign", {}, "search_codebase"); + h.tracker.observeResult(completed("foreign", { ...search(), identity: foreign }, "search_codebase"), overview(), true); + expect(h.tracker.snapshot(cwd, overview()).recent).toEqual([]); + } + }); + test("failed requests retain only actual scoped inputs, not untrusted result paths", () => { + const h = trackerHarness(); + call(h.tracker, "failed", { path: "src/request.ts", codebase: identity.codebaseId }); + h.tracker.observeResult(completed("failed", source("src/invented.ts"), "read_source", { isError: true }), overview(), true); + expect(h.tracker.snapshot(cwd, overview()).recent?.[0]).toEqual({ id: 1, operation: "read_source", outcome: "failed", targets: ["src/request.ts"] }); + call(h.tracker, "untargeted", { query: "no invented target" }, "search_codebase"); + h.tracker.observeResult(completed("untargeted", search(), "search_codebase", { isError: true }), overview(), true); + expect(h.tracker.snapshot(cwd, overview()).recent).toHaveLength(1); + h.tracker.reset(); + }); + test("an outer successful xd write cannot hide inner MCP failure", () => { + const h = trackerHarness(), name = "mcp__semctx_read_source"; + h.tracker.observeCall({ toolCallId: "xd-error", toolName: "write", + input: { path: `xd://${name}`, content: JSON.stringify({ path: "src/request.ts", codebase: identity.codebaseId }) } }, cwd); + h.tracker.observeResult(completed("xd-error", undefined, "read_source", { + toolName: "write", isError: false, details: { xdev: { tool: name, mode: "execute", inner: { + serverName: "semctx", mcpToolName: "read_source", isError: true, mcpMeta: { semctlDisplay: source("src/not-returned.ts") }, + } } }, + }), overview(), true); + expect(h.tracker.snapshot(cwd, overview()).requests).toEqual({ sent: 1, received: 1, failed: 1 }); + expect(h.tracker.snapshot(cwd, overview()).recent?.[0]).toEqual({ id: 1, operation: "read_source", outcome: "failed", targets: ["src/request.ts"] }); + h.tracker.reset(); + }); +}); + +describe("finite receipt lifecycle", () => { + test("expires staggered bounded receipts with one cancellable one-shot and no idle work", () => { + const h = trackerHarness(); + call(h.tracker, "first"); h.tracker.observeResult(completed("first"), overview(), true); + h.clock.advance(1000); + call(h.tracker, "second"); h.tracker.observeResult(completed("second"), overview(), true); + expect(h.clock.pending).toBe(1); + h.clock.advance(499); + expect(h.tracker.snapshot(cwd, overview()).recent).toHaveLength(2); + expect(h.renders).toBe(0); + h.clock.advance(1); + expect(h.tracker.snapshot(cwd, overview()).recent?.map(receipt => receipt.id)).toEqual([2]); + expect(h.clock.pending).toBe(1); + h.clock.advance(1000); + expect(h.tracker.snapshot(cwd, overview()).recent).toEqual([]); + expect(h.renders).toBe(2); + expect(h.clock.pending).toBe(0); + for (let i = 0; i < 40; i++) { call(h.tracker, String(i)); h.tracker.observeResult(completed(String(i)), overview(), true); } + expect(h.tracker.snapshot(cwd, overview()).recent).toHaveLength(32); + expect(h.clock.pending).toBe(1); + h.clock.advance(1500); + expect(h.clock.pending).toBe(0); + }); + test("reset, close and replaced checkout identities cannot leak stale feedback or timer callbacks", () => { + const h = trackerHarness(); + call(h.tracker, "first"); h.tracker.observeResult(completed("first"), overview(), true); + const stale = h.clock.callbacks.at(-1)!; + expect(h.tracker.snapshot(cwd, { ...overview(), identity: { ...identity, codebaseId: "replacement" } }).recent).toEqual([]); + h.tracker.reset(); + expect(h.clock.pending).toBe(0); + call(h.tracker, "second"); h.tracker.observeResult(completed("second"), overview(), true); + stale.callback(); + expect(h.tracker.snapshot(cwd, overview()).recent?.[0].id).toBe(2); + expect(h.clock.pending).toBe(1); + expect(h.renders).toBe(0); + h.tracker.clearReceipts(); + expect(h.clock.pending).toBe(0); + call(h.tracker, "closed"); h.tracker.observeResult(completed("closed"), overview(), false); + expect(h.tracker.snapshot(cwd, overview()).recent).toEqual([]); + expect(h.clock.pending).toBe(0); + h.tracker.reset(); + expect(h.tracker.observeResult(completed("second"), overview(), true)).toBe(false); + expect(h.tracker.snapshot(cwd, overview()).requests).toEqual({ sent: 0, received: 0, failed: 0 }); + }); +}); diff --git a/plugins/semctx/adapters/omp/graph-activity.ts b/plugins/semctx/adapters/omp/graph-activity.ts new file mode 100644 index 0000000..2ce235b --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-activity.ts @@ -0,0 +1,230 @@ +import * as path from "node:path"; +import type { TspTone } from "@oh-my-pi/pi-wire"; +import type { Display } from "./inspector"; + +/** A bounded, short-lived observation. It does not imply an internal search or embedding stage. */ +export interface GraphReceipt { + id: number; + operation: string; + outcome: "returned" | "failed"; + targets: readonly string[]; +} + +/** Session activity backed only by observed semctx requests and producer notifications. */ +export interface GraphActivity { + operations: string[]; + targets: string[]; + recent?: readonly GraphReceipt[]; + producer: null | { + text: string; tone: TspTone; progress?: number; details?: string; diagnostic?: string; + symbol?: "status.running" | "status.info" | "status.warning" | "status.error" | "status.pending" | "status.success"; + }; + requests?: { sent: number; received: number; failed: number }; +} + +const object = (value: unknown): value is Record => value !== null && typeof value === "object" && !Array.isArray(value); +const boundedText = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 4096 && !/[\u0000-\u001f\u007f]/.test(value); +const LIMIT = 256; +const RECEIPT_MS = 1500; +interface ObservedCall { + root: string; + operation: string; + name: string; + server: string; + selector: string | null | false; + targets: { value: string; source: boolean }[]; +} +export interface GraphActivityClock { + now(): number; + /** Return cancellation for a single one-shot callback. */ + schedule(callback: () => void, delay: number): () => void; +} +const systemClock: GraphActivityClock = { + now: () => Date.now(), + schedule(callback, delay) { + const timer = setTimeout(callback, delay); + return () => clearTimeout(timer); + }, +}; + +/** Observes host events, never transcript text or inferred producer stages. */ +export class GraphActivityTracker { + private readonly calls = new Map(); + private readonly traffic = new Map(); + private readonly bindings = new Map(); + private receipts: { root: string; checkout: string; codebaseId: string | null; receipt: GraphReceipt; expires: number }[] = []; + private cancelExpiry: (() => void) | undefined; + private expiryGeneration = 0; + private nextReceipt = 0; + constructor(private readonly normalizeRoot: (value: string) => string | undefined, + private readonly readDisplay: (value: unknown, operation: string) => Display | undefined, + private readonly changed: () => void, + private readonly clock: GraphActivityClock = systemClock) {} + + reset(): void { + this.calls.clear(); + this.traffic.clear(); + this.bindings.clear(); + this.clearReceipts(); + } + clearReceipts(): void { + this.expiryGeneration++; + this.cancelExpiry?.(); + this.cancelExpiry = undefined; + this.receipts = []; + } + private checkout(display: Display | undefined, cwd: string): string | undefined { + if (!display || display.identity.copy !== "checkout" || !display.identity.localRoot) return undefined; + const root = this.normalizeRoot(display.identity.localRoot); + if (!root) return undefined; + const relative = path.relative(root, cwd); + return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) ? root : undefined; + } + private bindingKey(call: ObservedCall): string { + return `${call.server}\0${call.root}\0${call.selector === null ? "default" : typeof call.selector === "string" ? this.normalizeRoot(call.selector) ?? call.selector : "invalid"}`; + } + private matches(call: ObservedCall, display: Display | undefined, requireBinding = true): string | undefined { + const root = this.checkout(display, call.root); + if (!root || call.selector === false) return undefined; + if (call.selector !== null && call.selector === display?.identity.codebaseId) return root; + if (call.selector !== null && this.normalizeRoot(call.selector) !== root) return undefined; + // A CLI checkout does not establish the MCP server's tenant/default binding. + if (!requireBinding || display?.identity.codebaseId !== null && this.bindings.get(this.bindingKey(call)) === display?.identity.codebaseId) return root; + return undefined; + } + private source(value: string, root: string): string | undefined { + if (!boundedText(value)) return undefined; + // Reject foreign platform absolutes as well as checkout traversal on this host. + const portable = value.replace(/\\/g, "/"); + if ((/^[a-z]:/i.test(portable) || portable.startsWith("//")) && !path.isAbsolute(value)) return undefined; + const absolute = path.isAbsolute(value) ? this.normalizeRoot(value) : path.resolve(root, portable); + if (!absolute) return undefined; + const relative = path.relative(root, absolute); + if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return undefined; + return relative.replace(/\\/g, "/"); + } + private targets(call: ObservedCall, display: Display | undefined): string[] { + const root = this.matches(call, display); + if (!root) return []; + return [...new Set(call.targets.flatMap(target => { + const value = target.source ? this.source(target.value, root) : target.value; + return value ? [value] : []; + }))].slice(0, LIMIT); + } + + observeCall(event: Record, root: string): boolean { + if (typeof event.toolCallId !== "string" || typeof event.toolName !== "string") return false; + let name = event.toolName; + let input = object(event.input) ? event.input : undefined; + if (name === "write") { + if (!input || typeof input.path !== "string" || !input.path.startsWith("xd://")) return false; + name = input.path.slice(5); + const content = input.content; + input = undefined; + if (typeof content === "string" && content.length <= 64 * 1024) { + try { const parsed: unknown = JSON.parse(content); if (object(parsed)) input = parsed; } catch { /* Count malformed dispatch, without mapping its targets. */ } + } + } + const match = /^(mcp__semctx_semctx_|mcp__semctx_)([a-z_]{1,64})$/.exec(name); + if (!match) return false; + // xd dispatch may also emit the wrapped call with the same host call ID. + if (this.calls.has(event.toolCallId)) return false; + const counts = this.traffic.get(root) ?? { sent: 0, received: 0, failed: 0 }; + counts.sent++; + this.traffic.set(root, counts); + let selector: ObservedCall["selector"] = input ? null : false; + if (input && input.codebase !== undefined && input.codebase !== null) selector = boundedText(input.codebase) ? input.codebase : false; + // Multi-codebase/query scope syntax is not an exact local checkout selector. + if (input && (input.scope !== undefined && input.scope !== null || input.codebase_ids !== undefined && input.codebase_ids !== null)) selector = false; + const targets: ObservedCall["targets"] = []; + if (input) { + if (boundedText(input.path)) targets.push({ value: input.path, source: true }); + for (const value of [input.symbol, input.from, input.to]) if (boundedText(value)) targets.push({ value, source: false }); + if (Array.isArray(input.symbols)) for (const value of input.symbols.slice(0, LIMIT)) if (boundedText(value)) targets.push({ value, source: false }); + } + this.calls.set(event.toolCallId, { root, name, operation: match[2], server: match[1] === "mcp__semctx_" ? "semctx" : "semctx:semctx", selector, targets }); + return true; + } + + observeResult(event: Record, display: Display | undefined, retainFeedback: boolean): boolean { + if (typeof event.toolCallId !== "string") return false; + const call = this.calls.get(event.toolCallId); + if (!call) return false; + this.calls.delete(event.toolCallId); + // Host ToolResultEvent carries details directly; xd write results carry xdev.inner. + let details = event.details; + let trustedRoute = event.toolName === call.name; + if (event.toolName === "write" && object(details) && object(details.xdev)) { + trustedRoute = details.xdev.mode === "execute" && details.xdev.tool === call.name; + details = details.xdev.inner; + } + const failed = event.isError === true || (trustedRoute && object(details) && details.isError === true); + const counts = this.traffic.get(call.root); + if (counts) { counts.received++; if (failed) counts.failed++; } + let targets: string[] = failed && trustedRoute && retainFeedback ? this.targets(call, display) : []; + const root = this.matches(call, display, false); + const hasCarrier = object(details) && object(details.mcpMeta) && details.mcpMeta.semctlDisplay !== undefined; + const returned = hasCarrier && trustedRoute && object(details) && details.serverName === call.server && details.mcpToolName === call.operation && object(details.mcpMeta) + ? this.readDisplay(details.mcpMeta.semctlDisplay, call.operation) : undefined; + const scoped = returned && root && display && this.returnedScope(returned, display, root); + if (hasCarrier && !scoped) { this.bindings.delete(this.bindingKey(call)); targets = []; } + if (!failed && scoped && returned && root && display) { + const parentScoped = returned.identity.codebaseId !== null && returned.identity.codebaseId === display.identity.codebaseId; + if (parentScoped) this.bindings.set(this.bindingKey(call), returned.identity.codebaseId!); + if (retainFeedback) { + const add = (value: string | null, codebaseId: string | null = null) => { + if (targets.length >= LIMIT || !value || (codebaseId === null ? !parentScoped : codebaseId !== display.identity.codebaseId)) return; + const source = this.source(value, root); + if (source && !targets.includes(source)) targets.push(source); + }; + if (returned.kind === "search") returned.hits.forEach(hit => add(hit.path, hit.codebaseId)); + else if (returned.kind === "batch") returned.symbols.forEach(symbol => symbol.hits.forEach(hit => add(hit.path, hit.codebaseId))); + else if (returned.kind === "source") add(returned.path); + else if (returned.kind === "graph") for (const node of returned.nodes) { + if (node.hit) add(node.hit.path, node.hit.codebaseId); + else if (node.path) add(node.path); + } + } + } + if (!retainFeedback) return true; + const checkout = this.matches(call, display, false); + if (targets.length && checkout && display) { + this.receipts.push({ root: call.root, checkout, codebaseId: display.identity.codebaseId, + receipt: { id: ++this.nextReceipt, operation: call.operation, outcome: failed ? "failed" : "returned", targets }, expires: this.clock.now() + RECEIPT_MS }); + if (this.receipts.length > 32) this.receipts.shift(); + this.scheduleExpiry(); + } + return true; + } + private returnedScope(returned: Display, overview: Display, root: string): boolean { + const identity = returned.identity; + if (identity.codebaseId !== null && identity.codebaseId !== overview.identity.codebaseId) return false; + if (identity.localRoot !== null && this.normalizeRoot(identity.localRoot) !== root) return false; + return true; + } + private scheduleExpiry(): void { + this.cancelExpiry?.(); + this.cancelExpiry = undefined; + const generation = ++this.expiryGeneration; + if (!this.receipts.length) return; + const expires = Math.min(...this.receipts.map(entry => entry.expires)); + this.cancelExpiry = this.clock.schedule(() => { + if (generation !== this.expiryGeneration) return; + this.cancelExpiry = undefined; + const now = this.clock.now(), prior = this.receipts.length; + this.receipts = this.receipts.filter(entry => entry.expires > now); + this.scheduleExpiry(); + if (this.receipts.length !== prior) this.changed(); + }, Math.max(0, expires - this.clock.now())); + } + snapshot(root: string, display: Display | undefined): Pick { + const calls = [...this.calls.values()].filter(call => call.root === root); + const checkout = this.checkout(display, root); + return { + operations: calls.map(call => call.operation), + targets: [...new Set(calls.flatMap(call => this.targets(call, display)))].slice(0, LIMIT), + recent: this.receipts.filter(entry => entry.root === root && entry.checkout === checkout && entry.codebaseId === display?.identity.codebaseId).map(entry => entry.receipt), + requests: this.traffic.get(root) ?? { sent: 0, received: 0, failed: 0 }, + }; + } +} diff --git a/plugins/semctx/adapters/omp/graph-appearance-panel.test.ts b/plugins/semctx/adapters/omp/graph-appearance-panel.test.ts new file mode 100644 index 0000000..97156c2 --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-appearance-panel.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, test } from "bun:test"; +import type { DescribeContext, NativeNode } from "@oh-my-pi/pi-tui"; +import { DEFAULT_GRAPH_APPEARANCE, GraphAppearanceStore } from "./graph-appearance"; +import { GraphAppearancePanel, type GraphAppearanceKeyMatcher } from "./graph-appearance-panel"; +import { asNativeElement } from "./native-elements"; + +const native: DescribeContext = { cols: 40, dark: true, reduceMotion: false, supports: () => true, feature: () => true }; +const match: GraphAppearanceKeyMatcher = (data, key) => data === key; +function nodes(root: NativeNode): NativeNode[] { + const result = [root]; + for (const child of root.c ?? []) if ("k" in child) result.push(...nodes(child)); + return result; +} +function action(panel: GraphAppearancePanel, act: string): void { + panel.handleNativeEvent({ type: "action", key: "", act, mods: [] }); +} +function control(panel: GraphAppearancePanel, key: string, cx = native) { + const found = nodes(panel.describe(cx)).find(node => node.key === key); + if (!found) throw new Error(`Missing native control ${key}`); + return found; +} +function harness(store = new GraphAppearanceStore()) { + let renders = 0, closes = 0; + const panel = new GraphAppearancePanel(store, () => renders++, () => closes++, match); + return { panel, store, get renders() { return renders; }, get closes() { return closes; } }; +} + +// All SDK imports here are type-only; the input matcher is a deterministic instance dependency. +describe("native graph appearance panel", () => { + + test("clicks change live preferences immediately and expose independent selected states", async () => { + const h = harness(); + action(h.panel, "appearance:layout:radial"); + action(h.panel, "appearance:color:neutral"); + action(h.panel, "appearance:detail:detailed"); + action(h.panel, "appearance:labels:hover"); + action(h.panel, "appearance:links:clear"); + action(h.panel, "appearance:motion:off"); + action(h.panel, "appearance:popup:large"); + expect(h.store.state.value).toEqual({ layout: "radial", color: "neutral", detail: "detailed", labels: "hover", links: "clear", motion: "off", popup: "large" }); + for (const key of ["layout:radial", "color:neutral", "detail:detailed", "labels:hover", "links:clear", "motion:off", "popup:large"]) { + expect(asNativeElement(control(h.panel, `appearance:${key}`))?.p.attrs?.["aria-pressed"]).toBe(true); + } + expect(asNativeElement(control(h.panel, "appearance:motion:system"))?.p.attrs?.["aria-pressed"]).toBe(false); + expect(asNativeElement(control(h.panel, "appearance:popup:small"))?.p.attrs?.["aria-pressed"]).toBe(false); + expect(h.renders).toBeGreaterThan(0); + action(h.panel, "appearance:layout:unknown"); + action(h.panel, "appearance:unknown:off"); + expect(h.store.state.value.layout).toBe("radial"); + action(h.panel, "reset"); + await h.store.flush(); + expect(h.store.state.value).toEqual(DEFAULT_GRAPH_APPEARANCE); + h.panel.dispose(); + }); + + test("keyboard moves rows, wraps choices in both directions, cycles with Enter and reaches reset/close", () => { + const h = harness(); + h.panel.handleInput("left"); + expect(h.store.state.value.layout).toBe("components"); + h.panel.handleInput("right"); + expect(h.store.state.value.layout).toBe("map"); + h.panel.handleInput("enter"); + expect(h.store.state.value.layout).toBe("force"); + h.panel.handleInput("down"); + h.panel.handleInput("right"); + expect(h.store.state.value.color).toBe("structure"); + for (let i = 0; i < 4; i++) h.panel.handleInput("down"); + h.panel.handleInput("enter"); + expect(h.store.state.value.motion).toBe("off"); + h.panel.handleInput("down"); // Popup size. + h.panel.handleInput("right"); + expect(h.store.state.value.popup).toBe("medium"); + h.panel.handleInput("left"); + h.panel.handleInput("left"); + expect(h.store.state.value.popup).toBe("large"); + h.panel.handleInput("down"); // Reset defaults. + h.panel.handleInput("enter"); + expect(h.store.state.value).toEqual(DEFAULT_GRAPH_APPEARANCE); + // Reset returns to the first row; Close follows the seven rows and Reset defaults. + for (let i = 0; i < 8; i++) h.panel.handleInput("down"); + h.panel.handleInput("enter"); + expect(h.closes).toBe(1); + h.panel.handleInput("escape"); + expect(h.closes).toBe(2); + h.panel.dispose(); + }); + + test("all five layouts expose their meaning without hover and remain reachable by pointer and keyboard", () => { + const h = harness(); + const help = () => { + const node = nodes(h.panel.describe(native)).find(node => node.key === "help"); + return node?.k === "text" ? node.p : undefined; + }; + const layouts = nodes(h.panel.describe(native)).find(node => node.p?.role === "semctl.appearance.layouts"); + expect(layouts?.c?.map(child => "k" in child ? child.key : undefined)).toEqual( + ["map", "force", "radial", "layers", "components"].map(layout => `appearance:layout:${layout}`)); + expect(asNativeElement(control(h.panel, "appearance:layout:map"))?.p.text).toBe("Concept map"); + expect(help()?.text).toContain("Groups placed by shared doc concepts and terms; position is not a score."); + h.panel.handleInput("right"); + expect(help()?.text).toContain("Nearby nodes do not imply extra relationships"); + h.panel.handleInput("right"); + expect(help()?.text).toContain("undirected hops"); + h.panel.handleInput("right"); + expect(help()?.text).toContain("Cycles share a column"); + h.panel.handleInput("right"); + expect(h.store.state.value.layout).toBe("components"); + expect(help()?.text).toContain("Rows within a block do not imply direction"); + expect(asNativeElement(control(h.panel, "appearance:layout:components"))?.p.attrs?.["aria-pressed"]).toBe(true); + h.panel.handleInput("right"); + expect(h.store.state.value.layout).toBe("map"); + action(h.panel, "appearance:layout:components"); + expect(h.store.state.value.layout).toBe("components"); + expect(help()?.aria).toBe(help()?.text); + action(h.panel, "appearance:detail:calm"); + expect(help()?.text).toContain("Overview maps"); + expect(help()?.text).toContain("Exact node navigation stays available"); + h.panel.handleInput("up"); + expect(help()?.text).toContain("Color"); + expect(h.store.state.value.layout).toBe("components"); + h.panel.dispose(); + }); + + test("only unselected option controls own short hover help while selected facts stay visible", () => { + const h = harness(); + for (const supportsElements of [true, false]) { + const cx: DescribeContext = { ...native, supports: kind => supportsElements || String(kind) !== "el" }; + const all = nodes(h.panel.describe(cx)); + for (const node of all.filter(node => node.p?.role === "semctl.appearance.row" || node.p?.role === "semctl.appearance.label")) { + expect(node.p?.title).toBeUndefined(); + } + for (const node of all) { + const lines = node.p?.title?.split("\n") ?? []; + expect(lines.length).toBeLessThanOrEqual(3); + for (const line of lines) expect(line.length).toBeLessThanOrEqual(88); + } + expect(control(h.panel, "appearance:layout:map", cx).p?.title).toBeUndefined(); + expect(control(h.panel, "appearance:layout:radial", cx).p?.title?.split("\n")).toEqual(["Layout · Radial", expect.stringContaining("undirected hops")]); + expect(control(h.panel, "appearance:layout:map", cx).p?.aria).toBeTruthy(); + } + action(h.panel, "appearance:color:neutral"); + const help = nodes(h.panel.describe(native)).find(node => node.key === "help"); + expect(help?.k === "text" && help.p?.text).toContain("not similarity, confidence, or indexing status"); + expect(help?.p?.aria).toBe(help?.k === "text" ? help.p?.text : undefined); + h.panel.dispose(); + }); + + test("supports stock-style ANSI keys when no matcher is supplied", () => { + const store = new GraphAppearanceStore(); + let closes = 0; + const panel = new GraphAppearancePanel(store, () => {}, () => closes++); + panel.handleInput("\x1b[C"); + expect(store.state.value.layout).toBe("force"); + panel.handleInput("\x1b[B"); + panel.handleInput("\r"); + expect(store.state.value.color).toBe("structure"); + panel.handleInput("\x1b"); + expect(closes).toBe(1); + panel.dispose(); + }); + + test("the Popup size row offers Small, Medium and Large and saves the choice like other preferences", async () => { + const writes: string[] = []; + const store = new GraphAppearanceStore("preferences.json", { + async read() { return JSON.stringify({ version: 2, appearance: DEFAULT_GRAPH_APPEARANCE }); }, + async writeAtomic(_file, contents) { writes.push(contents); }, + }); + const h = harness(store); + await store.load(); + const row = nodes(h.panel.describe(native)).filter(node => node.p?.role === "semctl.appearance.row").at(-1); + expect(row?.p?.aria).toBe("Popup size"); + const options = row ? nodes(row).map(asNativeElement).filter(node => node?.p.tag === "button") : []; + expect(options.map(node => [node?.key, node?.p.text, node?.p.attrs?.["aria-pressed"]])).toEqual([ + ["appearance:popup:small", "Small", true], ["appearance:popup:medium", "Medium", false], ["appearance:popup:large", "Large", false]]); + action(h.panel, "appearance:popup:medium"); + await store.flush(); + expect(store.state.value.popup).toBe("medium"); + expect(JSON.parse(writes.at(-1)!)).toEqual({ version: 2, appearance: { ...DEFAULT_GRAPH_APPEARANCE, popup: "medium" } }); + expect(h.panel.render(80)).toContain("> Popup size: Medium"); + h.panel.dispose(); + }); + + test("motion selection stays explicit across system reduced-motion changes and narrow descriptions", () => { + const h = harness(); + action(h.panel, "appearance:motion:off"); + for (const reduceMotion of [true, false]) { + const cx = { ...native, cols: 20, reduceMotion }; + expect(asNativeElement(control(h.panel, "appearance:motion:off", cx))?.p.attrs?.["aria-pressed"]).toBe(true); + expect(nodes(h.panel.describe(cx)).some(node => node.k === "spinner" || node.k === "shimmer")).toBe(false); + } + action(h.panel, "appearance:motion:system"); + expect(h.store.state.value.motion).toBe("system"); + expect(asNativeElement(control(h.panel, "appearance:motion:system", { ...native, reduceMotion: true }))?.p.attrs?.["aria-pressed"]).toBe(true); + h.panel.dispose(); + }); + + test("save errors remain visible and retry saves the selected in-memory value", async () => { + const writeStarted = Promise.withResolvers(), writeGate = Promise.withResolvers(); + let contents = JSON.stringify({ version: 2, appearance: DEFAULT_GRAPH_APPEARANCE }), calls = 0; + const store = new GraphAppearanceStore("preferences.json", { + async read() { return contents; }, + async writeAtomic(_file, next) { + if (++calls === 1) { writeStarted.resolve(); await writeGate.promise; } + contents = next; + }, + }); + const h = harness(store); + await store.load(); + action(h.panel, "appearance:color:neutral"); + await writeStarted.promise; + expect(store.state.saving).toBe(true); + const saving = nodes(h.panel.describe(native)).find(node => node.p?.role === "semctl.appearance.status"); + expect(saving?.k === "text" && saving.p?.text).toMatch(/saving/i); + writeGate.reject(new Error("disk is read-only")); + await store.flush(); + const error = nodes(h.panel.describe(native)).find(node => node.p?.role === "semctl.appearance.error"); + expect(error?.p?.tone).toBe("error"); + expect(error?.k === "text" && error.p?.text).toContain("disk is read-only"); + expect(asNativeElement(control(h.panel, "appearance:color:neutral"))?.p.attrs?.["aria-pressed"]).toBe(true); + expect(control(h.panel, "retry").p?.actions?.click).toBe("retry"); + action(h.panel, "retry"); + await store.flush(); + expect(JSON.parse(contents).appearance.color).toBe("neutral"); + expect(store.state.error).toBeUndefined(); + expect(nodes(h.panel.describe(native)).some(node => node.key === "retry")).toBe(false); + const saved = nodes(h.panel.describe(native)).find(node => node.p?.role === "semctl.appearance.status"); + expect(saved?.k === "text" && saved.p?.text).toMatch(/saved/i); + action(h.panel, "reset"); + await store.flush(); + expect(JSON.parse(contents).appearance).toEqual(DEFAULT_GRAPH_APPEARANCE); + h.panel.dispose(); + }); + + test("retry can recover a corrupt load without replacing it, and reset explicitly repairs it", async () => { + let contents = "not JSON", writes = 0; + const store = new GraphAppearanceStore("preferences.json", { + async read() { return contents; }, + async writeAtomic(_file, next) { writes++; contents = next; }, + }); + const h = harness(store); + await store.load(); + expect(store.state.error).toBeTruthy(); + action(h.panel, "retry"); + await store.flush(); + expect(writes).toBe(0); + expect(contents).toBe("not JSON"); + for (let i = 0; i < 7; i++) h.panel.handleInput("down"); + h.panel.handleInput("enter"); // Keyboard retry, not an appearance edit. + await store.flush(); + expect(writes).toBe(0); + action(h.panel, "reset"); + await store.flush(); + expect(writes).toBe(1); + expect(store.state.error).toBeUndefined(); + h.panel.dispose(); + }); + + test("supported native item fallback remains actionable and keyboard-selectable", () => { + const h = harness(); + const cx: DescribeContext = { ...native, supports: kind => (kind as string) !== "el" }; + const target = control(h.panel, "appearance:links:clear", cx); + expect(target.k).toBe("item"); + expect(target.p?.aria).toBeTruthy(); + expect(target.p?.actions?.click).toBe("appearance:links:clear"); + h.panel.handleNativeEvent({ type: "activate", key: "body", item: "appearance:links:clear" }); + expect(h.store.state.value.links).toBe("clear"); + expect(control(h.panel, "appearance:links:clear", cx).p?.tone).toBe("accent"); + h.panel.dispose(); + }); + + test("dispose unsubscribes and rejects late native events and keys after close or session reset", async () => { + const h = harness(); + action(h.panel, "appearance:motion:off"); + h.panel.dispose(); + h.panel.dispose(); + const renders = h.renders; + action(h.panel, "appearance:motion:system"); + action(h.panel, "reset"); + action(h.panel, "close"); + h.panel.handleNativeEvent({ type: "activate", key: "", item: "appearance:layout:layers" }); + h.panel.handleInput("right"); + h.panel.handleInput("escape"); + expect(h.store.state.value).toEqual({ ...DEFAULT_GRAPH_APPEARANCE, motion: "off" }); + expect(h.closes).toBe(0); + await h.store.update({ color: "neutral" }); + expect(h.renders).toBe(renders); + }); +}); diff --git a/plugins/semctx/adapters/omp/graph-appearance-panel.ts b/plugins/semctx/adapters/omp/graph-appearance-panel.ts new file mode 100644 index 0000000..856e41d --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-appearance-panel.ts @@ -0,0 +1,184 @@ +import type { Component, DescribeContext, NativeNode, NativeUiEvent } from "@oh-my-pi/pi-tui"; +import { GraphAppearanceStore, type GraphAppearance } from "./graph-appearance"; +import { nativeElement, supportsElements } from "./native-elements"; +import { compactTooltip } from "./tooltip-text"; + +/** Public matchesKey KeyIds, not KeybindingsManager action names. Inject OMP's parser for Kitty/remapped input. */ +export type GraphAppearanceKeyMatcher = (data: string, key: "up" | "down" | "left" | "right" | "enter" | "escape") => boolean; + +// A standalone panel still understands standard ANSI; the production host injects its stock key parser. +const ansiKeys: Record[1], readonly string[]> = { + up: ["\x1b[A", "\x1bOA"], down: ["\x1b[B", "\x1bOB"], left: ["\x1b[D", "\x1bOD"], + right: ["\x1b[C", "\x1bOC"], enter: ["\r", "\n"], escape: ["\x1b"], +}; +const standardInput: GraphAppearanceKeyMatcher = (data, key) => ansiKeys[key].includes(data); + +type AppearanceRow = { [K in keyof GraphAppearance]: { + key: K; label: string; hint: string; + options: readonly { value: GraphAppearance[K]; label: string; hint: string }[]; +} }[keyof GraphAppearance]; +const rows: readonly AppearanceRow[] = [ + { key: "layout", label: "Layout", hint: "Choose a stable arrangement; activity and color do not move nodes.", options: [ + { value: "map", label: "Concept map", hint: "Groups placed by shared doc concepts and terms; position is not a score." }, + { value: "force", label: "Force", hint: "Balance recorded links. Nearby nodes do not imply extra relationships." }, + { value: "radial", label: "Radial", hint: "Rings show undirected hops from roots, or a high-degree node. Disconnected sets stay separate." }, + { value: "layers", label: "Layers", hint: "Recorded direction runs left to right. Cycles share a column. Ordering reduces link crossings." }, + { value: "components", label: "Components", hint: "Separate connected sets into blocks. Rows within a block do not imply direction or a hierarchy." }, + ] }, + { key: "color", label: "Color", hint: "Shared across all graph views. Colors identify categories, not similarity, confidence, or indexing status.", options: [ + { value: "concepts", label: "Shared", hint: "One codebase palette: verified concepts, then source-directory colors." }, + { value: "structure", label: "Structure", hint: "Source-directory colors shared across graph views. Mixed groups stay neutral." }, + { value: "neutral", label: "Neutral", hint: "Quiet neutral nodes; status and selection remain visible." }, + ] }, + { key: "detail", label: "Detail", hint: "Overview maps only. Folded groups keep every indexed node reachable; graph evidence is unchanged.", options: [ + { value: "calm", label: "Calm", hint: "Overview maps: up to 12 visible groups and 24 link routes. Exact node navigation stays available." }, + { value: "balanced", label: "Balanced", hint: "Overview maps: up to 16 visible groups and 48 link routes. Exact node navigation stays available." }, + { value: "detailed", label: "Detailed", hint: "Overview maps: up to 24 visible groups and 72 link routes. Exact node navigation stays available." }, + ] }, + { key: "labels", label: "Labels", hint: "Names remain available in node tooltips and group exploration.", options: [ + { value: "key", label: "Key", hint: "Show the most useful labels without crowding the map." }, + { value: "more", label: "More", hint: "Show more visible group names." }, + { value: "hover", label: "On hover", hint: "Keep the map quiet; inspect names by hovering or selecting." }, + ] }, + { key: "links", label: "Links", hint: "Change contrast, never connections or direction.", options: [ + { value: "soft", label: "Soft", hint: "Subtle relationship lines with preserved direction cues." }, + { value: "clear", label: "Clear", hint: "Higher-contrast relationship lines with the same direction cues." }, + ] }, + { key: "motion", label: "Motion", hint: "System respects Reduce Motion; Off disables graph animation.", options: [ + { value: "system", label: "System", hint: "Follow the terminal's reduced-motion preference." }, + { value: "off", label: "Off", hint: "Disable graph animation regardless of the system preference." }, + ] }, + { key: "popup", label: "Popup size", hint: "Live popup only. The grip in the popup header steps through the same sizes.", options: [ + { value: "small", label: "Small", hint: "Compact map beside the editor." }, + { value: "medium", label: "Medium", hint: "Wider map and more busiest groups." }, + { value: "large", label: "Large", hint: "Overview-size map with the most labels." }, + ] }, +]; + +/** Compact native popup. The host owns its NativeSurface and calls dispose when the popup closes. */ +export class GraphAppearancePanel implements Component { + readonly nativeOverlay = { role: "omp.overlay.semctl-appearance", size: "sm" as const, head: "Graph appearance" }; + private selectedRow = 0; + private reveal = 0; + private disposed = false; + private readonly unsubscribe: () => void; + + constructor(private readonly store: GraphAppearanceStore, private readonly requestRender: () => void, + private readonly close: () => void, private readonly matches: GraphAppearanceKeyMatcher = standardInput) { + this.unsubscribe = store.subscribe(() => { if (!this.disposed) this.requestRender(); }); + void store.load(); + } + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.unsubscribe(); + } + + private choose(index: number, value: string): void { + const row = rows[index]; + if (!row || !row.options.some(option => option.value === value)) return; + this.selectedRow = index; + void this.store.update({ [row.key]: value } as Partial); + } + private actions(): readonly string[] { + return this.store.state.error && !this.store.state.saving ? ["retry", "reset", "close"] : ["reset", "close"]; + } + private activate(action: string): void { + if (action === "close") this.close(); + else if (action === "reset") { this.selectedRow = 0; void this.store.reset(); } + else if (action === "retry") void this.store.retry(); + else { + const match = /^appearance:([a-z]+):([a-z]+)$/.exec(action); + if (match) this.choose(rows.findIndex(row => row.key === match[1]), match[2]); + } + } + handleNativeEvent(event: NativeUiEvent): void { + if (this.disposed) return; + if (event.type === "action") this.activate(event.act); + else if (event.type === "activate") this.activate(event.item); + } + handleInput(data: string): void { + if (this.disposed) return; + if (this.matches(data, "escape")) { this.close(); return; } + const actions = this.actions(); + this.selectedRow = Math.min(this.selectedRow, rows.length + actions.length - 1); + if (this.matches(data, "up") || this.matches(data, "down")) { + this.selectedRow = Math.max(0, Math.min(rows.length + actions.length - 1, + this.selectedRow + (this.matches(data, "up") ? -1 : 1))); + this.reveal++; + this.requestRender(); + return; + } + const enter = this.matches(data, "enter"); + if (this.selectedRow >= rows.length) { + if (enter) this.activate(actions[this.selectedRow - rows.length]); + return; + } + if (!enter && !this.matches(data, "left") && !this.matches(data, "right")) return; + const row = rows[this.selectedRow]; + const current = row.options.findIndex(option => option.value === this.store.state.value[row.key]); + const delta = this.matches(data, "left") ? -1 : 1; + this.choose(this.selectedRow, row.options[(current + delta + row.options.length) % row.options.length].value); + } + + private help(): string { + const row = rows[this.selectedRow]; + if (row) { + const option = row.options.find(option => option.value === this.store.state.value[row.key])!; + return `${row.label} · ${option.label}: ${option.hint}\n${row.hint}`; + } + const action = this.actions()[this.selectedRow - rows.length]; + return action === "reset" ? "Reset all graph appearance preferences to defaults." + : action === "retry" ? "Retry loading or saving local appearance preferences." : "Close graph appearance. Current choices stay active."; + } + + private button(cx: DescribeContext, action: string, label: string, title: string, selected = false, active = false): NativeNode { + const props = { title: selected ? undefined : title, aria: title, actions: { click: action }, grow: 1, min: { w: 0 } }; + if (supportsElements(cx)) return nativeElement({ k: "el", key: action, p: { + ...props, tag: "button", text: label, class: "semctl-appearance-option", + attrs: { "aria-pressed": selected, "data-active": active, "data-selected": selected }, + } }); + return { k: "item", key: action, p: { ...props, label: `${selected ? "✓ " : ""}${label}`, tone: selected || active ? "accent" : "muted" } }; + } + describe(cx: DescribeContext): NativeNode { + const { value, ready, saving, error } = this.store.state; + const actions = this.actions(); + this.selectedRow = Math.min(this.selectedRow, rows.length + actions.length - 1); + const controls = rows.map((row, index): NativeNode => ({ + k: "col", key: row.key, reveal: this.selectedRow === index ? { at: "nearest", n: this.reveal } : undefined, + p: { role: "semctl.appearance.row", gap: "xs", aria: row.label }, c: [ + { k: "text", p: { role: "semctl.appearance.label", text: row.label, tone: this.selectedRow === index ? "accent" : "muted" } }, + { k: "row", p: { role: row.key === "layout" ? "semctl.appearance.layouts" : "semctl.appearance.options", gap: "xs", wrap: true, aria: row.label }, c: row.options.map(option => this.button(cx, + `appearance:${row.key}:${option.value}`, option.label, compactTooltip(`${row.label} · ${option.label}`, option.hint), + value[row.key] === option.value, this.selectedRow === index && value[row.key] === option.value)) }, + ], + })); + const status = !ready ? "Loading saved preferences…" : saving ? "Saving appearance…" : error ? "Using in-memory appearance" + : !this.store.persistent ? "In memory for this session" : this.store.saved ? "Saved locally" : "Defaults · saved when changed"; + return { k: "col", p: { role: "semctl.appearance", gap: "sm" }, c: [ + { k: "text", p: { text: "Live + project graph · shared color in all graphs", tone: "muted", wrap: "word" } }, + { k: "col", key: "body", p: { role: "semctl.appearance.body", gap: "sm", max: { h: "22lines" } }, c: [ + ...controls, + { k: "text", p: { text: this.store.persistent ? "This OMP profile · no server changes" : "This session · no server changes", tone: "muted", wrap: "word" } }, + ...(error ? [{ k: "text" as const, key: "error", p: { role: "semctl.appearance.error", text: error, tone: "error" as const, wrap: "word" as const, aria: error } }] : []), + ] }, + { k: "text", key: "help", p: { role: "semctl.appearance.help", text: this.help(), tone: "muted", wrap: "word", aria: this.help() } }, + { k: "text", key: "save-state", p: { role: "semctl.appearance.status", text: status, tone: error ? "error" : "muted", aria: status } }, + { k: "row", key: "actions", p: { gap: "xs", wrap: true }, c: actions.map((action, index) => ({ + ...this.button(cx, action, action === "reset" ? "Reset defaults" : action === "close" ? "Close" : "Retry", + action === "reset" ? "Reset all graph appearance preferences to defaults" : action === "close" ? "Close graph appearance" : "Retry loading or saving local appearance preferences", + false, this.selectedRow === rows.length + index), + reveal: this.selectedRow === rows.length + index ? { at: "nearest" as const, n: this.reveal } : undefined, + })) }, + { k: "text", p: { text: "↑↓ rows · ←→ / Enter choices · Esc close", tone: "muted", wrap: "word" } }, + ] }; + } + render(width: number): readonly string[] { + const actions = this.actions(); + return ["Graph appearance", ...rows.map((row, index) => `${this.selectedRow === index ? "> " : " "}${row.label}: ${row.options.find(option => option.value === this.store.state.value[row.key])!.label}`), + ...(this.store.state.error ? [this.store.state.error] : []), + ...actions.map((action, index) => `${this.selectedRow === rows.length + index ? "> " : " "}${action === "reset" ? "Reset defaults" : action === "close" ? "Close" : "Retry"}`), + this.help(), + "↑↓ rows · ←→ / Enter choices · Esc close"].map(line => line.slice(0, Math.max(0, width))); + } +} diff --git a/plugins/semctx/adapters/omp/graph-appearance-styles.ts b/plugins/semctx/adapters/omp/graph-appearance-styles.ts new file mode 100644 index 0000000..c03a8c1 --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-appearance-styles.ts @@ -0,0 +1,20 @@ +/** Registered by NativeSurface, separately from the component to avoid runtime import cycles. */ +export const GRAPH_APPEARANCE_STYLES: readonly { name: string; css: string }[] = [{ name: "semctl-graph-appearance", css: ` +.sf-overlay[data-role="omp.overlay.semctl-appearance"]{backdrop-filter:none;background:transparent} +.sf-overlay[data-role="omp.overlay.semctl-appearance"]>.sf-ov-card{width:min(390px,calc(100vw - 32px));max-height:calc(100vh - 80px)} +[data-role="omp.overlay.semctl-appearance"] .sf-ov-body{overflow:hidden;min-width:0} +[data-role="semctl.appearance"]{min-width:0;font-size:12px} +[data-role="semctl.appearance.body"]{flex:1 1 auto;min-width:0;min-height:0;overflow-x:hidden;overflow-y:auto;max-height:min(380px,calc(100vh - 360px));overscroll-behavior:contain} +[data-role="semctl.appearance.row"]{display:flex;flex-direction:row;align-items:center;gap:8px;min-width:0} +[data-role="semctl.appearance.label"]{flex:0 0 68px;min-width:68px} +[data-role="semctl.appearance.options"]{flex:1 1 0;min-width:0;flex-wrap:nowrap} +[data-role="semctl.appearance.layouts"]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));flex:1 1 0;min-width:0;gap:4px} +[data-role="semctl.appearance.layouts"]>:first-child{grid-column:span 2} +.semctl-appearance-option{display:flex;align-items:center;justify-content:center;flex:1 1 0;min-width:0;min-height:28px;padding:5px 8px;border:1px solid var(--l2);border-radius:var(--r-ctl);background:var(--l0);color:var(--t2);font:inherit;cursor:pointer;white-space:normal;text-align:center;overflow-wrap:anywhere} +.semctl-appearance-option:hover,.semctl-appearance-option:focus-visible{background:var(--l1);color:var(--t1);outline:1px solid var(--t2);outline-offset:1px} +.semctl-appearance-option[data-selected=true]{color:var(--t1);background:var(--l2);border-color:var(--t2);font-weight:600} +.semctl-appearance-option[data-active=true]{outline:1px solid var(--t2);outline-offset:1px} +[data-role="semctl.appearance.error"]{overflow-wrap:anywhere} +[data-role="semctl.appearance.help"]{min-height:3.5em;padding:7px 8px;border:1px solid var(--l1);border-radius:var(--r-ctl);background:var(--l0);line-height:1.4;overflow-wrap:anywhere} +@media (max-width:450px){[data-role="semctl.appearance.row"]{flex-direction:column;align-items:stretch;gap:4px}[data-role="semctl.appearance.label"]{flex:0 0 auto;min-width:0}[data-role="semctl.appearance.options"]{flex:0 1 auto;flex-wrap:wrap}} +` }]; diff --git a/plugins/semctx/adapters/omp/graph-appearance.test.ts b/plugins/semctx/adapters/omp/graph-appearance.test.ts new file mode 100644 index 0000000..64843c9 --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-appearance.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DEFAULT_GRAPH_APPEARANCE, GraphAppearanceStore, type GraphAppearance, type GraphAppearancePersistence } from "./graph-appearance"; + +function document(patch: Partial = {}): string { + return JSON.stringify({ version: 2, appearance: { ...DEFAULT_GRAPH_APPEARANCE, ...patch } }); +} +/** A version 1 appearance object: the six keys that predate the popup size. */ +function versionOne(patch: Partial = {}): Record { + return Object.fromEntries(Object.entries({ ...DEFAULT_GRAPH_APPEARANCE, ...patch }).filter(([key]) => key !== "popup")); +} +class MemoryPreferences implements GraphAppearancePersistence { + readonly writes: { file: string; contents: string }[] = []; + reads = 0; + failure?: Error; + constructor(public contents = document()) {} + async read(_file: string): Promise { this.reads++; return this.contents; } + async writeAtomic(file: string, contents: string): Promise { + this.writes.push({ file, contents }); + if (this.failure) throw this.failure; + this.contents = contents; + } +} + +// These tests import only Node and the pure store, never OMP's runtime or native bindings. +describe("graph appearance preferences", () => { + test("missing file uses defaults without writing; atomic saves reopen and touch only the preference file", async () => { + const directory = await mkdtemp(join(tmpdir(), "semctl-appearance-")); + try { + const file = join(directory, "preferences", "graph-appearance.json"); + const store = new GraphAppearanceStore(file); + expect(store.state.ready).toBe(false); + await store.load(); + expect(store.state.value).toEqual(DEFAULT_GRAPH_APPEARANCE); + expect(store.state.error).toBeUndefined(); + expect(store.state.ready).toBe(true); + expect(store.saved).toBe(false); + await store.flush(); + expect(await readdir(directory)).toEqual([]); + await store.update({ layout: "layers", color: "structure", labels: "hover", motion: "off" }); + expect(store.saved).toBe(true); + expect(JSON.parse(await readFile(file, "utf8"))).toEqual({ version: 2, appearance: store.state.value }); + expect(await readdir(join(directory, "preferences"))).toEqual(["graph-appearance.json"]); + const reopened = new GraphAppearanceStore(file); + await reopened.load(); + expect(reopened.state.value).toEqual(store.state.value); + expect(reopened.saved).toBe(true); + await reopened.reset(); + const reset = new GraphAppearanceStore(file); + await reset.load(); + expect(reset.state.value).toEqual(DEFAULT_GRAPH_APPEARANCE); + } finally { await rm(directory, { recursive: true, force: true }); } + }); + + test("loads a user's version 1 file unchanged with a small popup and writes version 2 only on edit", async () => { + const contents = `${JSON.stringify({ version: 1, appearance: { layout: "radial", color: "concepts", detail: "detailed", labels: "more", links: "soft", motion: "system" } }, null, 2)}\n`; + const io = new MemoryPreferences(contents), store = new GraphAppearanceStore("graph-appearance.json", io); + await store.load(); + expect(store.state.value).toEqual({ layout: "radial", color: "concepts", detail: "detailed", labels: "more", links: "soft", motion: "system", popup: "small" }); + expect(Object.keys(store.state.value)).toEqual(Object.keys(DEFAULT_GRAPH_APPEARANCE)); + expect(store.state.error).toBeUndefined(); + expect(store.saved).toBe(true); + await store.flush(); + expect(io.writes).toHaveLength(0); + expect(io.contents).toBe(contents); + await store.update({ popup: "medium" }); + expect(JSON.parse(io.contents)).toEqual({ version: 2, appearance: { layout: "radial", color: "concepts", detail: "detailed", labels: "more", links: "soft", motion: "system", popup: "medium" } }); + }); + + test("loads every version 1 layout unchanged and round-trips version 2 with all seven keys", async () => { + for (const layout of ["force", "radial", "layers", "components"] as const) { + const appearance = versionOne({ layout, color: "structure", detail: "calm", labels: "hover", links: "clear", motion: "off" }); + const io = new MemoryPreferences(JSON.stringify({ version: 1, appearance })); + const store = new GraphAppearanceStore("preferences.json", io); + await store.load(); + expect(store.state.value).toEqual({ ...appearance, popup: "small" } as GraphAppearance); + expect(store.state.error).toBeUndefined(); + expect(io.writes).toHaveLength(0); + await store.update({ layout: "map", popup: "large" }); + const saved = { ...appearance, layout: "map", popup: "large" }; + expect(JSON.parse(io.contents)).toEqual({ version: 2, appearance: saved }); + const reopened = new GraphAppearanceStore("preferences.json", io); + await reopened.load(); + expect(reopened.state.value).toEqual(saved as GraphAppearance); + expect(reopened.state.error).toBeUndefined(); + expect(reopened.saved).toBe(true); + } + }); + + test("rejects malformed, incomplete, mixed-version, oversized and future schemas without auto-overwriting", async () => { + const malformed = [ + "{", "null", "[]", " ".repeat(8193), JSON.stringify({ version: 3, appearance: DEFAULT_GRAPH_APPEARANCE }), + JSON.stringify({ version: "2", appearance: DEFAULT_GRAPH_APPEARANCE }), + JSON.stringify({ version: 1, appearance: { layout: "force" } }), + JSON.stringify({ version: 1, appearance: DEFAULT_GRAPH_APPEARANCE }), + JSON.stringify({ version: 2, appearance: versionOne() }), + JSON.stringify({ version: 2, appearance: { ...DEFAULT_GRAPH_APPEARANCE, popup: "huge" } }), + JSON.stringify({ version: 1, appearance: versionOne({ layout: "random" as GraphAppearance["layout"] }) }), + JSON.stringify({ version: 1, appearance: { ...versionOne(), motion: false } }), + JSON.stringify({ version: 2, appearance: { ...DEFAULT_GRAPH_APPEARANCE, foreign: "leave alone" } }), + JSON.stringify({ version: 2, appearance: DEFAULT_GRAPH_APPEARANCE, unrelated: {} }), + ]; + for (const contents of malformed) { + const io = new MemoryPreferences(contents), store = new GraphAppearanceStore("only-own-preferences.json", io); + await store.load(); + expect(store.state.ready).toBe(true); + expect(store.state.value).toEqual(DEFAULT_GRAPH_APPEARANCE); + expect(store.state.error).toBeTruthy(); + await store.flush(); + await store.retry(); + expect(io.writes).toHaveLength(0); + expect(io.contents).toBe(contents); + await store.update({ links: "clear" }); + expect(io.writes).toHaveLength(1); + expect(store.state.value.links).toBe("clear"); + expect(store.state.error).toBeUndefined(); + expect(Object.keys(JSON.parse(io.contents))).toEqual(["version", "appearance"]); + } + }); + + test("validates runtime updates without accepting unknown keys, getters or unsupported values", async () => { + const io = new MemoryPreferences(), store = new GraphAppearanceStore("preferences.json", io); + await store.load(); + const inputs: unknown[] = [null, [], { color: "red" }, { links: 1 }, { motion: undefined }, { layout: "radial", other: true }, { popup: "huge" }, { popup: "sm" }, + Object.defineProperty({}, "layout", { enumerable: true, get() { throw new Error("must not execute getter"); } })]; + for (const input of inputs) { + await store.update(input as Partial); + expect(store.state.error).toContain("rejected"); + expect(store.state.value).toEqual(DEFAULT_GRAPH_APPEARANCE); + } + expect(io.writes).toHaveLength(0); + await store.update({ color: "neutral", popup: "large" }); + expect(store.state.error).toBeUndefined(); + expect(store.state.value.color).toBe("neutral"); + expect(store.state.value.popup).toBe("large"); + }); + + test("late loading merges untouched disk preferences with the latest edits and coalesces writes", async () => { + const read = Promise.withResolvers(), started = Promise.withResolvers(); + const writes: string[] = []; + const store = new GraphAppearanceStore("preferences.json", { + read() { started.resolve(); return read.promise; }, + async writeAtomic(_file, contents) { writes.push(contents); }, + }); + const loading = store.load(); + await started.promise; + const first = store.update({ layout: "radial" }); + const second = store.update({ layout: "layers", links: "clear" }); + expect(store.state.value.layout).toBe("layers"); + expect(store.state.saving).toBe(true); + read.resolve(document({ layout: "force", color: "structure", labels: "hover", motion: "off" })); + await Promise.all([loading, first, second]); + expect(store.state.value).toEqual({ ...DEFAULT_GRAPH_APPEARANCE, layout: "layers", links: "clear", color: "structure", labels: "hover", motion: "off" }); + expect(writes).toHaveLength(1); + expect(JSON.parse(writes[0]).appearance).toEqual(store.state.value); + expect(store.state.saving).toBe(false); + }); + + test("reset during loading overrides every old field rather than being replaced by disk state", async () => { + const read = Promise.withResolvers(), started = Promise.withResolvers(); + const io = new MemoryPreferences(); + const store = new GraphAppearanceStore("preferences.json", { + read() { started.resolve(); return read.promise; }, + writeAtomic: io.writeAtomic.bind(io), + }); + const loading = store.load(); + await started.promise; + const reset = store.reset(); + read.resolve(document({ layout: "radial", color: "neutral", detail: "detailed", labels: "more", links: "clear", motion: "off" })); + await Promise.all([loading, reset]); + expect(store.state.value).toEqual(DEFAULT_GRAPH_APPEARANCE); + expect(JSON.parse(io.contents).appearance).toEqual(DEFAULT_GRAPH_APPEARANCE); + }); + + test("serializes active saves and collapses rapid edits to the newest complete snapshot", async () => { + const firstStarted = Promise.withResolvers(), secondStarted = Promise.withResolvers(); + const firstWrite = Promise.withResolvers(), secondWrite = Promise.withResolvers(); + const writes: string[] = []; + let active = 0, maximum = 0; + const store = new GraphAppearanceStore("preferences.json", { + async read() { return document(); }, + async writeAtomic(_file, contents) { + const index = writes.push(contents); + maximum = Math.max(maximum, ++active); + (index === 1 ? firstStarted : secondStarted).resolve(); + try { await (index === 1 ? firstWrite : secondWrite).promise; } + finally { active--; } + }, + }); + await store.load(); + const first = store.update({ layout: "radial" }); + await firstStarted.promise; + const second = store.update({ color: "neutral" }); + const third = store.update({ layout: "layers", motion: "off" }); + expect(writes).toHaveLength(1); + expect(store.state.value).toEqual({ ...DEFAULT_GRAPH_APPEARANCE, layout: "layers", color: "neutral", motion: "off" }); + const flushing = store.flush(); + firstWrite.resolve(); + await secondStarted.promise; + expect(writes).toHaveLength(2); + expect(JSON.parse(writes[1]).appearance).toEqual(store.state.value); + expect(store.state.saving).toBe(true); + secondWrite.resolve(); + await Promise.all([first, second, third, flushing]); + expect(maximum).toBe(1); + expect(store.state.saving).toBe(false); + expect(store.saved).toBe(true); + }); + + test("save failure retains edits; retry and reset recover without rejecting UI actions", async () => { + const io = new MemoryPreferences(), store = new GraphAppearanceStore("preferences.json", io); + await store.load(); + io.failure = new Error("permission denied"); + await store.update({ color: "neutral", motion: "off" }); + expect(store.state.value.motion).toBe("off"); + expect(store.state.error).toContain("permission denied"); + expect(store.saved).toBe(false); + expect(store.state.saving).toBe(false); + await store.retry(); + expect(io.writes).toHaveLength(2); + expect(store.state.error).toBeTruthy(); + io.failure = undefined; + await store.retry(); + expect(JSON.parse(io.contents).appearance).toEqual(store.state.value); + expect(store.state.error).toBeUndefined(); + await store.reset(); + expect(JSON.parse(io.contents).appearance).toEqual(DEFAULT_GRAPH_APPEARANCE); + const reopened = new GraphAppearanceStore("preferences.json", io); + await reopened.load(); + expect(reopened.state.value).toEqual(DEFAULT_GRAPH_APPEARANCE); + }); + + test("a newer edit during a failed write gets a serialized attempt instead of being stranded", async () => { + const started = Promise.withResolvers(), write = Promise.withResolvers(); + const io = new MemoryPreferences(); + let calls = 0; + const store = new GraphAppearanceStore("preferences.json", { + read: io.read.bind(io), + async writeAtomic(file, contents) { + if (++calls === 1) { started.resolve(); await write.promise; } + else await io.writeAtomic(file, contents); + }, + }); + await store.load(); + const first = store.update({ layout: "radial" }); + await started.promise; + const latest = store.update({ layout: "layers", motion: "off" }); + write.reject(new Error("first write failed")); + await Promise.all([first, latest]); + expect(calls).toBe(2); + expect(JSON.parse(io.contents).appearance).toEqual(store.state.value); + expect(store.state.value.layout).toBe("layers"); + expect(store.state.error).toBeUndefined(); + }); + + test("load failure can retry without saving, and explicit reset authorizes replacing invalid data", async () => { + const io = new MemoryPreferences("broken"), store = new GraphAppearanceStore("preferences.json", io); + await store.load(); + await store.flush(); + expect(io.writes).toHaveLength(0); + io.contents = document({ color: "neutral" }); + await store.retry(); + expect(store.state.value.color).toBe("neutral"); + expect(store.state.error).toBeUndefined(); + expect(io.writes).toHaveLength(0); + const invalid = new GraphAppearanceStore("preferences.json", new MemoryPreferences("future schema")); + await invalid.load(); + await invalid.reset(); + expect(invalid.state.value).toEqual(DEFAULT_GRAPH_APPEARANCE); + expect(invalid.state.error).toBeUndefined(); + }); + + test("memory-only instances stay isolated, publish live changes, and unsubscribe cleanly", async () => { + const first = new GraphAppearanceStore(), second = new GraphAppearanceStore(); + let changes = 0; + const unsubscribe = first.subscribe(() => changes++); + await first.update({ motion: "off" }); + expect(first.state.value.motion).toBe("off"); + expect(second.state.value).toEqual(DEFAULT_GRAPH_APPEARANCE); + expect(first.persistent).toBe(false); + expect(first.saved).toBe(false); + expect(first.state.ready).toBe(true); + expect(changes).toBeGreaterThan(0); + unsubscribe(); + const before = changes; + await first.reset(); + expect(changes).toBe(before); + expect(first.state.value).toEqual(DEFAULT_GRAPH_APPEARANCE); + }); +}); diff --git a/plugins/semctx/adapters/omp/graph-appearance.ts b/plugins/semctx/adapters/omp/graph-appearance.ts new file mode 100644 index 0000000..3e9c458 --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-appearance.ts @@ -0,0 +1,214 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, open, rename, unlink } from "node:fs/promises"; +import { dirname } from "node:path"; + +export interface GraphAppearance { + layout: "map" | "force" | "radial" | "layers" | "components"; + color: "concepts" | "structure" | "neutral"; + detail: "calm" | "balanced" | "detailed"; + labels: "key" | "more" | "hover"; + links: "soft" | "clear"; + motion: "system" | "off"; + popup: "small" | "medium" | "large"; +} + +export const DEFAULT_GRAPH_APPEARANCE: Readonly = Object.freeze({ + layout: "map", color: "concepts", detail: "balanced", labels: "key", links: "soft", motion: "system", popup: "small", +}); + +export interface GraphAppearanceState { + readonly value: Readonly; + readonly ready: boolean; + readonly saving: boolean; + readonly error?: string; +} + +/** Injectable file boundary; writeAtomic must replace only this file, never expose partial JSON. */ +export interface GraphAppearancePersistence { + read(file: string): Promise; + writeAtomic(file: string, contents: string): Promise; +} + +const MAX_BYTES = 8192; +const VERSION = 2; +const choices: { readonly [K in keyof GraphAppearance]: readonly GraphAppearance[K][] } = { + layout: ["map", "force", "radial", "layers", "components"], color: ["concepts", "structure", "neutral"], + detail: ["calm", "balanced", "detailed"], labels: ["key", "more", "hover"], + links: ["soft", "clear"], motion: ["system", "off"], popup: ["small", "medium", "large"], +}; +const keys = Object.keys(choices) as (keyof GraphAppearance)[]; +/** Version 1 files predate the popup size; they hold the other six keys. */ +const VERSION_1_KEYS = keys.filter(key => key !== "popup"); +const record = (value: unknown): value is Record => value !== null && typeof value === "object" && !Array.isArray(value); + +/** Without `required`, any subset of supported keys is valid. With it, the value holds exactly those keys. */ +function validPatch(value: unknown, required?: readonly (keyof GraphAppearance)[]): value is Partial { + if (!record(value)) return false; + const fields = Reflect.ownKeys(value), allowed: readonly string[] = required ?? keys; + return (!required || fields.length === required.length) && fields.every(key => typeof key === "string" && + allowed.includes(key) && Object.hasOwn(Object.getOwnPropertyDescriptor(value, key)!, "value") && + (choices[key as keyof GraphAppearance] as readonly unknown[]).includes(value[key])); +} + +function decode(contents: string): Readonly { + if (Buffer.byteLength(contents, "utf8") > MAX_BYTES) throw new Error("Appearance file exceeds the 8 KiB preference limit."); + let value: unknown; + try { value = JSON.parse(contents); } catch { throw new Error("Appearance file is not valid JSON."); } + const version = record(value) ? value.version : undefined; + const required = version === 1 ? VERSION_1_KEYS : version === VERSION ? keys : undefined; + if (!record(value) || !required || Object.keys(value).length !== 2 || !Object.hasOwn(value, "appearance") || !validPatch(value.appearance, required)) { + throw new Error("Appearance file has an unsupported version or invalid preferences."); + } + const saved = value.appearance as Partial; + // A key that the file version does not store keeps its default. + return Object.freeze(Object.fromEntries(keys.map(key => [key, saved[key] ?? DEFAULT_GRAPH_APPEARANCE[key]])) as unknown as GraphAppearance); +} + +const filesystem: GraphAppearancePersistence = { + async read(file) { + const handle = await open(file, "r"); + try { + const bytes = Buffer.alloc(MAX_BYTES + 1); + let length = 0; + while (length < bytes.length) { + const { bytesRead } = await handle.read(bytes, length, bytes.length - length, length); + if (bytesRead === 0) break; + length += bytesRead; + } + if (length > MAX_BYTES) throw new Error("Appearance file exceeds the 8 KiB preference limit."); + return bytes.toString("utf8", 0, length); + } finally { await handle.close(); } + }, + async writeAtomic(file, contents) { + await mkdir(dirname(file), { recursive: true }); + const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`; + try { + const handle = await open(temporary, "wx", 0o600); + try { await handle.writeFile(contents, "utf8"); await handle.sync(); } + finally { await handle.close(); } + await rename(temporary, file); + } finally { + // A failed replacement leaves the previous file intact; remove only our unique temporary. + await unlink(temporary).catch(() => {}); + } + }, +}; + +function message(error: unknown): string { + return (error instanceof Error ? error.message : String(error)).replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 1024); +} + +/** Local, versioned preferences. IO failures never reject UI actions or discard in-memory edits. */ +export class GraphAppearanceStore { + private snapshot: GraphAppearanceState; + private readonly listeners = new Set<() => void>(); + private loading?: Promise; + private flushing?: Promise; + private attempted: boolean; + private loadFailed = false; + private filePresent = false; + private revision = 0; + private savedRevision = 0; + private beforeLoad: Partial = {}; + + constructor(private readonly file?: string, private readonly persistence: GraphAppearancePersistence = filesystem) { + this.attempted = !file; + this.snapshot = { value: DEFAULT_GRAPH_APPEARANCE, ready: !file, saving: false }; + } + get state(): GraphAppearanceState { return this.snapshot; } + get persistent(): boolean { return Boolean(this.file); } + get saved(): boolean { return this.filePresent && this.savedRevision === this.revision; } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { this.listeners.delete(listener); }; + } + private publish(patch: Partial): void { + this.snapshot = { ...this.snapshot, ...patch }; + for (const listener of this.listeners) { + // A consumer's render failure must not interrupt the persistence queue. + try { listener(); } catch {} + } + } + + load(): Promise { + if (this.loading) return this.loading; + if (!this.file || (this.attempted && (!this.loadFailed || this.revision > this.savedRevision))) return Promise.resolve(); + this.loading = Promise.resolve().then(async () => { + try { + let value = DEFAULT_GRAPH_APPEARANCE; + try { value = decode(await this.persistence.read(this.file!)); this.filePresent = true; } + catch (error) { + if (!(record(error) && error.code === "ENOENT")) throw error; + this.filePresent = false; + } + this.loadFailed = false; + this.publish({ value: Object.freeze({ ...value, ...this.beforeLoad }), error: undefined }); + } catch (error) { + this.loadFailed = true; + this.publish({ error: `Could not load appearance: ${message(error)} Existing file kept; changes remain usable in memory.` }); + } finally { + this.attempted = true; + this.beforeLoad = {}; + this.loading = undefined; + this.publish({ ready: true }); + } + }); + return this.loading; + } + + update(patch: Partial): Promise { + try { + if (!validPatch(patch)) throw new Error("Expected only supported appearance options."); + } catch (error) { + this.publish({ error: `Appearance change rejected: ${message(error)}` }); + return Promise.resolve(); + } + if (Object.keys(patch).length === 0) return this.retry(); + this.revision++; + if (!this.attempted || this.loading) this.beforeLoad = { ...this.beforeLoad, ...patch }; + this.publish({ value: Object.freeze({ ...this.state.value, ...patch }) }); + return this.save(); + } + reset(): Promise { return this.update({ ...DEFAULT_GRAPH_APPEARANCE }); } + + /** Retry a failed save; without explicit edits, retry loading without replacing an unreadable file. */ + retry(): Promise { + return this.revision > this.savedRevision ? this.save() : this.loadFailed ? this.load() : Promise.resolve(); + } + /** Wait for already-requested IO only; shutdown never creates or repairs an untouched file. */ + flush(): Promise { return this.flushing ?? this.loading ?? Promise.resolve(); } + private save(): Promise { + if (!this.file) { + this.savedRevision = this.revision; + this.publish({ ready: true, saving: false, error: undefined }); + return Promise.resolve(); + } + if (this.flushing) return this.flushing; + this.flushing = Promise.resolve().then(async () => { + try { + await this.load(); + while (this.savedRevision < this.revision) { + const revision = this.revision; + const contents = `${JSON.stringify({ version: VERSION, appearance: this.state.value }, null, 2)}\n`; + try { + await this.persistence.writeAtomic(this.file!, contents); + this.savedRevision = revision; + this.loadFailed = false; + this.filePresent = true; + this.publish({ error: undefined }); + } catch (error) { + this.publish({ error: `Could not save appearance: ${message(error)} Changes are active in memory; retry to save.` }); + // An edit received during this write deserves its own attempt, not a lost queue slot. + if (this.revision === revision) break; + } + } + } finally { + this.flushing = undefined; + this.publish({ saving: false }); + } + }); + this.publish({ saving: true }); + return this.flushing; + } +} diff --git a/plugins/semctx/adapters/omp/graph-colors.test.ts b/plugins/semctx/adapters/omp/graph-colors.test.ts new file mode 100644 index 0000000..87fec45 --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-colors.test.ts @@ -0,0 +1,256 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { DescribeContext } from "@oh-my-pi/pi-tui"; +import { conceptRevision, groupConcept, lookupConcept, resetConcepts, setConceptSnapshot, subscribeConcepts } from "./concept-colors"; +import { bindGraphGroupColors, getGraphColorMode, graphColorRevision, graphGroupColor, lookupGraphColor, setGraphColorMode } from "./graph-colors"; +import type { GraphData } from "./graph"; +import { readDisplay, type Display, type Hit } from "./inspector"; +import type { DocSource } from "./graph-data/doc-concepts.js"; +import { conceptPalette } from "./graph-data/palette.js"; +import { nativeGraphColorCue } from "./native-concepts"; + +type GraphNode = GraphData["nodes"][number]; +function file(path: string, extra: Partial = {}): GraphNode { + return { id: `query-local:${path}`, label: "Query-local label", detail: null, hit: null, category: "file", path, ...extra }; +} +function hit(path: string | null, codebaseId: string | null = null): Hit { + return { path, codebaseId, symbol: "Resolve", qualifiedName: "scope.Resolve", language: "typescript", kind: "function", score: null, + startLine: 1, endLine: 1, startByte: null, endByte: null, snippet: null, snippetStartLine: null, snippetEndLine: null, + snippetStartByte: null, snippetEndByte: null, focusLine: null, focusByte: null, + binding: { state: "unverified", reason: null } }; +} +function overview(codebaseId: string): Extract { + return { version: 1, kind: "graph", identity: { codebaseId, name: null, localRoot: "D:/repo", copy: "checkout" }, + title: "Project overview", query: null, roots: [], caveats: [], truncated: false, + nodes: [file("src/a.ts", { id: "a" }), file("src/b.ts", { id: "b" }), file("src/unsupported.ts", { id: "unsupported" })], + edges: [{ from: "a", to: "b", kind: "reference", label: "references", symbol: "ResolveSymbol", moniker: "actual::ResolveSymbol" }] }; +} +function snapshot(value: Extract, docs: readonly DocSource[] = []): void { + const display = readDisplay(value, "overview", true); + if (!display) throw new Error("Invalid graph color fixture"); + setConceptSnapshot(display, docs); +} + +describe("shared codebase graph colors", () => { + afterEach(resetConcepts); + + test("file, symbol, directory, project root and query-local representations share normalized source-directory colors", () => { + const source = file("src/feature/entry.ts"); + const symbol: GraphNode = { id: "symbol-result:73", label: "Different query", detail: null, hit: hit(".\\src\\feature\\entry.ts") }; + const directory = file("src/feature", { id: "directory-result", category: "directory" }); + const project = file("src/feature", { id: "project-result", category: "project" }); + const color = lookupGraphColor("shared-source", source, "concepts"); + expect(color?.source).toBe("structure"); + for (const node of [symbol, directory, project, file("src/feature/other.ts", { id: "ordinal-shift", label: "Unrelated label", indexing: "failed", indexingError: "reported failure" })]) { + expect(lookupGraphColor("shared-source", node, "concepts")).toEqual(color); + expect(lookupGraphColor("shared-source", node, "structure")).toEqual(color); + } + expect(color).toMatchObject({ label: "src/feature", ...conceptPalette(color!.key) }); + expect(lookupGraphColor("shared-source", file("root.ts"), "structure")).toEqual( + lookupGraphColor("shared-source", file("", { category: "directory" }), "structure")); + expect(lookupGraphColor("shared-source", file("src/Feature/entry.ts"), "structure")?.key).not.toBe(color?.key); + expect(lookupGraphColor("other-codebase", source, "structure")?.key).not.toBe(color?.key); + }); + + test("explicit foreign hit identities never borrow the envelope's concept or structure colors", () => { + snapshot(overview("local-envelope")); + const local = file("src/a.ts"), foreign = file("local/wrong.ts", { hit: hit("src/a.ts", "foreign-hit") }); + expect(lookupGraphColor("local-envelope", local, "concepts")?.source).toBe("concept"); + const fallback = lookupGraphColor("local-envelope", foreign, "concepts"); + expect(fallback?.source).toBe("structure"); + expect(fallback).toEqual(lookupGraphColor("foreign-hit", local, "structure")); + expect(fallback).toEqual(lookupGraphColor(null, foreign, "concepts")); + expect(fallback?.key).not.toBe(lookupGraphColor("local-envelope", local, "structure")?.key); + snapshot(overview("foreign-hit")); + const foreignConcept = lookupConcept("foreign-hit", "src/a.ts"); + expect(lookupGraphColor("local-envelope", foreign, "concepts")).toMatchObject({ key: foreignConcept?.key, light: foreignConcept?.light, source: "concept", concept: foreignConcept }); + expect(graphGroupColor("local-envelope", [local, foreign], "concepts")).toBeUndefined(); + expect(graphGroupColor("local-envelope", [local, foreign], "structure")).toBeUndefined(); + }); + + test("missing, invalid and misleading source identities stay neutral rather than hashing ids or labels", () => { + const pathless: GraphNode = { id: "src/a.ts", label: "src/a.ts", detail: "Directory: src", hit: null }; + expect(lookupGraphColor("source-boundary", pathless, "concepts")).toBeUndefined(); + for (const path of ["", ".", "..", "../src/a.ts", "/src/a.ts", "src//a.ts", "src/./a.ts", "D:/repo/src/a.ts", "file://src/a.ts", "src/\u0000a.ts", "x".repeat(4097)]) { + expect(lookupGraphColor("source-boundary", file(path), "concepts")).toBeUndefined(); + } + for (const codebase of [null, undefined, "", " padded", "padded ", "bad\nidentity", "x".repeat(4097)]) { + expect(lookupGraphColor(codebase, file("src/a.ts"), "structure")).toBeUndefined(); + } + for (const foreign of ["", " padded", "invalid\nidentity"]) { + expect(lookupGraphColor("valid-envelope", file("src/a.ts", { hit: hit("src/a.ts", foreign) }), "concepts")).toBeUndefined(); + } + expect(lookupGraphColor("valid-envelope", file("src/a.ts", { hit: hit("../invalid.ts") }), "concepts")).toBeUndefined(); + expect(lookupGraphColor("valid-envelope", file("src/a.ts", { category: "group" }), "concepts")).toBeUndefined(); + }); + + test("validated complete concept evidence overrides structure, while unverified or unsupported metadata does not", () => { + const raw = overview("validated-colors"); + setConceptSnapshot(raw); + expect(lookupGraphColor("validated-colors", raw.nodes[0], "concepts")?.source).toBe("structure"); + snapshot(raw); + const concept = lookupConcept("validated-colors", "src/a.ts")!; + expect(concept).toBeDefined(); + expect(lookupGraphColor("validated-colors", raw.nodes[0], "concepts")).toMatchObject({ key: concept.key, label: concept.label, ...conceptPalette(concept.key), source: "concept", concept }); + expect(lookupGraphColor("validated-colors", { id: "different-result", label: "Resolve", detail: null, hit: hit("src/a.ts") }, "concepts")).toEqual( + lookupGraphColor("validated-colors", raw.nodes[0], "concepts")); + expect(lookupGraphColor("validated-colors", raw.nodes[0], "structure")?.source).toBe("structure"); + expect(lookupGraphColor("validated-colors", raw.nodes[2], "concepts")?.source).toBe("structure"); + for (const value of [ + { ...overview("partial-colors"), truncated: true }, + { ...overview("canonical-colors"), identity: { ...overview("canonical-colors").identity, copy: "canonical" as const, localRoot: null } }, + ]) { + snapshot(value); + expect(lookupGraphColor(value.identity.codebaseId, value.nodes[0], "concepts")?.source).toBe("structure"); + } + const arbitrary = readDisplay(overview("tool-colors"), "imports"); + if (!arbitrary) throw new Error("Invalid ordinary graph fixture"); + setConceptSnapshot(arbitrary); + expect(lookupGraphColor("tool-colors", file("src/a.ts"), "concepts")?.source).toBe("structure"); + }); + + test("groups are colored only when every source-bearing member has the same complete color identity", () => { + const a = file("src/a.ts"), b = file("src/b.ts"), other = file("other/c.ts"); + expect(graphGroupColor("aggregate-colors", [a, b], "structure")).toEqual(lookupGraphColor("aggregate-colors", a, "structure")); + expect(graphGroupColor("aggregate-colors", [a, other], "structure")).toBeUndefined(); + const synthetic: GraphNode = { id: "arbitrary-wrapper", label: "Files", detail: null, hit: null, category: "group" }; + expect(graphGroupColor("aggregate-colors", [synthetic, a, b], "concepts")).toEqual(lookupGraphColor("aggregate-colors", a, "concepts")); + for (const unknown of [ + { ...synthetic, category: "file" as const }, { ...synthetic, category: "project" as const }, + { ...synthetic, category: undefined }, { ...synthetic, project: "unreported-root" }, { ...synthetic, path: "unknown/path" }, + ]) expect(graphGroupColor("aggregate-colors", [a, unknown], "concepts")).toBeUndefined(); + expect(graphGroupColor("aggregate-colors", [], "concepts")).toBeUndefined(); + expect(graphGroupColor("aggregate-colors", [synthetic], "concepts")).toBeUndefined(); + snapshot(overview("aggregate-colors")); + expect(graphGroupColor("aggregate-colors", [a, b], "concepts")?.source).toBe("concept"); + expect(graphGroupColor("aggregate-colors", [a, file("src/unsupported.ts")], "concepts")).toBeUndefined(); + expect(graphGroupColor("aggregate-colors", [a, file("src", { category: "directory" })], "concepts")).toBeUndefined(); + }); + + test("doc concepts color files, directories and project roots alike; groups show the concept without one member's route", () => { + const paths = ["docs/dispatch.md", "src/dispatch/queue.ts", "src/dispatch/worker.ts", "src/dispatch/pool.ts", "lib/other.ts"]; + snapshot({ ...overview("doc-colors"), truncated: true, edges: [], + nodes: [...paths.map((path, index) => file(path, { id: String(index) })), file("src/dispatch", { id: "directory", category: "directory" })] }, + [{ path: "docs/dispatch.md", text: "# Dispatch\n\nQueue in `src/dispatch/queue.ts`; workers live in `src/dispatch/`.\n" }]); + const doc = file("docs/dispatch.md"), queue = file("src/dispatch/queue.ts"), directory = file("src/dispatch", { category: "directory" }); + const color = lookupGraphColor("doc-colors", queue, "concepts"), folder = lookupGraphColor("doc-colors", directory, "concepts"); + expect(color).toMatchObject({ source: "concept", key: "doc:docs/dispatch.md", concept: lookupConcept("doc-colors", "src/dispatch/queue.ts") }); + expect(color?.concept?.via).toBe("mention"); + expect(folder).toMatchObject({ source: "concept", key: color?.key }); + expect(folder?.concept?.via).toBe("folder"); + expect(lookupGraphColor("doc-colors", file("src/dispatch", { category: "project" }), "concepts")).toBe(folder!); + expect(lookupGraphColor("doc-colors", directory, "structure")?.source).toBe("structure"); + const group = graphGroupColor("doc-colors", [doc, directory, queue], "concepts"); + expect(group).toMatchObject({ source: "concept", key: color?.key }); + expect(group?.concept).toBe(groupConcept(color!.concept!)); + expect(group?.concept && "via" in group.concept).toBe(false); + const projected = file("ignored/path.ts", { category: "group" }); + bindGraphGroupColors(projected, "doc-colors", [doc, directory, queue]); + expect(lookupGraphColor("doc-colors", projected, "concepts")).toBe(group!); + expect(graphGroupColor("doc-colors", [queue, file("lib/other.ts")], "concepts")).toBeUndefined(); + const cx: DescribeContext = { cols: 80, dark: true, reduceMotion: false, supports: () => true, feature: name => name === "styles" }; + const title = (value: typeof color) => value && nativeGraphColorCue(value, cx)?.p.title; + expect(title(color)).toBe("Concept: Dispatch\nFrom docs/dispatch.md · mentioned in the doc"); + expect(title(folder)).toBe("Concept: Dispatch\nFrom docs/dispatch.md · same folder"); + expect(title(lookupGraphColor("doc-colors", doc, "concepts"))).toBe("Concept: Dispatch\nFrom docs/dispatch.md · the doc itself"); + expect(title(group)).toBe("Concept: Dispatch\nFrom docs/dispatch.md"); + expect(title(lookupGraphColor("doc-colors", file("lib/other.ts"), "concepts"))).toBe("Code structure: lib\nSource-directory color"); + }); + + test("projection bindings preserve both palettes, neutral mixed aggregates and codebase scope without parsing projected metadata", () => { + const data = overview("projected-colors"); + snapshot(data); + const members = data.nodes.slice(0, 2); + const projected = file("misleading/metadata.ts", { id: "arbitrary-query-group:19", category: "group", label: "Other result name" }); + bindGraphGroupColors(projected, "projected-colors", members); + expect(lookupGraphColor("projected-colors", projected, "concepts")).toEqual(graphGroupColor("projected-colors", members, "concepts")); + expect(lookupGraphColor("projected-colors", projected, "structure")).toEqual(graphGroupColor("projected-colors", members, "structure")); + expect(lookupGraphColor("projected-colors", projected, "concepts")?.source).toBe("concept"); + expect(lookupGraphColor("projected-colors", projected, "structure")?.source).toBe("structure"); + expect(lookupGraphColor("projected-colors", projected, "neutral")).toBeUndefined(); + expect(lookupGraphColor("wrong-codebase", projected, "concepts")).toBeUndefined(); + expect(lookupGraphColor(null, projected, "structure")).toBeUndefined(); + expect(lookupGraphColor("projected-colors", { ...projected }, "concepts")).toBeUndefined(); + const mixed = file("src/a.ts", { category: "group" }); + bindGraphGroupColors(mixed, "projected-colors", [...members, file("other/c.ts")]); + expect(lookupGraphColor("projected-colors", mixed, "concepts")).toBeUndefined(); + expect(lookupGraphColor("projected-colors", mixed, "structure")).toBeUndefined(); + expect(graphGroupColor("projected-colors", [members[0], mixed], "concepts")).toBeUndefined(); + const nested = file("ignored/path.ts", { category: "group" }); + bindGraphGroupColors(nested, "projected-colors", [projected]); + expect(lookupGraphColor("projected-colors", nested, "concepts")).toEqual(lookupGraphColor("projected-colors", projected, "concepts")); + }); + + test("retained result roots and nested bindings gain and lose validated concepts without rebinding or changing structural colors", () => { + const codebase = "retained-result-colors"; + const members = [file("src/a.ts"), file("src/b.ts")]; + const root = file("src/a.ts", { id: "persistent-result-root" }); + bindGraphGroupColors(root, codebase, members); + const nested = file("ignored/path.ts", { category: "group" }); + bindGraphGroupColors(nested, codebase, [root]); + const structure = lookupGraphColor(codebase, root, "structure"); + const evidence = JSON.stringify([root, nested]); + expect(lookupGraphColor(codebase, root, "concepts")?.source).toBe("structure"); + expect(lookupGraphColor(codebase, nested, "concepts")?.source).toBe("structure"); + // Later edits to the caller's array cannot silently redefine this retained aggregate. + members.push(file("other/not-an-original-member.ts")); + snapshot(overview(codebase)); + expect(lookupGraphColor(codebase, nested, "concepts")?.source).toBe("concept"); + expect(lookupGraphColor(codebase, root, "concepts")).toEqual(lookupGraphColor(codebase, file("src/a.ts"), "concepts")); + expect(graphGroupColor(codebase, [nested, file("src/a.ts")], "concepts")?.source).toBe("concept"); + expect(lookupGraphColor(codebase, root, "structure")).toBe(structure); + const unsupported = overview(codebase); + unsupported.edges = []; + snapshot(unsupported); + expect(lookupGraphColor(codebase, root, "concepts")).toBe(structure); + expect(lookupGraphColor(codebase, nested, "concepts")).toBe(structure); + expect(lookupGraphColor(codebase, root, "structure")).toBe(structure); + snapshot(overview(codebase)); + expect(lookupGraphColor(codebase, root, "concepts")?.source).toBe("concept"); + resetConcepts(); + expect(lookupGraphColor(codebase, nested, "concepts")).toBe(structure); + expect(JSON.stringify([root, nested])).toBe(evidence); + }); + + test("palette changes repaint existing subscribers and bound groups without changing concept revisions or graph evidence", () => { + const previous = getGraphColorMode(); + const data = overview("mode-colors"); + snapshot(data); + const node = data.nodes[0], projected = file("ignored/path.ts", { category: "group" }); + bindGraphGroupColors(projected, "mode-colors", data.nodes.slice(0, 2)); + const originalNodes = data.nodes, originalEdges = data.edges, evidence = JSON.stringify(data); + const owners = [{ paints: 0 }, { paints: 0 }]; + const stops = owners.map(owner => subscribeConcepts(owner, value => { value.paints++; })); + try { + setGraphColorMode("concepts"); + owners.forEach(owner => { owner.paints = 0; }); + const start = graphColorRevision(), concepts = conceptRevision(); + const shared = lookupGraphColor("mode-colors", projected); + expect(shared?.source).toBe("concept"); + setGraphColorMode("structure"); + expect(graphColorRevision()).toBe(start + 1); + expect(conceptRevision()).toBe(concepts); + expect(owners.map(owner => owner.paints)).toEqual([1, 1]); + expect(lookupGraphColor("mode-colors", node)?.source).toBe("structure"); + expect(lookupGraphColor("mode-colors", projected)?.source).toBe("structure"); + expect(lookupGraphColor("mode-colors", projected, "concepts")).toBe(shared); + setGraphColorMode("structure"); + expect(graphColorRevision()).toBe(start + 1); + expect(owners.map(owner => owner.paints)).toEqual([1, 1]); + setGraphColorMode("neutral"); + expect(lookupGraphColor("mode-colors", node)).toBeUndefined(); + expect(lookupGraphColor("mode-colors", projected)).toBeUndefined(); + expect(graphGroupColor("mode-colors", data.nodes)).toBeUndefined(); + setGraphColorMode("concepts"); + expect(lookupGraphColor("mode-colors", projected)).toBe(shared); + setGraphColorMode("unrecognized" as "neutral"); + expect(getGraphColorMode()).toBe("concepts"); + expect(conceptRevision()).toBe(concepts); + expect(JSON.stringify(data)).toBe(evidence); + expect(data.nodes).toBe(originalNodes); + expect(data.edges).toBe(originalEdges); + } finally { + stops.forEach(stop => stop()); + setGraphColorMode(previous); + } + }); +}); diff --git a/plugins/semctx/adapters/omp/graph-colors.ts b/plugins/semctx/adapters/omp/graph-colors.ts new file mode 100644 index 0000000..9e1dee7 --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-colors.ts @@ -0,0 +1,111 @@ +import { conceptRevision, groupConcept, lookupConcept, notifyColorObservers, type ConceptColor } from "./concept-colors"; +import type { GraphData } from "./graph"; +import type { GraphAppearance } from "./graph-appearance"; +import { conceptPalette, normalizeConceptPath } from "./graph-data/palette.js"; + +type GraphNode = GraphData["nodes"][number]; +type ColorMode = GraphAppearance["color"]; +/** `concept` carries the concept evidence; structure colors have none. */ +export interface GraphColor extends Pick { source: "concept" | "structure"; concept?: ConceptColor } +interface GroupColor { color: GraphColor; identity: string } +interface GroupBinding { + scope: string | null; + members: readonly GraphNode[]; + sharedRevision: number; + shared?: GroupColor; + structure?: GroupColor; +} +const groupBindings = new WeakMap(); +const structuralColors = new WeakMap(); +const conceptColors = new WeakMap(); +let currentMode: ColorMode = "concepts"; +let revision = 0; + +export function getGraphColorMode(): ColorMode { return currentMode; } +export function graphColorRevision(): number { return revision; } +function conceptColor(concept: ConceptColor): GraphColor { + let color = conceptColors.get(concept); + if (!color) { + color = Object.freeze({ key: concept.key, label: concept.label, light: concept.light, dark: concept.dark, reason: concept.reason, source: "concept", concept }); + conceptColors.set(concept, color); + } + return color; +} + +/** Category colors are profile-wide; changing them repaints without changing graph evidence or grouping. */ +export function setGraphColorMode(mode: ColorMode): void { + if ((mode !== "concepts" && mode !== "structure" && mode !== "neutral") || mode === currentMode) return; + currentMode = mode; + revision++; + notifyColorObservers(); +} + +/** A source directory identifies structural color; query ids, labels, status and result order never do. */ +export function lookupGraphColor(codebaseId: string | null | undefined, node: GraphNode, mode: ColorMode = currentMode): GraphColor | undefined { + if (mode !== "concepts" && mode !== "structure") return undefined; + const binding = groupBindings.get(node); + if (binding) { + if (binding.scope !== (codebaseId ?? null)) return undefined; + if (mode === "concepts" && binding.sharedRevision !== conceptRevision()) { + // Mark before descending so even accidentally cyclic wrapper bindings stay neutral. + binding.sharedRevision = conceptRevision(); + binding.shared = undefined; + binding.shared = resolveGroup(binding.scope, binding.members, "concepts"); + } + return (mode === "concepts" ? binding.shared : binding.structure)?.color; + } + const codebase = node.hit?.codebaseId ?? codebaseId; + if (typeof codebase !== "string" || !codebase || codebase.length > 4096 || codebase.trim() !== codebase || /[\u0000-\u001f\u007f-\u009f]/.test(codebase)) return undefined; + // An unbound aggregate's single path cannot establish the category of all its unseen members. + if (node.category === "group") return undefined; + const directoryNode = node.category === "directory" || node.category === "project"; + const source = node.hit?.path ?? node.path; + const path = directoryNode && source === "" ? "" : normalizeConceptPath(source); + if (path === undefined) return undefined; + // Doc concepts also name directories and project roots by path. + if (mode === "concepts") { + const concept = lookupConcept(codebase, path); + if (concept) return conceptColor(concept); + } + const directory = directoryNode ? path : path.slice(0, Math.max(0, path.lastIndexOf("/"))); + const key = `structure:${JSON.stringify([codebase, directory])}`; + let color = structuralColors.get(node); + if (!color || color.key !== key) { + color = Object.freeze({ key, label: directory || ".", ...conceptPalette(key), source: "structure", + reason: "Reported source directory, shared across graph views in this codebase. Structural category, not semantic similarity, confidence, or embedding evidence." }); + structuralColors.set(node, color); + } + return color; +} + +function resolveGroup(codebaseId: string | null | undefined, members: readonly GraphNode[], mode: "concepts" | "structure"): GroupColor | undefined { + let result: GroupColor | undefined; + for (const member of members) { + const binding = groupBindings.get(member); + const color = lookupGraphColor(codebaseId, member, mode); + if (!color) { + // Only an explicitly synthetic, unbound wrapper with no source evidence can be skipped. + if (!binding && member.category === "group" && !member.hit && member.path === undefined && member.project === undefined) continue; + return undefined; + } + const identity = binding ? (mode === "concepts" ? binding.shared : binding.structure)!.identity + : JSON.stringify([member.hit?.codebaseId ?? codebaseId, color.source, color.key]); + if (result && result.identity !== identity) return undefined; + result ??= { color, identity }; + } + // A group shows the concept itself, never the assignment route of one member. + const concept = result?.color.concept && groupConcept(result.color.concept); + return result && concept && concept !== result.color.concept ? { color: conceptColor(concept), identity: result.identity } : result; +} + +/** A mixed or unresolved source aggregate stays neutral, including across codebase boundaries. */ +export function graphGroupColor(codebaseId: string | null | undefined, members: readonly GraphNode[], mode: ColorMode = currentMode): GraphColor | undefined { + return mode === "concepts" || mode === "structure" ? resolveGroup(codebaseId, members, mode)?.color : undefined; +} + +/** Retain source membership for new evidence, while palette-only repaints reuse both cached resolutions. */ +export function bindGraphGroupColors(node: GraphNode, codebaseId: string | null | undefined, members: readonly GraphNode[]): void { + const retained = Object.freeze(members.slice()); + groupBindings.set(node, { scope: codebaseId ?? null, members: retained, sharedRevision: conceptRevision(), + shared: resolveGroup(codebaseId, retained, "concepts"), structure: resolveGroup(codebaseId, retained, "structure") }); +} diff --git a/plugins/semctx/adapters/omp/graph-data/concepts.d.ts b/plugins/semctx/adapters/omp/graph-data/concepts.d.ts new file mode 100644 index 0000000..39971eb --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-data/concepts.d.ts @@ -0,0 +1,20 @@ +import type { GraphData } from "../graph"; +export interface InferredConcept { + key: string; + type: string; + label: string; + members: string[]; + symbols: { symbol: string; moniker: string | null; count: number; weight: number }[]; + projects: string[]; + directories: string[]; + relationCount: number; + edges: number[]; +} +export function inferConcepts(data: Pick): { + available: boolean; + reason: string; + groups: InferredConcept[]; + assignments: Map; + unassigned: string[]; + stats: { passes: number; visits: number }; +}; diff --git a/plugins/semctx/adapters/omp/graph-data/concepts.js b/plugins/semctx/adapters/omp/graph-data/concepts.js new file mode 100644 index 0000000..fa7646f --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-data/concepts.js @@ -0,0 +1,91 @@ +// Functional communities inferred only from resolved file references and weak import support. +// Sparse modularity moves are bounded by passes, not by all-pairs file comparisons. +import { normalizeConceptPath } from "./palette.js"; +const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0; +const file = node => node.category === "file"; +const displayName = symbol => symbol.replace(/([a-z\d])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim(); +export function inferConcepts(data) { + const files = data.nodes.filter(file), nodesById = new Map(files.map(node => [node.id, node])), paths = new Map(); + for (const node of files) { + const path = normalizeConceptPath(node.path); + if (path) paths.set(path, (paths.get(path) ?? 0) + 1); + } + const ids = new Set(files.filter(node => { + const path = normalizeConceptPath(node.path); + return path && paths.get(path) === 1 && (!node.hit?.codebaseId || node.hit.codebaseId === data.identity?.codebaseId); + }).map(node => node.id)); + // Transport IDs can be ordinal. File identity, not transport order, owns ties and keys. + const identity = id => normalizeConceptPath(nodesById.get(id)?.path) ?? id; + const compareId = (a, b) => compare(identity(a), identity(b)) || compare(a, b); + files.sort((a, b) => compareId(a.id, b.id)); + const references = data.edges.map((edge, index) => ({ edge, index })).filter(({ edge }) => edge.kind === "reference" && typeof edge.symbol === "string" && edge.symbol.trim() && typeof edge.moniker === "string" && edge.moniker.trim() && ids.has(edge.from) && ids.has(edge.to) && edge.from !== edge.to) + .sort((a, b) => compareId(a.edge.from, b.edge.from) || compareId(a.edge.to, b.edge.to) || compare(a.edge.moniker ?? a.edge.symbol, b.edge.moniker ?? b.edge.symbol) || compare(a.edge.symbol, b.edge.symbol)); + const empty = reason => ({ available: false, reason, groups: [], assignments: new Map(), unassigned: files.map(node => node.id), stats: { passes: 0, visits: 0 } }); + if (!references.length) return empty("Concepts need resolved references between indexed files with real symbol identities. This result has no such evidence; Structure remains available."); + const identityFiles = new Map(), nameFiles = new Map(), supported = new Set(); + for (const { edge } of references) { + const identity = edge.moniker ?? edge.symbol; + if (!identityFiles.has(identity)) identityFiles.set(identity, new Set()); + if (!nameFiles.has(edge.symbol)) nameFiles.set(edge.symbol, new Set()); + for (const id of [edge.from, edge.to]) { identityFiles.get(identity).add(id); nameFiles.get(edge.symbol).add(id); supported.add(id); } + } + const idf = count => Math.log1p(files.length / (1 + count)); + const weighted = references.map(item => ({ ...item, weight: idf(identityFiles.get(item.edge.moniker ?? item.edge.symbol).size) * idf(nameFiles.get(item.edge.symbol).size) })); + const adjacency = new Map([...supported].sort(compareId).map(id => [id, new Map()])); + function add(a, b, weight) { const neighbours = adjacency.get(a); neighbours.set(b, (neighbours.get(b) ?? 0) + weight); } + for (const { edge, weight } of weighted) { add(edge.from, edge.to, weight); add(edge.to, edge.from, weight); } + for (const edge of [...data.edges].sort((a, b) => compareId(a.from, b.from) || compareId(a.to, b.to))) if ((edge.kind === "import" || (!edge.kind && edge.label === "imports")) && edge.from !== edge.to && supported.has(edge.from) && supported.has(edge.to)) { add(edge.from, edge.to, .12); add(edge.to, edge.from, .12); } + const degree = new Map(), totals = new Map(), communities = new Map(); let mass = 0; + for (const [id, neighbours] of adjacency) { const value = [...neighbours.values()].reduce((sum, weight) => sum + weight, 0); degree.set(id, value); totals.set(id, value); communities.set(id, id); mass += value; } + const stats = { passes: 0, visits: 0 }; + for (let pass = 0; pass < 16; pass++) { + let moved = false; stats.passes++; + for (const [id, neighbours] of adjacency) { + const old = communities.get(id), weights = new Map(); + for (const [other, weight] of neighbours) { stats.visits++; const community = communities.get(other); weights.set(community, (weights.get(community) ?? 0) + weight); } + totals.set(old, totals.get(old) - degree.get(id)); + const score = community => (weights.get(community) ?? 0) - degree.get(id) * (totals.get(community) ?? 0) / mass; + let best = old, bestScore = score(old); + for (const community of [...weights.keys()].sort(compareId)) { const value = score(community); if (value > bestScore + 1e-10) { best = community; bestScore = value; } } + communities.set(id, best); totals.set(best, (totals.get(best) ?? 0) + degree.get(id)); if (best !== old) moved = true; + } + if (!moved) break; + } + const members = new Map(); + for (const [id, community] of communities) { if (!members.has(community)) members.set(community, []); members.get(community).push(id); } + const evidence = new Map(); + for (const item of weighted) { + const community = communities.get(item.edge.from); if (community !== communities.get(item.edge.to)) continue; + if (!evidence.has(community)) evidence.set(community, { symbols: new Map(), edges: [] }); + const entry = evidence.get(community), identity = item.edge.moniker ?? item.edge.symbol; + if (!entry.symbols.has(identity)) entry.symbols.set(identity, { symbol: item.edge.symbol, moniker: item.edge.moniker ?? null, count: 0, weight: 0 }); + const symbol = entry.symbols.get(identity); symbol.count++; symbol.weight += item.weight; entry.edges.push({ index: item.index, weight: item.weight, from: item.edge.from, to: item.edge.to, symbol: item.edge.symbol }); + } + const groups = [], assignments = new Map(); + for (const [community, values] of members) { + const entry = evidence.get(community); if (values.length < 2 || !entry?.edges.length) continue; + values.sort(compareId); + const symbols = [...entry.symbols.values()].sort((a, b) => b.weight - a.weight || compare(a.symbol, b.symbol) || compare(a.moniker ?? "", b.moniker ?? "")); + entry.edges.sort((a, b) => b.weight - a.weight || compareId(a.from, b.from) || compareId(a.to, b.to) || compare(a.symbol, b.symbol)); + const memberNodes = values.map(id => nodesById.get(id)), projects = [...new Set(memberNodes.map(node => node.project).filter(value => typeof value === "string"))].sort(compare); + const directories = [...new Set(memberNodes.map(node => (node.path ?? "").replace(/\\/g, "/").split("/").slice(0, -1).join("/") || "."))].sort(compare); + const group = { key: `concept:${JSON.stringify(values.map(identity))}`, type: "Inferred concept", members: values, symbols, projects, directories, relationCount: entry.edges.length, edges: entry.edges.map(item => item.index) }; + groups.push(group); for (const id of values) assignments.set(id, group); + } + groups.sort((a, b) => compare(a.key, b.key)); + const frequency = new Map(); + for (const group of groups) for (const name of new Set(group.symbols.map(item => item.symbol))) frequency.set(name, (frequency.get(name) ?? 0) + 1); + for (const group of groups) { + const distinguishing = item => item.weight * Math.log1p(groups.length / (1 + frequency.get(item.symbol))); + group.symbols.sort((a, b) => distinguishing(b) - distinguishing(a) || compare(a.symbol, b.symbol)); + group.label = [...new Set(group.symbols.map(item => item.symbol))].slice(0, 2).map(displayName).join(" · "); + } + const labels = new Map(); + for (const group of groups) labels.set(group.label, (labels.get(group.label) ?? 0) + 1); + for (const group of groups) if (labels.get(group.label) > 1) { + // A factual path disambiguates repeated identifiers; it is not a semantic label. + group.label += ` · ${group.directories.length === 1 ? group.directories[0] : nodesById.get(group.members[0]).path ?? group.members[0]}`; + } + if (!groups.length) return { ...empty("Resolved references exist, but no multi-file community has internal symbol evidence. Unsupported files are not assigned a concept."), stats }; + return { available: true, reason: "Inferred from actual resolved-reference/import topology, with shared identifiers downweighted by inverse file frequency. Not embedding similarity or semantic proof.", groups, assignments, unassigned: files.filter(node => !assignments.has(node.id)).map(node => node.id), stats }; +} diff --git a/plugins/semctx/adapters/omp/graph-data/concepts.test.js b/plugins/semctx/adapters/omp/graph-data/concepts.test.js new file mode 100644 index 0000000..48a8c32 --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-data/concepts.test.js @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test"; +import { inferConcepts } from "./concepts.js"; +import { createSimulation } from "./layout.js"; +import { conceptPalette } from "./palette.js"; +const file = (id, path = `src/${id}.rs`) => ({ id, label: id, path, category: "file", project: "", hit: null }); +const reference = (from, to, symbol, moniker = `real::${symbol}`) => ({ from, to, label: "references", kind: "reference", symbol, moniker }); +function modules() { + const nodes = ["a0", "a1", "a2", "b0", "b1", "b2", "unsupported"].map(id => file(id)); + nodes.push({ id: "workspace", label: "Workspace", category: "group", path: "", hit: null }); + const edges = []; + for (const prefix of ["a", "b"]) for (let from = 0; from < 3; from++) for (let to = 0; to < 3; to++) if (from !== to) edges.push(reference(`${prefix}${from}`, `${prefix}${to}`, prefix === "a" ? "GraphQuery" : "VectorStore")); + for (let from = 0; from < 3; from++) for (let to = 0; to < 3; to++) edges.push(reference(`a${from}`, `b${to}`, "SharedIdentifier")); + edges.push({ from: "unsupported", to: "a0", label: "imports", kind: "import" }); + return { nodes, edges, roots: ["workspace"] }; +} +const assignments = result => result.groups.map(group => ({ key: group.key, members: group.members, symbols: group.symbols.map(item => [item.symbol, item.moniker, item.count]) })); + +describe("inferred concepts from actual bindings", () => { + test("does not invent concepts from paths, containment, imports or incomplete identities", () => { + const data = { nodes: [file("a", "query/parser.rs"), file("b", "query/reader.rs")], edges: [{ from: "a", to: "b", kind: "containment", label: "contains" }, { from: "a", to: "b", kind: "import", label: "imports" }, { from: "a", to: "b", kind: "reference", label: "references", symbol: "Unresolved" }], roots: [] }; + const result = inferConcepts(data); expect(result.available).toBe(false); expect(result.assignments.size).toBe(0); expect(result.unassigned).toEqual(["a", "b"]); + expect(inferConcepts({ ...data, edges: [reference("a", "a", "SelfOnly")] }).available).toBe(false); + }); + test("finds two reference communities despite a ubiquitous shared identifier", () => { + const data = modules(), result = inferConcepts(data); + expect(result.available).toBe(true); expect(result.groups.map(group => group.members)).toEqual([["a0", "a1", "a2"], ["b0", "b1", "b2"]]); + expect(result.groups.map(group => group.symbols[0].symbol)).toEqual(["GraphQuery", "VectorStore"]); + expect(result.unassigned).toEqual(["unsupported"]); expect(result.assignments.has("workspace")).toBe(false); + }); + test("retains verifiable original edge indices, symbol identities and complete member files", () => { + const data = modules(), result = inferConcepts(data); + for (const group of result.groups) { + expect(group.relationCount).toBe(group.edges.length); const members = new Set(group.members); + for (const index of group.edges) { const edge = data.edges[index]; expect(edge.kind).toBe("reference"); expect(members.has(edge.from)).toBe(true); expect(members.has(edge.to)).toBe(true); expect(group.symbols.some(item => item.moniker === edge.moniker)).toBe(true); } + for (const item of group.symbols) expect(item.count).toBe(group.edges.filter(index => data.edges[index].moniker === item.moniker).length); + } + }); + test("is deterministic under transport reorder and independent of renamed directory/project categories", () => { + const data = modules(), reordered = { ...data, nodes: [...data.nodes].reverse().map(node => node.category === "file" ? { ...node, path: `unrelated/${node.id}.ts`, project: "different" } : node), edges: [...data.edges].reverse() }; + const evidence = value => assignments(value).map(({ members, symbols }) => ({ members, symbols })); + expect(evidence(inferConcepts(data))).toEqual(evidence(inferConcepts(reordered))); + }); + test("keeps concept identity and palette stable when transport ordinals shift", () => { + const data = modules(), before = inferConcepts(data); + const renamed = new Map(data.nodes.map((node, index) => [node.id, `node-${300 - index}`])); + const changed = { nodes: [{ id: "new-context", category: "directory", path: "other" }, ...data.nodes.map(node => ({ ...node, id: renamed.get(node.id) })).reverse()], + edges: data.edges.map(edge => ({ ...edge, from: renamed.get(edge.from), to: renamed.get(edge.to) })).reverse(), roots: [] }; + const after = inferConcepts(changed); + expect(after.groups.map(group => group.key)).toEqual(before.groups.map(group => group.key)); + for (const node of data.nodes.filter(node => node.category === "file")) { + const original = before.assignments.get(node.id), updated = after.assignments.get(renamed.get(node.id)); + expect(updated?.key).toBe(original?.key); + if (original) expect(conceptPalette(updated.key)).toEqual(conceptPalette(original.key)); + } + }); + test("does not assign ambiguous paths or foreign file identities", () => { + const nodes = [file("a", "src/a.rs"), file("b", "src/b.rs"), { ...file("foreign"), hit: { codebaseId: "foreign" } }]; + const data = { identity: { codebaseId: "local" }, nodes, edges: [reference("a", "b", "Local"), reference("a", "foreign", "Foreign")], roots: [] }; + expect(inferConcepts(data).assignments.has("foreign")).toBe(false); + expect(inferConcepts({ ...data, nodes: [...nodes, file("duplicate", "src/a.rs")] }).assignments.size).toBe(0); + }); + test("excludes containment and redundant project associations from inference", () => { + const data = modules(), augmented = { ...data, edges: [...data.edges] }; + for (const node of data.nodes.filter(node => node.category === "file")) for (let repeat = 0; repeat < 6; repeat++) augmented.edges.push({ from: "workspace", to: node.id, label: "within project root", kind: "association" }, { from: "workspace", to: node.id, label: "contains", kind: "containment" }); + expect(assignments(inferConcepts(data))).toEqual(assignments(inferConcepts(augmented))); + }); + test("concept assignments drive different physical anchors, not merely renamed directory labels", () => { + const data = modules(), result = inferConcepts(data), categoryFor = node => result.assignments.get(node.id) ?? { key: "unassigned", label: "Unassigned", type: "No concept evidence" }; + const simulation = createSimulation(data, undefined, categoryFor); + expect(simulation.positions.get("a0").category).toBe(result.assignments.get("a0").key); expect(simulation.positions.get("b0").category).not.toBe(simulation.positions.get("a0").category); + const mean = members => members.reduce((value, id) => ({ x: value.x + simulation.positions.get(id).x / members.length, y: value.y + simulation.positions.get(id).y / members.length }), { x: 0, y: 0 }); + const a = mean(result.groups[0].members), b = mean(result.groups[1].members); expect(Math.hypot(a.x - b.x, a.y - b.y)).toBeGreaterThan(100); + }); + test("keeps complete identifier provenance beyond the compact label and exposes factual structure", () => { + const nodes = [file("a", "query/reader.rs"), file("b", "query/parser.rs")]; + const edges = Array.from({ length: 12 }, (_, index) => reference("a", "b", `Identifier${index}`)); + const result = inferConcepts({ nodes, edges, roots: [] }), group = result.groups[0]; + expect(result.groups).toHaveLength(1); expect(group.symbols).toHaveLength(12); + expect(new Set(group.symbols.map(item => item.moniker))).toEqual(new Set(edges.map(edge => edge.moniker))); + expect(group.relationCount).toBe(12); expect(group.directories).toEqual(["query"]); expect(group.projects).toEqual([""]); + expect(result.unassigned).toEqual([]); + }); + test("disambiguates repeated identifier labels with reported paths, not invented concepts", () => { + const nodes = [file("a0", "first/a.rs"), file("a1", "first/b.rs"), file("b0", "second/a.rs"), file("b1", "second/b.rs")]; + const edges = [reference("a0", "a1", "SharedIdentity", "first::SharedIdentity"), reference("b0", "b1", "SharedIdentity", "second::SharedIdentity")]; + const result = inferConcepts({ nodes, edges, roots: [] }); + expect(result.groups.map(group => group.label)).toEqual(["Shared Identity · first", "Shared Identity · second"]); + expect(result.groups.map(group => group.members)).toEqual([["a0", "a1"], ["b0", "b1"]]); + }); + test("community work is bounded by sparse adjacency visits, not all file pairs", () => { + const count = 1200, data = { nodes: Array.from({ length: count }, (_, index) => file(String(index).padStart(4, "0"))), edges: [], roots: [] }; + for (let index = 0; index < count; index++) data.edges.push(reference(data.nodes[index].id, data.nodes[(index + 1) % count].id, `Identifier${index % 40}`)); + const result = inferConcepts(data); expect(result.stats.passes).toBeLessThanOrEqual(16); expect(result.stats.visits).toBeLessThanOrEqual(data.edges.length * 32); expect(result.available).toBe(true); + }); +}); diff --git a/plugins/semctx/adapters/omp/graph-data/doc-concepts.d.ts b/plugins/semctx/adapters/omp/graph-data/doc-concepts.d.ts new file mode 100644 index 0000000..d4779f8 --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-data/doc-concepts.d.ts @@ -0,0 +1,41 @@ +import type { GraphData } from "../graph"; +import type { TermVector } from "./embedding.js"; +/** Repository-relative POSIX path of a catalog Markdown file and its text. */ +export interface DocSource { path: string; text: string } +/** Key "family:"; label is the humanized last folder segment. */ +export interface DocConceptFamily { key: string; label: string } +/** "doc": the source doc itself. "mention": explicit doc evidence. "reference": reference/import edges or a same-stem sibling. "folder": folder plurality. */ +export type DocConceptVia = "doc" | "mention" | "reference" | "folder"; +export interface DocConcept { + /** "doc:" */ + key: string; + /** Short display name, at most 28 characters, unique in the result. */ + label: string; + /** Full document title. */ + title: string; + /** Repository-relative path of the doc that names the concept. */ + source: string; + /** Only when at least 2 concepts share the doc folder. */ + family?: DocConceptFamily; + /** L2-normalized title and heading terms (embedding.js tokenizer). */ + terms: TermVector; + /** Assigned node IDs (files and directories), sorted. */ + members: string[]; +} +export interface DocConceptResult { + /** Sorted by member count desc, then key. */ + concepts: DocConcept[]; + /** Node ID -> concept. Project nodes, the checkout root and unsupported files have no entry. */ + assignments: Map; + /** `files` counts catalog file nodes; `assigned` counts the assigned ones. `hubs` lists index docs that point at too much code to name a concept. */ + stats: { docs: number; hubs: string[]; files: number; assigned: number }; +} +/** 256 files, 1 MiB per file, 16 MiB total. `docConcepts` applies the same bounds in path order. */ +export const DOC_SOURCE_LIMITS: Readonly<{ files: number; fileBytes: number; totalBytes: number }>; +/** .md, .markdown or .mdx, case-insensitive. */ +export function isDocPath(path: string): boolean; +/** Pure and deterministic: the result does not depend on node, edge or doc order. Docs whose path is not a catalog file node are ignored. */ +export function docConcepts(data: Pick, docs: readonly DocSource[]): DocConceptResult; +/** WeakMap binding, like `bindTermVector`. `undefined` removes the binding. */ +export function bindConceptFamily(node: object, family: DocConceptFamily | undefined): void; +export function conceptFamily(node: object): DocConceptFamily | undefined; diff --git a/plugins/semctx/adapters/omp/graph-data/doc-concepts.js b/plugins/semctx/adapters/omp/graph-data/doc-concepts.js new file mode 100644 index 0000000..5c53d55 --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-data/doc-concepts.js @@ -0,0 +1,442 @@ +// Human concepts from the checkout's Markdown documents: titles, headings and explicit path, file or identifier mentions. +// Evidence is lexical and explicit. No model or server inference runs, and a mention does not prove behavior. +import { centroid, identifierTerms } from "./embedding.js"; +import { normalizeConceptPath } from "./palette.js"; +const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0; +export const DOC_SOURCE_LIMITS = Object.freeze({ files: 256, fileBytes: 1 << 20, totalBytes: 16 << 20 }); +export function isDocPath(path) { return typeof path === "string" && /\.(?:md|markdown|mdx)$/i.test(path); } +const families = new WeakMap(); +export function bindConceptFamily(node, family) { if (family && typeof family.key === "string") families.set(node, family); else families.delete(node); } +export function conceptFamily(node) { return families.get(node); } +// Parse bounds: one pathological document cannot dominate the cost. Every parse pattern is linear in the text length. +// A catalog path has at most 4096 characters, so a longer token cannot name a file. +const mentionLimit = 4000, headingLimit = 24, tokenLimit = 4096; +// Weights fall as a mention gets less explicit. Names that more files share than the ambiguity bound give no evidence. +const fileWeight = 1, suffixWeight = .8, stemWeight = .6, symbolWeight = .5, suffixFiles = 2, identifierFiles = 3, evidenceCap = 3; +// A doc that points at more code than this is an index (hub) of the checkout, not a concept. +const hubMinimum = 64, hubShare = .2; +// A concept needs at least 3 evidenced code files from at least 2 distinct mentions, and must keep at least 3 files against competing docs. +// One example path, such as a sample argument in a tool page, does not name a topic. +const minimumReach = 3, minimumMentions = 2, minimumWinners = 3; +// Reference propagation needs a majority of the assigned neighbours that also covers a quarter of all code neighbours. +// Folder inheritance needs a clear plurality of at least 2 files that also covers a fifth of the folder, so a few files cannot claim a large tree. +// A directory node takes a concept only when that concept holds at least half of its files. +const propagationRounds = 3, propagationShare = .5, propagationCoverage = .25, folderShare = .6, folderCount = 2, folderCoverage = .2, directoryShare = .6, directoryCoverage = .5; +const labelLimit = 28, labelKeep = 6, headingWeight = .35, folderWeight = .3; + +// Literal-first patterns let the engine skip to candidates. Line constructs are found with indexOf and confirmed at their line start. +const headingLine = / {0,3}(#{1,6})(?:[ \t]+([^\r\n]*?))?(?:[ \t]+#+)?[ \t]*(?=\r?\n|$)/y, definition = /(?:^|\n) {0,3}\[[^\]\n]+\]:[ \t]*]+)>?[^\r\n]*/g; +const codeSpan = /`([^`\n]+)`/g, codeSeparator = /[^\w./\\*#:-]+/, link = /\[[^\]\n]*\]\([ \t]*]*)>?(?:[ \t]+(?:"[^"\n]*"|'[^'\n]*'))?[ \t]*\)/g; +const edgePunctuation = /^[("'<[{]+|[)"'>\]},;:!?.]+$/g; +const frontMatter = /^---[ \t]*\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/y, identifier = /^[A-Za-z_]\w*$/; +// Character classes of bare path-like tokens: 1 = [\w.-] (head and tail), 2 = "*" (tail only). +const pathCharacters = new Uint8Array(128); +for (const character of "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-") pathCharacters[character.charCodeAt(0)] = 1; +pathCharacters[42] = 2; +const pathHead = code => pathCharacters[code] === 1, pathTail = code => pathCharacters[code] > 0; +function addMention(doc, raw, linked) { + if (!raw || raw.length > tokenLimit || doc.count >= mentionLimit) return; + doc.count++; const key = (linked ? "L" : "C") + raw; doc.mentions.set(key, (doc.mentions.get(key) ?? 0) + 1); +} +// Bare tokens match `[\w.-]+(/[\w.*-]+)+/?` with the longest head. A slash scan is linear; "scheme://" URLs are skipped to the next space. +function scanPaths(text, doc) { + for (let slash = text.indexOf("/"); slash >= 0 && doc.count < mentionLimit; slash = text.indexOf("/", slash + 1)) { + if (text.charCodeAt(slash - 1) === 58 && text.charCodeAt(slash + 1) === 47) { while (slash < text.length && text.charCodeAt(slash) > 32) slash++; continue; } + let start = slash, end = slash; + while (start > 0 && pathHead(text.charCodeAt(start - 1))) start--; + if (start === slash) continue; + while (text.charCodeAt(end) === 47 && pathTail(text.charCodeAt(end + 1))) { end++; while (pathTail(text.charCodeAt(end))) end++; } + if (end === slash) continue; + if (text.charCodeAt(end) === 47) end++; + addMention(doc, text.slice(start, end), false); slash = end - 1; + } +} +// Reference definitions, code spans, link targets and bare path-like tokens are mentions. URLs, images and prose words are not. +// Each matched construct is blanked before the next scan, so one token counts once. +function scanMentions(region, doc) { + let rest = region; + if (rest.includes("]:")) rest = rest.replace(definition, (_, target) => { addMention(doc, target, true); return "\n"; }); + if (rest.includes("`")) rest = rest.replace(codeSpan, (_, body) => { if (doc.count < mentionLimit) for (const token of body.split(codeSeparator)) addMention(doc, token, false); return " "; }); + if (rest.includes("](")) rest = rest.replace(link, (_, target, offset, text) => { if (text.charCodeAt(offset - 1) !== 33) addMention(doc, target, true); return " "; }); + scanPaths(rest, doc); +} +function cleanMarkdown(text) { + return text.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/<[^>]*>/g, "").replace(/[`*]+|~~/g, "").replace(/(?").replace(/"/g, "\"").replace(/'/g, "'").replace(/\s+/g, " ").trim(); +} +// Start of the line that holds `index` when at most 3 spaces precede it on that line, otherwise -1. +function lineStart(text, index) { + let start = index; while (start > 0 && index - start < 3 && text.charCodeAt(start - 1) === 32) start--; + return start === 0 || text.charCodeAt(start - 1) === 10 ? start : -1; +} +// Remove blockquote markers before measuring code indentation. A tab advances to the next 4-column stop. +function blockLine(text, start, end) { + let position = start, quotes = 0; + for (;;) { + let marker = position; while (marker < end && marker - position < 3 && text.charCodeAt(marker) === 32) marker++; + if (text.charCodeAt(marker) !== 62) break; + position = marker + 1; quotes++; + if (text.charCodeAt(position) === 32 || text.charCodeAt(position) === 9) position++; + } + let indent = 0; + while (position < end) { + const code = text.charCodeAt(position); + if (code === 32) indent++; else if (code === 9) indent += 4 - indent % 4; else break; + position++; + } + return { position, indent, quotes }; +} +// List content starts after its marker and 1–4 spaces. Extra indentation can start a code block within the item. +function listContent(text, position, end, indent) { + const first = text.charCodeAt(position); let marker = position; + if (first === 42 || first === 43 || first === 45) marker++; + else { + while (marker < end && marker - position < 9 && text.charCodeAt(marker) >= 48 && text.charCodeAt(marker) <= 57) marker++; + if (marker === position || (text.charCodeAt(marker) !== 46 && text.charCodeAt(marker) !== 41)) return undefined; + marker++; + } + if (text.charCodeAt(marker) !== 32 && text.charCodeAt(marker) !== 9) return undefined; + let after = marker, padding = 0; + while (after < end && (text.charCodeAt(after) === 32 || text.charCodeAt(after) === 9)) { + padding += text.charCodeAt(after) === 9 ? 4 - (indent + marker - position + padding) % 4 : 1; after++; + } + if (padding > 4) { after = marker + 1; padding = text.charCodeAt(marker) === 9 ? 4 - (indent + marker - position) % 4 : 1; } + return { position: after, indent: indent + marker - position + padding }; +} +// Code blocks give no titles, headings or mentions. Track quote/list containers so nested fences and indented code stay excluded. +// An unclosed fence ends with its containing quote/list, or at the end of the document. +function proseRegions(text) { + const regions = [], lists = []; let fence, from = 0, start = 0, previousQuotes = 0; + while (start < text.length) { + let end = text.indexOf("\n", start); if (end < 0) end = text.length; + const next = Math.min(end + 1, text.length), first = text.charCodeAt(start); + // Plain unindented lines cannot open code blocks or list containers. + if (!fence && !lists.length && first !== 32 && first !== 9 && first !== 62 && first !== 96 && first !== 126 && first !== 42 && first !== 43 && first !== 45 && !(first >= 48 && first <= 57)) { + previousQuotes = 0; start = next; continue; + } + const block = blockLine(text, start, end), blank = block.position >= end || text.charCodeAt(block.position) === 13; + if (fence && !blank && (block.quotes < fence.quotes || block.indent < fence.listIndent)) fence = undefined; + if (block.quotes !== previousQuotes) { lists.length = 0; previousQuotes = block.quotes; } + if (!fence && !blank) while (lists.length && lists[lists.length - 1] > block.indent) lists.pop(); + const parent = lists[lists.length - 1] ?? 0, item = fence || block.indent - parent >= 4 ? undefined : listContent(text, block.position, end, block.indent); + let position = item?.position ?? block.position, base = fence?.listIndent ?? parent; + if (item) { lists.push(item.indent); base = item.indent; } + let indentation = item ? 0 : block.indent - base; + while (position < end && (text.charCodeAt(position) === 32 || text.charCodeAt(position) === 9)) { + indentation += text.charCodeAt(position) === 9 ? 4 - indentation % 4 : 1; position++; + } + const code = text.charCodeAt(position); let markerEnd = position; + if (code === 96 || code === 126) while (text.charCodeAt(markerEnd) === code) markerEnd++; + const length = markerEnd - position, rest = length >= 3 ? text.slice(markerEnd, end) : ""; + let excluded = Boolean(fence) || indentation >= 4; + if (fence) { + if (block.quotes === fence.quotes && code === fence.code && length >= fence.length && indentation <= 3 && !rest.trim()) fence = undefined; + } else if (indentation <= 3 && length >= 3 && (code === 126 || !rest.includes("`"))) { + fence = { code, length, quotes: block.quotes, listIndent: base }; excluded = true; + } + if (excluded) { if (from < start) regions.push(text.slice(from, start)); from = next; } + start = next; + } + if (from < text.length) regions.push(text.slice(from)); + return regions; +} +// ATX headings: the first level-1 text is the title; level-2 and level-3 texts are headings. A full slot skips the parse. +function scanHeadings(region, doc) { + for (let index = region.indexOf("#"); index >= 0 && (doc.h1 === undefined || doc.headings.length < headingLimit); index = region.indexOf("#", index + 1)) { + const start = lineStart(region, index); if (start < 0) continue; + let level = 0; while (level < 7 && region.charCodeAt(index + level) === 35) level++; + if (level === 1 ? doc.h1 === undefined : level <= 3 && doc.headings.length < headingLimit) { + headingLine.lastIndex = start; + const value = cleanMarkdown(headingLine.exec(region)?.[2] ?? ""); + if (value && level === 1) doc.h1 = value; else if (value) doc.headings.push(value); + } + index = region.indexOf("\n", index); if (index < 0) break; + } +} +function parseDoc(source) { + const doc = { h1: undefined, front: undefined, headings: [], mentions: new Map(), count: 0 }; + let text = source.charCodeAt(0) === 0xfeff ? source.slice(1) : source; + frontMatter.lastIndex = 0; + const front = frontMatter.exec(text); + if (front) { + const title = /^title:[ \t]*(.+?)[ \t]*$/m.exec(front[1])?.[1].replace(/^(["'])(.*)\1$/, "$2"); + doc.front = title ? cleanMarkdown(title) || undefined : undefined; text = text.slice(frontMatter.lastIndex); + } + for (const region of proseRegions(text)) { scanHeadings(region, doc); if (doc.count < mentionLimit) scanMentions(region, doc); } + return doc; +} +// Normalized mention: no URL, anchor, line suffix, leading "./" or "/". A "*" glob becomes a path prefix. +// Most tokens name nothing, so every rewrite runs only when its trigger character is present. +function normalizeMention(raw) { + let token = raw.includes("\\") ? raw.replace(/\\/g, "/") : raw; + if ("(\"'<[{".includes(token[0]) || ")\"'>]},;:!?.".includes(token[token.length - 1])) token = token.replace(edgePunctuation, ""); + if (token.includes(":")) { + if (/^(?:[a-z][a-z\d+.-]*:\/\/|mailto:|data:)/i.test(token)) return undefined; + token = token.replace(/(?::\d+)+(?:-\d+)?$/, ""); + const scope = token.lastIndexOf("::"); if (scope >= 0 && !token.includes("/")) token = token.slice(scope + 2); + } + const hash = token.indexOf("#"); if (hash === 0) return undefined; if (hash > 0) token = token.slice(0, hash); + if (token[0] === "." || token[0] === "/") token = token.replace(/^(?:\.\/)+/, "").replace(/^\/+/, ""); + const star = token.indexOf("*"), glob = star >= 0; if (glob) token = token.slice(0, star); + if (token && ".,;:!?".includes(token[token.length - 1])) token = token.replace(/[.,;:!?]+$/, ""); + return token ? { token, glob } : undefined; +} +function joinPath(folder, token) { + if (!folder && !token.includes("./") && !token.includes("//") && !token.endsWith("/")) return token; + const parts = folder ? folder.split("/") : []; + for (const part of token.split("/")) { if (!part || part === ".") continue; if (part !== "..") parts.push(part); else if (!parts.pop()) return undefined; } + return parts.join("/"); +} +const parentOf = path => { const cut = path.lastIndexOf("/"); return cut < 0 ? "" : path.slice(0, cut); }; +const nameOf = path => path.slice(path.lastIndexOf("/") + 1); +const stemOf = path => { const name = nameOf(path), cut = name.lastIndexOf("."); return cut > 0 ? name.slice(0, cut) : name; }; +function ancestors(path) { const folders = []; for (let folder = parentOf(path); folder; folder = parentOf(folder)) folders.push(folder); return folders; } +function catalog(data) { + const byPath = category => { + const counts = new Map(), ids = new Map(); + for (const node of data.nodes) if (node?.category === category && typeof node.id === "string") { + const path = normalizeConceptPath(node.path); if (!path) continue; + counts.set(path, (counts.get(path) ?? 0) + 1); ids.set(path, node.id); + } + // Two nodes with one path are ambiguous. Neither receives a concept. + for (const [path, count] of counts) if (count > 1) ids.delete(path); + return ids; + }; + const files = byPath("file"), directories = byPath("directory"), paths = [...files.keys()].sort(compare), code = paths.filter(path => !isDocPath(path)); + const names = new Map(), stems = new Map(), symbols = new Map(), neighbours = new Map(code.map(path => [path, new Set()])), pathOf = new Map(), sizes = new Map(); + const link = (a, b) => { if (a !== b && neighbours.has(a) && neighbours.has(b)) { neighbours.get(a).add(b); neighbours.get(b).add(a); } }; + for (const path of paths) { + pathOf.set(files.get(path), path); + for (const [index, key] of [[names, nameOf(path)], [stems, stemOf(path)]]) { if (!index.has(key)) index.set(key, []); index.get(key).push(path); } + for (const folder of ancestors(path)) sizes.set(folder, (sizes.get(folder) ?? 0) + 1); + } + for (const edge of data.edges) { + const from = pathOf.get(edge?.from), to = pathOf.get(edge?.to); if (!from || !to || from === to) continue; + if (edge.kind === "reference" && typeof edge.symbol === "string") { if (!symbols.has(edge.symbol)) symbols.set(edge.symbol, new Set()); symbols.get(edge.symbol).add(to); } + if (edge.kind === "reference" || edge.kind === "import") link(from, to); + } + // Files that share a stem within the identifier ambiguity bound (a header, its source and its test) are linked like an import. + for (const group of stems.values()) if (group.length > 1 && group.length <= identifierFiles) for (const a of group) for (const b of group) link(a, b); + // Resolved mention targets per call: code tokens resolve from the checkout root, link targets also from the doc folder. + return { files, directories, paths, code, codeSet: new Set(code), names, stems, symbols, neighbours, sizes, cache: { code: new Map(), linked: new Map() } }; +} +const nothing = Object.freeze([]); +// Sorted code paths make a directory or glob prefix one binary-searched range. +function prefixRange(sorted, prefix) { + const bound = value => { let low = 0, high = sorted.length; while (low < high) { const middle = (low + high) >> 1; if (sorted[middle] < value) low = middle + 1; else high = middle; } return low; }; + const low = bound(prefix); if (low === sorted.length || !sorted[low].startsWith(prefix)) return nothing; + return sorted.slice(low, bound(prefix + "\uffff")); +} +const spread = paths => paths.map(path => [path, 1 / Math.sqrt(paths.length)]); +const shared = (paths, weight) => paths.map(path => [path, weight / paths.length]); +// Precedence: exact file, directory or glob prefix, path suffix, file stem, defining file of a referenced symbol. +function resolveMention(index, mention, linked, folder) { + const { token, glob } = mention, roots = []; + if (linked) { + const relative = joinPath(folder, token); + // A link that leaves the checkout names an external file, not a local suffix or basename. + if (relative === undefined) return nothing; + roots.push(relative); + } + roots.push(joinPath("", token)); + for (const root of roots) { + if (!root) continue; + if (glob) { const below = prefixRange(index.code, token.endsWith("/") ? root + "/" : root); if (below.length) return spread(below); continue; } + if (index.files.has(root)) return [[root, fileWeight]]; + // A bare word names a directory only with a slash or as a link target. Words such as `hv` or `tools` are too ambiguous. + if (linked || token.includes("/")) { const below = prefixRange(index.code, root + "/"); if (below.length) return spread(below); } + } + if (glob) return nothing; + const suffix = token.startsWith("../") || token.endsWith("/") ? token.replace(/^(?:\.\.\/)+/, "").replace(/\/+$/, "") : token; + if (!suffix || suffix.includes("..")) return nothing; + const named = index.names.get(nameOf(suffix))?.filter(path => path === suffix || path.endsWith("/" + suffix)); + if (named?.length) return named.length <= suffixFiles ? shared(named, suffixWeight) : nothing; + if (suffix.length < 5 || !identifier.test(suffix) || !(suffix.includes("_") || /[a-z\d][A-Z]/.test(suffix))) return nothing; + const stems = index.stems.get(suffix); + if (stems && stems.length <= identifierFiles) return shared(stems, stemWeight); + const defined = index.symbols.get(suffix); + return defined && defined.size <= identifierFiles ? shared([...defined].sort(compare), symbolWeight) : nothing; +} +function humanize(text) { + const words = text.replace(/([a-z\d])([A-Z])/g, "$1 $2").replace(/[\s._-]+/g, " ").trim(); + return words ? words[0].toUpperCase() + words.slice(1) : ""; +} +function fallbackTitle(path) { + const stem = stemOf(path); + if (!/^readme$/i.test(stem)) return humanize(stem) || stem; + const folder = parentOf(path); return humanize(nameOf(folder)) || "Readme"; +} +// The humanized folder name takes the casing that a member title uses for it, so "amd" becomes "AMD" when a title says "AMD". +function familyLabel(folder, titles) { + const plain = humanize(nameOf(folder)), pattern = new RegExp(`(? 0 ? head.slice(0, space) : head).replace(/[\s,;:·–—-]+$/, "")}…`; +} +// A title that fits stays whole. A longer title keeps a subtitle-free prefix of at least 6 characters. +// Coordinating words are not subtitle separators: cutting "source and implementation bindings" at "and" loses the subject. +function shortLabel(title) { + if (title.length <= labelLimit) return title; + let cut = -1; + for (const separator of [" — ", " – ", ": ", " ("]) { + const index = title.indexOf(separator, labelKeep); + if (index >= 0 && (cut < 0 || index < cut)) cut = index; + } + return cut < 0 ? title : title.slice(0, cut).replace(/[\s,;:·–—-]+$/, ""); +} +// Repeated labels take the doc folder name. An ordinal resolves what still repeats. +function labels(concepts) { + const bases = new Map(concepts.map(concept => [concept.key, shortLabel(concept.title)])), counts = new Map(); + for (const base of bases.values()) { const label = capLabel(base, labelLimit); counts.set(label, (counts.get(label) ?? 0) + 1); } + const used = new Set(), result = new Map(); + for (const concept of concepts) { + const base = bases.get(concept.key); let named = capLabel(base, labelLimit); + if (counts.get(named) > 1) { const suffix = ` · ${capLabel(nameOf(parentOf(concept.source)) || stemOf(concept.source), 12)}`; named = capLabel(base, labelLimit - suffix.length) + suffix; } + let label = named; + for (let ordinal = 2; used.has(label); ordinal++) { const suffix = ` ${ordinal}`; label = capLabel(named, labelLimit - suffix.length) + suffix; } + used.add(label); result.set(concept.key, label); + } + return result; +} +function termVector(title, headings, source) { + const weights = new Map(), add = (text, weight) => { for (const term of identifierTerms(text)) weights.set(term, (weights.get(term) ?? 0) + weight); }; + add(title, 1); for (const value of headings) add(value, headingWeight); add(nameOf(parentOf(source)), folderWeight); + return centroid([weights]); +} +// Plurality with its count and share. Ties have no winner. +function plurality(tally) { + let best, count = 0, total = 0, tied = false; + for (const [key, value] of tally) { total += value; if (value > count) { best = key; count = value; tied = false; } else if (value === count) tied = true; } + return best === undefined || tied ? undefined : { key: best, count, share: count / total }; +} +function tallyAdd(tally, key) { tally.set(key, (tally.get(key) ?? 0) + 1); } +// Concept counts of the assigned files below each folder. The checkout root is not a folder here. +function folderTallies(assigned) { + const folders = new Map(); + for (const [path, { key }] of assigned) for (const folder of ancestors(path)) { if (!folders.has(folder)) folders.set(folder, new Map()); tallyAdd(folders.get(folder), key); } + return folders; +} +function selectDocs(index, docs) { + const sources = new Map(); + for (const doc of docs) { + const path = normalizeConceptPath(doc?.path); + if (!path || typeof doc.text !== "string" || !isDocPath(path) || !index.files.has(path)) continue; + // Repeated paths keep the smallest text, so source order cannot change the result. + const text = doc.text.slice(0, DOC_SOURCE_LIMITS.fileBytes), known = sources.get(path); + if (known === undefined || text < known) sources.set(path, text); + } + const selected = []; let total = 0; + for (const path of [...sources.keys()].sort(compare)) { + const text = sources.get(path); + if (selected.length >= DOC_SOURCE_LIMITS.files || total + text.length > DOC_SOURCE_LIMITS.totalBytes) break; + total += text.length; selected.push({ path, text }); + } + return selected; +} +function evidenceOf(index, doc, path) { + const evidence = new Map(), cache = index.cache, folder = parentOf(path); let mentions = 0; + for (const [key, count] of doc.mentions) { + const linked = key[0] === "L", raw = key.slice(1), cacheKey = linked ? `${folder}\u0000${raw}` : raw; + let targets = (linked ? cache.linked : cache.code).get(cacheKey); + if (!targets) { + const mention = normalizeMention(raw); + targets = mention ? resolveMention(index, mention, linked, folder) : nothing; + (linked ? cache.linked : cache.code).set(cacheKey, targets); + } + let evidenced = false; + for (const [target, weight] of targets) if (target !== path && index.codeSet.has(target)) { evidence.set(target, Math.min(evidenceCap, (evidence.get(target) ?? 0) + weight * count)); evidenced = true; } + if (evidenced) mentions++; + } + return { evidence, mentions }; +} +// Each evidenced code file goes to its best candidate: score, then the narrower doc, then the source path. +// A candidate that keeps fewer than 3 files dissolves, weakest first, and its files go to their best remaining candidate. +function mentionWinners(candidates, codeCount) { + const preferences = new Map(); + for (const doc of candidates) { + const specificity = Math.log1p(codeCount / doc.reach); + for (const [path, evidence] of doc.evidence) { if (!preferences.has(path)) preferences.set(path, []); preferences.get(path).push({ doc, score: evidence * specificity }); } + } + for (const list of preferences.values()) list.sort((a, b) => b.score - a.score || a.doc.reach - b.doc.reach || compare(a.doc.path, b.doc.path)); + const active = new Set(candidates), winners = new Map(), best = path => preferences.get(path).find(item => active.has(item.doc))?.doc; + for (const path of [...preferences.keys()].sort(compare)) winners.set(path, best(path)); + for (;;) { + const won = new Map([...active].map(doc => [doc, 0])); + for (const doc of winners.values()) if (doc) won.set(doc, won.get(doc) + 1); + let weakest; + for (const [doc, count] of won) if (!weakest || count < weakest.count || (count === weakest.count && compare(doc.key, weakest.doc.key) < 0)) weakest = { doc, count }; + if (!weakest || weakest.count >= minimumWinners) break; + active.delete(weakest.doc); + for (const [path, doc] of winners) if (doc === weakest.doc) winners.set(path, best(path)); + } + const assigned = new Map(); + for (const [path, doc] of winners) if (doc) assigned.set(path, { key: doc.key, via: "mention" }); + return { active, assigned }; +} +// Synchronous rounds: every decision in one round reads the state from the round start. +function propagate(index, assigned) { + for (let round = 0; round < propagationRounds; round++) { + const updates = []; + for (const path of index.code) { + if (assigned.has(path)) continue; + const tally = new Map(), neighbours = index.neighbours.get(path); + for (const other of neighbours) { const item = assigned.get(other); if (item) tallyAdd(tally, item.key); } + // The coverage term counts every code neighbour, so a sparse assignment cannot spread through unassigned code. + const top = plurality(tally); if (top && top.share >= propagationShare && top.count >= propagationCoverage * neighbours.size) updates.push([path, top.key]); + } + if (!updates.length) return; + for (const [path, key] of updates) assigned.set(path, { key, via: "reference" }); + } +} +// One pass from the current state: every remaining file takes the decision of its nearest qualifying ancestor folder. +function inheritFolders(index, assigned) { + const inherited = new Map(); + for (const [folder, tally] of folderTallies(assigned)) { + const top = plurality(tally); + if (top && top.count >= Math.max(folderCount, folderCoverage * index.sizes.get(folder)) && top.share >= folderShare) inherited.set(folder, top.key); + } + const inheritance = []; + for (const path of index.paths) { const folder = assigned.has(path) ? undefined : ancestors(path).find(value => inherited.has(value)); if (folder) inheritance.push([path, inherited.get(folder)]); } + for (const [path, key] of inheritance) assigned.set(path, { key, via: "folder" }); +} +function directoryAssignments(index, assigned) { + const below = folderTallies(assigned), assignments = []; + for (const [path, id] of index.directories) { + const top = below.has(path) ? plurality(below.get(path)) : undefined; + if (top && top.share >= directoryShare && top.count >= directoryCoverage * (index.sizes.get(path) ?? 0)) assignments.push([id, { key: top.key, via: "folder" }]); + } + return assignments; +} +// Concept docs arrive in path order, so labels and ordinals follow concept keys. +function describeConcepts(concepts, members) { + const folderTitles = new Map(); + for (const doc of concepts) { const folder = parentOf(doc.path); if (folder) { if (!folderTitles.has(folder)) folderTitles.set(folder, []); folderTitles.get(folder).push(doc.title); } } + const familyOf = new Map([...folderTitles].filter(([, titles]) => titles.length >= 2).map(([folder, titles]) => [folder, Object.freeze({ key: `family:${folder}`, label: familyLabel(folder, titles) })])); + const labelOf = labels(concepts.map(doc => ({ key: doc.key, title: doc.title, source: doc.path }))); + return concepts.map(doc => { + const family = familyOf.get(parentOf(doc.path)), concept = { key: doc.key, label: labelOf.get(doc.key), title: doc.title, source: doc.path, terms: termVector(doc.title, doc.headings, doc.path), members: members.get(doc.key) }; + return family ? { ...concept, family } : concept; + }).sort((a, b) => b.members.length - a.members.length || compare(a.key, b.key)); +} +export function docConcepts(data, docs) { + const index = catalog(data); + const parsed = selectDocs(index, docs).map(({ path, text }) => { + const doc = parseDoc(text), { evidence, mentions } = evidenceOf(index, doc, path); + return { path, key: `doc:${path}`, title: doc.h1 ?? doc.front ?? fallbackTitle(path), headings: doc.headings, evidence, mentions, reach: evidence.size }; + }); + const codeCount = index.code.length, hubLimit = Math.max(hubMinimum, hubShare * codeCount); + const hubs = parsed.filter(doc => doc.reach > hubLimit), candidates = parsed.filter(doc => doc.reach <= hubLimit && doc.reach >= minimumReach && doc.mentions >= minimumMentions); + const { active, assigned } = mentionWinners(candidates, codeCount); + propagate(index, assigned); + const concepts = parsed.filter(doc => active.has(doc)); + for (const doc of concepts) assigned.set(doc.path, { key: doc.key, via: "doc" }); + inheritFolders(index, assigned); + const assignments = [...[...assigned].map(([path, item]) => [index.files.get(path), item]), ...directoryAssignments(index, assigned)].sort((a, b) => compare(a[0], b[0])); + const members = new Map(concepts.map(doc => [doc.key, []])); + for (const [id, { key }] of assignments) members.get(key).push(id); + return { concepts: describeConcepts(concepts, members), assignments: new Map(assignments), stats: { docs: parsed.length, hubs: hubs.map(doc => doc.path), files: index.paths.length, assigned: assigned.size } }; +} diff --git a/plugins/semctx/adapters/omp/graph-data/doc-concepts.test.js b/plugins/semctx/adapters/omp/graph-data/doc-concepts.test.js new file mode 100644 index 0000000..75d930e --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-data/doc-concepts.test.js @@ -0,0 +1,230 @@ +import { describe, expect, test } from "bun:test"; +import { bindConceptFamily, conceptFamily, docConcepts, DOC_SOURCE_LIMITS, isDocPath } from "./doc-concepts.js"; +const file = path => ({ id: path, label: path, detail: null, hit: null, category: "file", path }); +const directory = path => ({ id: `dir:${path}`, label: path || "Indexed files", detail: null, hit: null, category: "directory", path }); +const reference = (from, to, symbol = "shared_value") => ({ from, to, label: "references", kind: "reference", symbol, moniker: `${to}#${symbol}` }); +const graph = (paths, edges = [], extra = []) => ({ nodes: [...paths.map(file), ...extra], edges }); +const conceptOf = (result, source) => result.concepts.find(concept => concept.source === source); +const via = (result, id) => result.assignments.get(id)?.via; +const keyOf = (result, id) => result.assignments.get(id)?.key; +const spans = paths => paths.map(path => `\`${path}\``).join(", "); +// One doc that names 3 files of its own area; the minimal fixture that forms a concept. +const topic = (doc, title, area) => ({ doc, title, files: [0, 1, 2].map(index => `${area}/f${index}.ts`) }); +function topics(specs) { + return { data: graph(specs.flatMap(spec => [spec.doc, ...spec.files])), docs: specs.map(spec => ({ path: spec.doc, text: `# ${spec.title}\n\n${spans(spec.files)}\n` })) }; +} +const snapshot = result => ({ concepts: result.concepts.map(concept => ({ ...concept, terms: [...concept.terms] })), assignments: [...result.assignments], stats: result.stats }); + +describe("doc concept parsing", () => { + test("takes the first H1 outside fenced code, H2/H3 headings and code, link and bare path mentions", () => { + const text = [ + "---", "title: Front title", "---", + "Intro names `src/a/one.ts` and [two](../src/a/two.ts \"Two\") and [three](src/a/three.ts).", + "```text", "# Fenced heading", "`lib/fenced.ts` lib/fenced2.ts", "```", + "# Storage engine — blocks and journals", "", + "## Block layout", "### Journal `replay`", "#### Deep detail", + "Ignored: https://lib/url.ts, [site](https://lib/link.ts), ![image](../lib/image.ts) and #hashtag.", + "~~~", "src/a/one.ts", "~~~", + ].join("\n"); + const paths = ["docs/storage.md", "src/a/one.ts", "src/a/two.ts", "src/a/three.ts", "lib/fenced.ts", "lib/fenced2.ts", "lib/url.ts", "lib/link.ts", "lib/image.ts"]; + const result = docConcepts(graph(paths), [{ path: "docs/storage.md", text }]), concept = conceptOf(result, "docs/storage.md"); + expect(concept.title).toBe("Storage engine — blocks and journals"); expect(concept.label).toBe("Storage engine"); expect(concept.key).toBe("doc:docs/storage.md"); + expect(concept.members).toEqual(["docs/storage.md", "src/a/one.ts", "src/a/three.ts", "src/a/two.ts"]); + for (const path of ["src/a/one.ts", "src/a/two.ts", "src/a/three.ts"]) expect(via(result, path)).toBe("mention"); + expect(via(result, "docs/storage.md")).toBe("doc"); + for (const path of paths.filter(path => path.startsWith("lib/"))) expect(result.assignments.has(path)).toBe(false); + for (const term of ["storage", "engine", "block", "layout", "journal", "replay", "docs"]) expect(concept.terms.has(term)).toBe(true); + for (const term of ["fenced", "heading", "deep", "detail", "front"]) expect(concept.terms.has(term)).toBe(false); + }); + test("excludes indented code and fences inside quotes and list items", () => { + const hidden = ["hidden/alpha.ts", "hidden/beta.ts", "hidden/gamma.ts"], live = ["live/a.ts", "live/b.ts", "live/c.ts"], data = graph(["docs/example.md", ...hidden, ...live]); + const lines = ["# Hidden heading", "## Hidden terms", ...hidden]; + const examples = [ + lines.map(line => ` ${line}`).join("\n"), + lines.map(line => `\t${line}`).join("\n"), + hidden.map(path => ` - ${path}`).join("\n"), + ["> ```md", ...lines.map(line => `> ${line}`), "> ```"].join("\n"), + ["> > ~~~md", ...lines.map(line => `> > ${line}`), "> > ~~~"].join("\n"), + ["- ```md", ...lines.map(line => ` ${line}`), " ```"].join("\n"), + ["1. Example", "", " ```md", ...lines.map(line => ` ${line}`), " ```"].join("\n"), + ["- Outer", " - Inner", "", " ~~~md", ...lines.map(line => ` ${line}`), " ~~~"].join("\n"), + ["> - ```md", ...lines.map(line => `> ${line}`), "> ```"].join("\n"), + ["- Example", "", ...lines.map(line => ` ${line}`)].join("\n"), + ["> ```md", ...lines.map(line => `> ${line}`)].join("\n"), + ["```md", "> ```", ...lines, "```"].join("\n"), + ]; + for (const code of examples) { + expect(docConcepts(data, [{ path: "docs/example.md", text: `# CLI examples\n\n${code}\n` }]).concepts).toEqual([]); + const result = docConcepts(data, [{ path: "docs/example.md", text: `${code}\n\n# Real topic\n\n${spans(live)}\n` }]), concept = conceptOf(result, "docs/example.md"); + expect(concept.title).toBe("Real topic"); expect(concept.terms.has("hidden")).toBe(false); + for (const path of hidden) expect(result.assignments.has(path)).toBe(false); + for (const path of live) expect(via(result, path)).toBe("mention"); + } + }); + test("falls back to the front-matter title, then the humanized stem or README folder", () => { + const area = name => [0, 1, 2].map(index => `${name}/f${index}.ts`); + const docs = [ + { path: "docs/front.md", text: `---\ntitle: "Front matter title"\n---\n${spans(area("a"))}\n` }, + { path: "docs/hypercall-channel.md", text: `Plain text with ${spans(area("b"))}.\n` }, + { path: "docs/runtime/README.md", text: `## Only a heading\n${spans(area("c"))}\n` }, + ]; + const result = docConcepts(graph([...docs.map(doc => doc.path), ...area("a"), ...area("b"), ...area("c")]), docs); + expect(docs.map(doc => conceptOf(result, doc.path).title)).toEqual(["Front matter title", "Hypercall channel", "Runtime"]); + }); + test("ignores mentions after the per-document bound", () => { + const paths = ["docs/late.md", "src/a.ts", "src/b.ts", "src/c.ts"], text = `${"`nothing` ".repeat(4000)}\n${spans(paths.slice(1))}\n`; + expect(docConcepts(graph(paths), [{ path: "docs/late.md", text }]).concepts).toEqual([]); + expect(docConcepts(graph(paths), [{ path: "docs/late.md", text: spans(paths.slice(1)) }]).concepts).toHaveLength(1); + }); +}); + +describe("doc concept evidence", () => { + test("resolves exact files, directories, globs, suffixes, stems and symbols within the ambiguity bounds", () => { + const paths = ["docs/topic.md", "core/exact.rs", "vendor/core/exact.rs", "pkg/p0.rs", "pkg/p1.rs", "globbed/exit_a.rs", "globbed/exit_b.rs", "globbed/other.rs", + "one/unique_name.rs", "two_a/twice.rs", "two_b/twice.rs", "t1/thrice.rs", "t2/thrice.rs", "t3/thrice.rs", "stem/ParserState.ts", "store/vector.rs", "s1/x.rs", "s2/x.rs", "s3/x.rs", "s4/x.rs", "tools/run.rs", "caller/use.rs"]; + const edges = [reference("caller/use.rs", "store/vector.rs", "vector_store"), ...["s1", "s2", "s3", "s4"].map(folder => reference("caller/use.rs", `${folder}/x.rs`, "Shared_symbol"))]; + const text = `${spans(["core/exact.rs", "pkg/", "globbed/exit_*", "unique_name.rs", "twice.rs", "thrice.rs", "ParserState", "vector_store", "Shared_symbol", "tools", "short"])}\n`; + const result = docConcepts(graph(paths, edges), [{ path: "docs/topic.md", text }]), mentioned = [...result.assignments].filter(([, item]) => item.via === "mention").map(([id]) => id); + expect(mentioned).toEqual(["core/exact.rs", "globbed/exit_a.rs", "globbed/exit_b.rs", "one/unique_name.rs", "pkg/p0.rs", "pkg/p1.rs", "stem/ParserState.ts", "store/vector.rs", "two_a/twice.rs", "two_b/twice.rs"]); + }); + test("rejects links that escape the checkout before any suffix or basename fallback", () => { + const files = ["src/a.ts", "src/b.ts", "src/c.ts"], data = graph(["docs/external.md", ...files]); + for (const prefix of ["../../", "part/../../../"]) { + const text = files.map((path, index) => `[file ${index}](${prefix}${path})`).join("\n"); + expect(docConcepts(data, [{ path: "docs/external.md", text }]).concepts).toEqual([]); + } + const definitions = files.map((path, index) => `[file${index}]: ../../${path}`).join("\n"); + expect(docConcepts(data, [{ path: "docs/external.md", text: definitions }]).concepts).toEqual([]); + }); + test("retains nonescaping relative links and prefers the doc folder before the checkout root", () => { + const chosen = ["first/a.ts", "second/b.ts", "docs/guide/src/c.ts"], data = graph(["docs/guide/topic.md", ...chosen, "src/c.ts"]); + const text = "[a](../../first/a.ts)\n[b](../../second/b.ts)\n[c](src/c.ts)\n"; + const result = docConcepts(data, [{ path: "docs/guide/topic.md", text }]); + for (const path of chosen) expect(via(result, path)).toBe("mention"); + expect(via(result, "src/c.ts")).not.toBe("mention"); + }); + test("weights an exact path above a basename and a file stem above a symbol", () => { + const a = ["a/a0.ts", "a/a1.ts", "a/a2.ts"], b = ["b/b0.ts", "b/b1.ts", "b/b2.ts"], edges = [reference("a/a0.ts", "x/state_table.ts", "symbol_only")]; + const data = graph(["docs/a.md", "docs/b.md", ...a, ...b, "x/contested.ts", "x/state_table.ts"], edges); + const result = docConcepts(data, [{ path: "docs/a.md", text: spans([...a, "x/contested.ts", "symbol_only"]) }, { path: "docs/b.md", text: spans([...b, "contested.ts", "state_table"]) }]); + expect(keyOf(result, "x/contested.ts")).toBe("doc:docs/a.md"); expect(keyOf(result, "x/state_table.ts")).toBe("doc:docs/b.md"); + }); + test("ignores self mentions and doc-to-doc mentions as membership evidence", () => { + const paths = ["docs/index.md", "docs/a.md", "docs/b.md", "docs/c.md"], text = spans(["docs/index.md", "docs/a.md", "b.md", "./c.md"]); + const result = docConcepts(graph(paths), [{ path: "docs/index.md", text }]); + expect(result.concepts).toEqual([]); expect(result.assignments.size).toBe(0); + }); + test("one example path does not name a concept", () => { + const paths = ["docs/tool.md", ...[0, 1, 2, 3, 4].map(index => `src/auth/f${index}.rs`)]; + expect(docConcepts(graph(paths), [{ path: "docs/tool.md", text: "Filter example: `path: \"src/auth\"`.\n" }]).concepts).toEqual([]); + }); + test("excludes hub docs that point at too much code", () => { + const many = Array.from({ length: 70 }, (_, index) => `mod/f${index}.ts`), paths = ["docs/index.md", "docs/focus.md", ...many, "extra/one.ts"]; + const result = docConcepts(graph(paths), [{ path: "docs/index.md", text: spans(["mod/", "extra/one.ts"]) }, { path: "docs/focus.md", text: spans(many.slice(0, 3)) }]); + expect(result.stats.hubs).toEqual(["docs/index.md"]); expect(result.concepts.map(concept => concept.source)).toEqual(["docs/focus.md"]); + expect(conceptOf(result, "docs/focus.md").members).toEqual(["docs/focus.md", "mod/f0.ts", "mod/f1.ts", "mod/f2.ts"]); + expect(result.assignments.has("docs/index.md")).toBe(false); expect(result.assignments.has("extra/one.ts")).toBe(false); + }); + test("dissolves a candidate that keeps fewer than 3 files and reassigns them to the next candidate", () => { + const big = ["big/b0.ts", "big/b1.ts", "big/b2.ts", "big/b3.ts"], paths = ["docs/big.md", "docs/small.md", ...big, "small/s1.ts", "small/s2.ts", "small/s3.ts"]; + const edges = [reference("big/b0.ts", "small/s3.ts", "vector_store")]; + const docs = [{ path: "docs/big.md", text: spans([...big, "small/s3.ts", "small/s3.ts", "small/s3.ts", "s1.ts"]) }, { path: "docs/small.md", text: spans(["small/s1.ts", "small/s2.ts", "vector_store"]) }]; + const result = docConcepts(graph(paths, edges), docs); + expect(result.concepts.map(concept => concept.source)).toEqual(["docs/big.md"]); + expect([via(result, "small/s1.ts"), keyOf(result, "small/s1.ts")]).toEqual(["mention", "doc:docs/big.md"]); + expect([via(result, "small/s3.ts"), keyOf(result, "small/s3.ts")]).toEqual(["mention", "doc:docs/big.md"]); + expect(via(result, "small/s2.ts")).toBe("folder"); expect(result.assignments.has("docs/small.md")).toBe(false); + }); +}); + +describe("doc concept propagation", () => { + test("propagates over references and same-stem siblings only with a majority that covers the neighbourhood", () => { + const core = ["core/m1.ts", "core/m2.ts", "core/m3.ts"], noise = ["noise/u1.ts", "noise/u2.ts", "noise/u3.ts", "noise/u4.ts"]; + const paths = ["docs/core.md", ...core, "near/p.ts", "far/r.ts", "weak/q.ts", "include/m1.h", ...noise]; + const edges = [reference("near/p.ts", "core/m1.ts"), reference("core/m2.ts", "near/p.ts"), reference("far/r.ts", "near/p.ts"), reference("weak/q.ts", "core/m1.ts"), ...noise.map(path => reference("weak/q.ts", path))]; + const result = docConcepts(graph(paths, edges), [{ path: "docs/core.md", text: spans(core) }]); + expect(via(result, "near/p.ts")).toBe("reference"); expect(via(result, "far/r.ts")).toBe("reference"); expect(via(result, "include/m1.h")).toBe("reference"); + expect(result.assignments.has("weak/q.ts")).toBe(false); for (const path of noise) expect(result.assignments.has(path)).toBe(false); + }); + test("leaves a file with tied neighbour concepts unassigned", () => { + const { data, docs } = topics([topic("docs/a.md", "Alpha", "a"), topic("docs/b.md", "Beta", "b")]); + data.nodes.push(file("between/t.ts")); data.edges.push(reference("between/t.ts", "a/f0.ts"), reference("between/t.ts", "b/f0.ts")); + expect(docConcepts(data, docs).assignments.has("between/t.ts")).toBe(false); + }); + test("inherits the nearest folder plurality only when it covers a fifth of the folder", () => { + const big = Array.from({ length: 20 }, (_, index) => `big/x${index}.ts`); + const paths = ["docs/topic.md", "src/f/a.ts", "src/f/b.ts", "src/f/c.ts", "src/f/d.ts", "src/f/deep/g.ts", "src/f/NOTES.md", ...big]; + const extra = [directory(""), directory("src"), directory("src/f"), directory("src/f/deep"), directory("big"), { id: "project", label: "Workspace", detail: null, hit: null, category: "project", path: "" }]; + const result = docConcepts(graph(paths, [], extra), [{ path: "docs/topic.md", text: spans(["src/f/a.ts", "src/f/b.ts", "src/f/c.ts", "big/x0.ts", "big/x1.ts"]) }]); + for (const path of ["src/f/d.ts", "src/f/deep/g.ts", "src/f/NOTES.md"]) expect(via(result, path)).toBe("folder"); + expect(big.filter(path => result.assignments.has(path))).toEqual(["big/x0.ts", "big/x1.ts"]); + for (const id of ["dir:src", "dir:src/f", "dir:src/f/deep"]) expect([via(result, id), keyOf(result, id)]).toEqual(["folder", "doc:docs/topic.md"]); + for (const id of ["dir:", "dir:big", "project"]) expect(result.assignments.has(id)).toBe(false); + expect(result.stats).toEqual({ docs: 1, hubs: [], files: paths.length, assigned: 9 }); + }); +}); + +describe("doc concept labels and families", () => { + test("shortens long titles at separators, caps them at a word boundary and resolves duplicates", () => { + const { data, docs } = topics([ + topic("docs/storage.md", "Storage engine — blocks and journals", "s"), topic("docs/build.md", "Build and test", "b"), + topic("docs/uefi.md", "UEFI, PI, and ACPI specification bindings", "u"), topic("docs/long.md", "Extraordinarily comprehensive hypervisor bring-up", "l"), + topic("docs/amd/bindings.md", "Bindings", "amd"), topic("docs/intel/bindings.md", "Bindings", "intel"), + topic("docs/a/same.md", "Same", "x"), topic("docs/b/a/same.md", "Same", "y"), topic("docs/source.md", "AMD source and implementation bindings", "binding"), + ]); + const result = docConcepts(data, docs), labels = new Map(result.concepts.map(concept => [concept.source, concept.label])); + expect(labels.get("docs/storage.md")).toBe("Storage engine"); expect(labels.get("docs/build.md")).toBe("Build and test"); + expect(labels.get("docs/uefi.md")).toBe("UEFI, PI, and ACPI…"); expect(labels.get("docs/long.md")).toBe("Extraordinarily…"); + expect(labels.get("docs/source.md")).toBe("AMD source and…"); + expect(labels.get("docs/amd/bindings.md")).toBe("Bindings · amd"); expect(labels.get("docs/intel/bindings.md")).toBe("Bindings · intel"); + expect([labels.get("docs/a/same.md"), labels.get("docs/b/a/same.md")]).toEqual(["Same · a", "Same · a 2"]); + expect(new Set(labels.values()).size).toBe(labels.size); for (const label of labels.values()) expect(label.length).toBeLessThanOrEqual(28); + }); + test("gives a family only to concepts that share their doc folder", () => { + const { data, docs } = topics([ + topic("docs/architecture/dispatch.md", "Dispatch", "d"), topic("docs/architecture/storage.md", "Storage engine", "s"), topic("docs/policy/threat.md", "Threat model", "t"), + topic("docs/specs/amd/bindings.md", "AMD bindings", "ab"), topic("docs/specs/amd/README.md", "AMD guide", "ag"), topic("README.md", "Root", "r"), topic("DESIGN.md", "Design", "e"), + ]); + const result = docConcepts(data, docs), family = source => conceptOf(result, source).family; + expect(family("docs/architecture/dispatch.md")).toEqual({ key: "family:docs/architecture", label: "Architecture" }); expect(family("docs/architecture/storage.md")).toBe(family("docs/architecture/dispatch.md")); + expect(family("docs/specs/amd/README.md")).toEqual({ key: "family:docs/specs/amd", label: "AMD" }); + for (const source of ["docs/policy/threat.md", "README.md", "DESIGN.md"]) expect("family" in conceptOf(result, source)).toBe(false); + }); + test("builds L2-normalized term vectors from the title and the first 24 headings, capped at 48 terms", () => { + const word = index => `w${String.fromCharCode(97 + Math.floor(index / 26))}${String.fromCharCode(97 + index % 26)}`; + const headings = Array.from({ length: 30 }, (_, index) => `## ${word(2 * index)} ${word(2 * index + 1)}`).join("\n"); + const text = `# Vector store\n\n${headings}\n${spans(["v/a.ts", "v/b.ts", "v/c.ts"])}\n`; + const terms = conceptOf(docConcepts(graph(["docs/vector.md", "v/a.ts", "v/b.ts", "v/c.ts"]), [{ path: "docs/vector.md", text }]), "docs/vector.md").terms; + expect([...terms.values()].reduce((sum, weight) => sum + weight * weight, 0)).toBeCloseTo(1, 10); expect(terms.size).toBe(48); + expect(terms.get("vector")).toBe(terms.get("store")); expect(terms.has(word(48))).toBe(false); + expect(Math.max(...[...terms].filter(([term]) => term !== "vector" && term !== "store").map(([, weight]) => weight))).toBeLessThan(terms.get("vector")); + }); +}); + +describe("doc concept contract", () => { + test("is independent of node, edge and doc order and of ordinal node IDs", () => { + const { data, docs } = topics([topic("docs/architecture/a.md", "Alpha engine", "a"), topic("docs/architecture/b.md", "Beta engine", "b"), topic("docs/c.md", "Gamma", "c")]); + data.nodes.push(file("a/extra.ts"), file("link/p.ts"), directory("a")); data.edges.push(reference("link/p.ts", "b/f0.ts"), reference("link/p.ts", "b/f1.ts")); + const reordered = { nodes: [...data.nodes].reverse(), edges: [...data.edges].reverse() }; + expect(snapshot(docConcepts(reordered, [...docs].reverse()))).toEqual(snapshot(docConcepts(data, docs))); + const ordinal = new Map(data.nodes.map((node, index) => [node.id, String(index)])), renumbered = { nodes: data.nodes.map(node => ({ ...node, id: ordinal.get(node.id) })), edges: data.edges.map(edge => ({ ...edge, from: ordinal.get(edge.from), to: ordinal.get(edge.to) })) }; + const original = docConcepts(data, docs), shifted = docConcepts(renumbered, docs); + expect(shifted.concepts.map(concept => [concept.key, concept.label, concept.members.length])).toEqual(original.concepts.map(concept => [concept.key, concept.label, concept.members.length])); + expect([...shifted.assignments].map(([id, item]) => [data.nodes[Number(id)].id, item]).sort()).toEqual([...original.assignments].sort()); + }); + test("sorts concepts by member count, then key, and ignores docs that are not catalog files", () => { + const { data, docs } = topics([topic("docs/z.md", "Zeta", "z"), topic("docs/a.md", "Alpha", "a"), topic("docs/m.md", "Mu", "m")]); + data.nodes.push(file("m/f3.ts")); docs[2].text += "`m/f3.ts`\n"; + const result = docConcepts(data, [...docs, { path: "docs/missing.md", text: docs[0].text }, { path: "../escape.md", text: docs[0].text }]); + expect(result.concepts.map(concept => concept.key)).toEqual(["doc:docs/m.md", "doc:docs/a.md", "doc:docs/z.md"]); expect(result.stats.docs).toBe(3); + }); + test("recognizes Markdown paths and exposes frozen source bounds", () => { + expect(["a/B.MD", "x.markdown", "page.mdx"].map(isDocPath)).toEqual([true, true, true]); expect(["a.md.txt", "md", undefined].map(isDocPath)).toEqual([false, false, false]); + expect(DOC_SOURCE_LIMITS).toEqual({ files: 256, fileBytes: 1 << 20, totalBytes: 16 << 20 }); expect(Object.isFrozen(DOC_SOURCE_LIMITS)).toBe(true); + }); + test("binds and clears a concept family on an object", () => { + const node = {}, family = { key: "family:docs/architecture", label: "Architecture" }; + expect(conceptFamily(node)).toBeUndefined(); bindConceptFamily(node, family); expect(conceptFamily(node)).toBe(family); expect(conceptFamily({})).toBeUndefined(); + bindConceptFamily(node, undefined); expect(conceptFamily(node)).toBeUndefined(); + }); +}); diff --git a/plugins/semctx/adapters/omp/graph-data/embedding.d.ts b/plugins/semctx/adapters/omp/graph-data/embedding.d.ts new file mode 100644 index 0000000..34ef3ee --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-data/embedding.d.ts @@ -0,0 +1,23 @@ +import type { GraphData } from "../graph"; +/** Lexical identifier weights for drawing placement, not the server's embeddings. L2-normalized; at most 48 terms; order is weight desc, then term asc. */ +export type TermVector = ReadonlyMap; +export interface SemanticNeighbour { a: string; b: string; similarity: number } +export interface SemanticFamily { key: string; label: string; terms: string[]; members: string[]; cohesion: number } +export function identifierTerms(text: string): string[]; +/** Aggregate nodes (overview groups) carry a precomputed centroid. `graphEmbedding` uses it as-is. */ +export function bindTermVector(node: object, vector: TermVector): void; +/** `informative`: at least one term is shared by two nodes and not every non-empty vector is the same. */ +export function graphEmbedding(data: Pick): { vectors: Map; informative: boolean }; +export function cosine(a?: TermVector, b?: TermVector): number; +export function centroid(vectors: Iterable): TermVector; +/** + * Classical MDS on a symmetric similarity, read once per unordered ID pair and once per ID with itself. + * The squared map distance follows S(a, a) + S(b, b) − 2·S(a, b); the RMS radius is 1. + * S need not be positive semidefinite: the axes follow the two largest eigenvalues, not the largest magnitudes. + * Undefined for fewer than 2 or more than 512 IDs, no pair with positive similarity, or no spread. Otherwise one point per unique ID. + */ +export function similarityMap(ids: readonly string[], similarity: (a: string, b: string) => number): Map | undefined; +/** `similarityMap` with cosine similarity as S, so the map distance is the chord distance between the vectors. */ +export function semanticMap(ids: readonly string[], vectors: ReadonlyMap): Map | undefined; +export function semanticNeighbours(ids: readonly string[], vectors: ReadonlyMap, options?: { k?: number; minimum?: number }): SemanticNeighbour[]; +export function semanticFamilies(ids: readonly string[], vectors: ReadonlyMap, options?: { limit?: number; threshold?: number }): SemanticFamily[]; diff --git a/plugins/semctx/adapters/omp/graph-data/embedding.js b/plugins/semctx/adapters/omp/graph-data/embedding.js new file mode 100644 index 0000000..36dc823 --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-data/embedding.js @@ -0,0 +1,168 @@ +// Deterministic lexical identifier vectors for drawing placement only. Terms come from supplied paths, labels and resolved symbol names. +// These vectors are not the server's semantic embeddings. Near vectors share identifier words; they do not prove related behavior. +import { normalizeConceptPath } from "./palette.js"; +const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0; +const capacity = 48, ignored = new Set(["src", "lib", "mod", "index", "main", "rs", "ts", "tsx", "js", "jsx", "mjs", "cjs", "go", "cs", "cpp", "cc", "h", "hpp", "py", "d", "the", "and", "of", "to", "for"]); +const splits = new Map(), bound = new WeakMap(), embeddings = new WeakMap(); +function split(text) { + let terms = splits.get(text); if (terms) return terms; + const unique = new Set(); + // Letter runs only: digits and punctuation are boundaries, so pure digit tokens never become terms. + for (const word of text.replace(/(\p{Ll})(\p{Lu})/gu, "$1 $2").replace(/(\p{Lu}+)(\p{Lu}\p{Ll})/gu, "$1 $2").toLowerCase().split(/[^\p{L}]+/u)) { + const term = word.length > 4 && word.endsWith("s") && !word.endsWith("ss") ? word.slice(0, -1) : word; + if (term.length > 1 && !ignored.has(term)) unique.add(term); + } + if (splits.size >= 4096) splits.delete(splits.keys().next().value); + terms = Object.freeze([...unique]); splits.set(text, terms); return terms; +} +export const identifierTerms = text => typeof text === "string" ? [...split(text)] : []; +export function bindTermVector(node, vector) { if (vector instanceof Map) bound.set(node, vector); } +function normalized(weights) { + const entries = [...weights].filter(([, weight]) => weight > 0).sort((a, b) => b[1] - a[1] || compare(a[0], b[0])).slice(0, capacity); + const norm = Math.sqrt(entries.reduce((sum, [, weight]) => sum + weight * weight, 0)); + return new Map(entries.map(([term, weight]) => [term, weight / norm])); +} +function add(terms, text, weight) { if (typeof text === "string") for (const term of split(text)) terms.set(term, (terms.get(term) ?? 0) + weight); } +function sources(node) { + const terms = new Map(), path = normalizeConceptPath(node.hit?.path ?? node.path), parts = path?.split("/") ?? [], last = parts.pop(); + if (node.category === "directory" || node.category === "project" || node.category === "group") { add(terms, last, 1); for (const part of parts) add(terms, part, .3); } + else if (last) { + const cut = last.lastIndexOf("."); add(terms, cut > 0 ? last.slice(0, cut) : last, 1); + parts.reverse().slice(0, 3).forEach((part, index) => add(terms, part, index ? .2 : .45)); + } + add(terms, node.hit?.symbol, 1); add(terms, node.hit?.qualifiedName, .4); + if (!path && !node.hit) add(terms, node.label, .8); + return terms; +} +const structural = edge => edge.kind !== "association" && edge.label !== "within project root" && edge.from !== edge.to; +// One step from pre-diffusion vectors. Bound aggregates are absent from `own`, so they neither receive nor give terms. +function diffuse(own, edges) { + const neighbours = new Map(), link = (a, b) => { if (!neighbours.has(a)) neighbours.set(a, new Set()); neighbours.get(a).add(b); }; + for (const edge of edges) if (structural(edge) && own.has(edge.from) && own.has(edge.to)) { link(edge.from, edge.to); link(edge.to, edge.from); } + return new Map([...own].map(([id, vector]) => { + const around = neighbours.get(id); if (!around) return [id, vector]; + const sum = new Map(vector), share = .35 / around.size; + for (const other of around) for (const [term, weight] of own.get(other)) sum.set(term, (sum.get(term) ?? 0) + share * weight); + return [id, normalized(sum)]; + })); +} +// Informative vectors share at least one term and are not all the same vector. +function discriminates(vectors) { + const seen = new Set(); let first, shared = false, distinct = false; + for (const vector of vectors) { + if (!vector?.size) continue; + if (!first) first = vector; else if (!distinct && cosine(first, vector) < 1 - 1e-9) distinct = true; + if (!shared) for (const [term, weight] of vector) if (weight > 0) { if (seen.has(term)) { shared = true; break; } seen.add(term); } + if (shared && distinct) return true; + } + return false; +} +export function graphEmbedding(data) { + const cached = embeddings.get(data); if (cached) return cached; + const nodes = [...data.nodes].sort((a, b) => compare(a.id, b.id)), terms = new Map(), frequency = new Map(); + for (const node of nodes) if (!bound.has(node)) terms.set(node.id, sources(node)); + const edges = [...data.edges].sort((a, b) => compare(a.from, b.from) || compare(a.to, b.to) || compare(a.kind ?? "", b.kind ?? "") || compare(a.symbol ?? "", b.symbol ?? "")); + for (const edge of edges) if (edge.kind === "reference" && typeof edge.symbol === "string") { if (terms.has(edge.to)) add(terms.get(edge.to), edge.symbol, .9); if (terms.has(edge.from)) add(terms.get(edge.from), edge.symbol, .5); } + for (const weights of terms.values()) for (const term of weights.keys()) frequency.set(term, (frequency.get(term) ?? 0) + 1); + const idf = term => 1 + Math.log((1 + terms.size) / (1 + frequency.get(term))); + const own = new Map([...terms].map(([id, weights]) => [id, normalized([...weights].map(([term, weight]) => [term, Math.log1p(weight) * idf(term)]))])); + const diffused = nodes.length <= 4096 ? diffuse(own, edges) : own, vectors = new Map(nodes.map(node => [node.id, bound.get(node) ?? diffused.get(node.id)])); + const result = { vectors, informative: discriminates(vectors.values()) }; + embeddings.set(data, result); return result; +} +export function cosine(a, b) { + if (!a?.size || !b?.size) return 0; + const small = a.size <= b.size ? a : b, large = small === a ? b : a; let sum = 0; + for (const [term, weight] of small) { const other = large.get(term); if (other !== undefined) sum += weight * other; } + return Math.max(-1, Math.min(1, sum)); +} +export function centroid(vectors) { + const sum = new Map(); + for (const vector of vectors) if (vector) for (const [term, weight] of vector) sum.set(term, (sum.get(term) ?? 0) + weight); + return normalized(sum); +} +const unique = ids => [...new Set(ids)].sort(compare); +function similarities(ids, vectors) { + const list = ids.map(id => vectors.get(id)), matrix = ids.map(() => new Float64Array(ids.length)); + for (let i = 0; i < ids.length; i++) for (let j = i; j < ids.length; j++) matrix[i][j] = matrix[j][i] = cosine(list[i], list[j]); + return matrix; +} +const dot = (a, b) => { let sum = 0; for (let i = 0; i < a.length; i++) sum += a[i] * b[i]; return sum; }; +const multiply = (matrix, vector) => Float64Array.from(matrix, row => dot(row, vector)); +const unit = vector => { const norm = Math.sqrt(dot(vector, vector)); return vector.map(value => norm > 1e-12 ? value / norm : 0); }; +function orthonormal([first, second]) { const a = unit(first), projection = dot(second, a); return [a, unit(second.map((value, i) => value - projection * a[i]))]; } +// Eigenvector signs are arbitrary. Later sorted IDs take the positive side; the largest coordinate decides a symmetric axis. +function orientation(values) { + const middle = (values.length - 1) / 2; let sum = 0, extreme = 0; + values.forEach((value, i) => { sum += value * (i - middle); if (Math.abs(value) > Math.abs(extreme)) extreme = value; }); + return Math.abs(sum) > 1e-12 ? Math.sign(sum) : Math.sign(extreme) || 1; +} +// Subspace iteration finds the two eigenvalues of largest magnitude. A shift by the most negative one keeps only the largest values. +// J·S·J maps every vector to a centred one. The shifted run stays centred, because the shift would also lift the constant vector. +// The shift moves the eigenvalue ratios nearer to 1, so the shifted run takes more steps. +function principalAxes(centred, shift) { + const n = centred.length; + const centre = vector => { const mean = vector.reduce((sum, value) => sum + value, 0) / n; return vector.map(value => value - mean); }; + const apply = shift ? axis => centre(multiply(centred, axis).map((value, i) => value + shift * axis[i])) : axis => multiply(centred, axis); + let axes = orthonormal([Math.cos, Math.sin].map(wave => Float64Array.from({ length: n }, (_, i) => wave(i * 2.399963))).map(axis => shift ? centre(axis) : axis)); + for (let step = 0; step < (shift ? 240 : 80); step++) axes = orthonormal(axes.map(apply)); + return axes; +} +/** Classical MDS: the eigenvectors of J·S·J for the two largest eigenvalues, so S need not be positive semidefinite. The squared map distance follows S(a, a) + S(b, b) − 2·S(a, b). RMS radius 1 around the origin. */ +export function similarityMap(ids, similarity) { + const sorted = unique(ids), n = sorted.length; + if (n < 2 || n > 512) return undefined; + const gram = sorted.map(() => new Float64Array(n)); + for (let i = 0; i < n; i++) for (let j = i; j < n; j++) gram[i][j] = gram[j][i] = similarity(sorted[i], sorted[j]); + if (!gram.some((row, i) => row.some((value, j) => j !== i && value > 0))) return undefined; + if (n === 2) return (gram[0][0] + gram[1][1] - 2 * gram[0][1]) / 2 < 1e-6 ? undefined : new Map([[sorted[0], [-1, 0]], [sorted[1], [1, 0]]]); + const means = gram.map(row => row.reduce((sum, value) => sum + value, 0) / n), mean = means.reduce((sum, value) => sum + value, 0) / n; + const centred = gram.map((row, i) => row.map((value, j) => value - means[i] - means[j] + mean)); + let axes = principalAxes(centred, 0), values = axes.map(axis => dot(axis, multiply(centred, axis))); + if (Math.min(...values) < -1e-9) { axes = principalAxes(centred, -Math.min(...values)); values = axes.map(axis => dot(axis, multiply(centred, axis))); } + values = values.map(value => Math.max(0, value)); + if (values[0] < 1e-6) return undefined; + const columns = axes.map((axis, index) => { + const scaled = axis.map(value => value * Math.sqrt(values[index])), centre = scaled.reduce((sum, value) => sum + value, 0) / n, shifted = scaled.map(value => value - centre), sign = orientation(shifted); + return shifted.map(value => value * sign); + }); + const radius = Math.sqrt(columns.reduce((sum, column) => sum + dot(column, column), 0) / n); + return new Map(sorted.map((id, i) => [id, [columns[0][i] / radius, columns[1][i] / radius]])); +} +/** Classical MDS on chord distance, with the cosine matrix as S. */ +export function semanticMap(ids, vectors) { + return similarityMap(ids, (a, b) => cosine(vectors.get(a), vectors.get(b))); +} +export function semanticNeighbours(ids, vectors, { k = 3, minimum = .2 } = {}) { + const sorted = unique(ids), n = sorted.length; if (n > 512) return []; + const matrix = similarities(sorted, vectors), pairs = new Set(); + for (let i = 0; i < n; i++) { + const candidates = []; + for (let j = 0; j < n; j++) if (j !== i && matrix[i][j] > 0 && matrix[i][j] >= minimum) candidates.push(j); + for (const j of candidates.sort((a, b) => matrix[i][b] - matrix[i][a] || a - b).slice(0, Math.max(0, k))) pairs.add(Math.min(i, j) * n + Math.max(i, j)); + } + return [...pairs].sort((a, b) => a - b).map(pair => { const a = Math.floor(pair / n), b = pair % n; return { a: sorted[a], b: sorted[b], similarity: matrix[a][b] }; }); +} +/** Average-linkage clusters on cosine. A slot index is always the smallest member index, so ties merge in sorted ID order. */ +export function semanticFamilies(ids, vectors, { limit = 6, threshold = .22 } = {}) { + const supplied = unique(ids); if (supplied.length > 128) return []; + const sorted = supplied.filter(id => vectors.get(id)?.size), matrix = similarities(sorted, vectors), linkage = matrix.map(row => Float64Array.from(row)), clusters = sorted.map((_, i) => [i]); + for (;;) { + let best = -Infinity, left = -1, right = -1; + for (let i = 0; i < clusters.length; i++) if (clusters[i]) for (let j = i + 1; j < clusters.length; j++) if (clusters[j] && linkage[i][j] > best) { best = linkage[i][j]; left = i; right = j; } + if (left < 0 || best < threshold) break; + const a = clusters[left].length, b = clusters[right].length; + for (let other = 0; other < clusters.length; other++) if (clusters[other] && other !== left && other !== right) linkage[left][other] = linkage[other][left] = (a * linkage[left][other] + b * linkage[right][other]) / (a + b); + clusters[left] = [...clusters[left], ...clusters[right]].sort((x, y) => x - y); clusters[right] = null; + } + const families = clusters.filter(members => members?.length > 1).map(members => { + const list = members.map(i => vectors.get(sorted[i])), share = term => list.filter(vector => vector.get(term) > 0).length / list.length; + const terms = [...centroid(list)].map(([term, weight]) => [term, weight * share(term)]).sort((a, b) => b[1] - a[1] || compare(a[0], b[0])).slice(0, 2).map(([term]) => term); + let total = 0, pairs = 0; + for (let x = 0; x < members.length; x++) for (let y = x + 1; y < members.length; y++) { total += matrix[members[x]][members[y]]; pairs++; } + return { key: `family:${terms.join("+")}`, label: terms.join(" · "), terms, members: members.map(i => sorted[i]), cohesion: total / pairs }; + }).sort((a, b) => b.members.length - a.members.length || compare(a.key, b.key) || compare(a.members[0], b.members[0])); + const repeats = new Map(); + for (const family of families) { const count = (repeats.get(family.key) ?? 0) + 1; repeats.set(family.key, count); if (count > 1) family.key += `#${count}`; } + return families.slice(0, Math.max(0, limit)); +} diff --git a/plugins/semctx/adapters/omp/graph-data/embedding.test.js b/plugins/semctx/adapters/omp/graph-data/embedding.test.js new file mode 100644 index 0000000..34e6148 --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-data/embedding.test.js @@ -0,0 +1,140 @@ +import { describe, expect, test } from "bun:test"; +import { bindTermVector, centroid, cosine, graphEmbedding, identifierTerms, semanticFamilies, semanticMap, semanticNeighbours, similarityMap } from "./embedding.js"; +const file = (id, path, symbol = null) => ({ id, label: id, detail: null, path, category: "file", project: "", hit: symbol ? { path, symbol, qualifiedName: null } : null }); +const planted = () => ({ nodes: [file("a1", "graph/query_parser.rs", "QueryParser"), file("a2", "graph/query_planner.rs", "QueryPlanner"), file("a3", "graph/query_cache.rs", "QueryCache"), + file("b1", "store/vector_writer.rs", "VectorWriter"), file("b2", "store/vector_reader.rs", "VectorReader"), file("b3", "store/vector_loader.rs", "VectorLoader")], edges: [], roots: [] }); +function structured() { + const data = planted(), directory = (id, path) => ({ id, label: path, detail: null, category: "directory", path, hit: null }); + data.nodes.push(directory("dir:graph", "graph"), directory("dir:store", "store"), { id: "project", label: "Workspace", detail: null, category: "project", hit: null }); + for (const node of data.nodes.filter(node => node.category === "file")) data.edges.push({ from: `dir:${node.path.split("/")[0]}`, to: node.id, label: "contains", kind: "containment" }, { from: "project", to: node.id, label: "within project root", kind: "association" }); + data.edges.push({ from: "a1", to: "b1", label: "references", kind: "reference", symbol: "VectorWriter", moniker: "store::VectorWriter" }, { from: "a1", to: "a2", label: "imports", kind: "import" }); + return data; +} +function snapshot(data, reverse = false) { + const { vectors, informative } = graphEmbedding(data), ids = [...vectors.keys()]; + if (reverse) ids.reverse(); + return { informative, vectors: [...vectors].map(([id, vector]) => [id, [...vector]]), map: [...semanticMap(ids, vectors)], families: semanticFamilies(ids, vectors), neighbours: semanticNeighbours(ids, vectors) }; +} + +describe("lexical identifier vectors", () => { + test("splits identifiers on case, acronym, separator and digit boundaries", () => { + expect(identifierTerms("GraphQueryBuilder")).toEqual(["graph", "query", "builder"]); expect(identifierTerms("HTTPServer")).toEqual(["http", "server"]); + expect(identifierTerms("parseXMLHttpRequest")).toEqual(["parse", "xml", "http", "request"]); + expect(identifierTerms("vector_store-cache.loader/path::Codec")).toEqual(["vector", "store", "cache", "loader", "path", "codec"]); + expect(identifierTerms("utf8Decoder2024 v2")).toEqual(["utf", "decoder"]); expect(identifierTerms("src/lib/mod.rs index.tsx main.go the_x_of_y")).toEqual([]); + expect(identifierTerms("GraphNodes class tests")).toEqual(["graph", "node", "class", "test"]); expect(identifierTerms("graph_graph Graph")).toEqual(["graph"]); + const terms = identifierTerms("GraphQuery"); terms.push("mutated"); expect(identifierTerms("GraphQuery")).toEqual(["graph", "query"]); + }); + test("files in one directory with related symbols are nearer than unrelated files", () => { + const data = { nodes: [file("parser", "query/parser.rs", "QueryParser"), file("reader", "query/reader.rs", "QueryReader"), file("lexer", "query/lexer.rs", "TokenLexer"), file("vector", "storage/vector.rs", "VectorCodec")], edges: [], roots: [] }; + const { vectors, informative } = graphEmbedding(data), similarity = (a, b) => cosine(vectors.get(a), vectors.get(b)); + expect(informative).toBe(true); expect(similarity("parser", "reader")).toBeGreaterThan(similarity("parser", "lexer")); + expect(similarity("parser", "lexer")).toBeGreaterThan(0); expect(similarity("parser", "vector")).toBe(0); + }); + test("resolved references raise definer and user similarity; the definer carries the symbol more strongly", () => { + const nodes = [file("x", "alpha/one.rs"), file("y", "beta/two.rs"), file("z", "gamma/three.rs")], plain = graphEmbedding({ nodes, edges: [] }); + const linked = graphEmbedding({ nodes, edges: [{ from: "x", to: "y", label: "references", kind: "reference", symbol: "TokenStream", moniker: "beta::TokenStream" }] }); + expect(cosine(plain.vectors.get("x"), plain.vectors.get("y"))).toBe(0); expect(plain.informative).toBe(false); + expect(cosine(linked.vectors.get("x"), linked.vectors.get("y"))).toBeGreaterThan(.3); expect(linked.informative).toBe(true); + expect(linked.vectors.get("y").get("token")).toBeGreaterThan(linked.vectors.get("x").get("token")); + expect(cosine(linked.vectors.get("x"), linked.vectors.get("z"))).toBe(0); + }); + test("diffuses one step over structural edges but not over project associations", () => { + const data = planted(), blank = id => ({ id, label: "", detail: null, hit: null }); + data.nodes.push(blank("empty"), blank("lonely")); + data.edges.push({ from: "empty", to: "a1", label: "imports", kind: "import" }, { from: "lonely", to: "a1", label: "project", kind: "association" }, { from: "lonely", to: "a2", label: "within project root" }); + const { vectors } = graphEmbedding(data); + expect(cosine(vectors.get("empty"), vectors.get("a1"))).toBeGreaterThan(.99); expect(vectors.get("lonely").size).toBe(0); + }); + test("vectors are normalized, capped and ordered by weight, then term", () => { + const { vectors } = graphEmbedding(structured()); + for (const vector of vectors.values()) { + const entries = [...vector]; expect(entries.length).toBeLessThanOrEqual(48); + if (entries.length) expect(Math.hypot(...vector.values())).toBeCloseTo(1, 10); + for (let index = 1; index < entries.length; index++) expect(entries[index - 1][1] > entries[index][1] || (entries[index - 1][1] === entries[index][1] && entries[index - 1][0] < entries[index][0])).toBe(true); + } + }); + test("is independent of node, edge and ID order", () => { + const data = structured(), reordered = { ...data, nodes: [...data.nodes].reverse().map(node => ({ ...node })), edges: [...data.edges].reverse().map(edge => ({ ...edge })) }; + const before = snapshot(data); expect(before.informative).toBe(true); expect(snapshot(reordered, true)).toEqual(before); + }); + test("uses bound aggregate vectors verbatim and keeps them out of diffusion", () => { + const vector = new Map([["custom", 1]]), group = { id: "group", label: "Query group", detail: null, category: "group", path: "graph", hit: null }; + bindTermVector(group, vector); + const data = { nodes: [group, file("a1", "graph/query_parser.rs", "QueryParser")], edges: [{ from: "group", to: "a1", label: "contains", kind: "containment" }], roots: [] }, result = graphEmbedding(data); + expect(result.vectors.get("group")).toBe(vector); expect(result.vectors.get("a1").has("custom")).toBe(false); expect(graphEmbedding(data)).toBe(result); + const copy = graphEmbedding({ ...data, nodes: [{ ...group }, data.nodes[1]] }); + expect(copy.vectors.get("group").has("custom")).toBe(false); expect(copy.vectors.get("group").has("graph")).toBe(true); + }); + test("reports vectors without shared or distinct terms as uninformative", () => { + const numbered = Array.from({ length: 3 }, (_, index) => ({ id: `n${index}`, label: `Node ${index}`, detail: null, hit: null })); + expect(graphEmbedding({ nodes: numbered, edges: [] }).informative).toBe(false); + expect(graphEmbedding({ nodes: numbered.map(node => ({ ...node, category: "file", path: `src/${node.id}.ts` })), edges: [] }).informative).toBe(false); + expect(graphEmbedding({ nodes: [file("only", "query/parser.rs")], edges: [] }).informative).toBe(false); + }); + test("cosine and centroid treat missing vectors as empty", () => { + const a = new Map([["graph", .6], ["query", .8]]), b = new Map([["query", 1]]); + expect(cosine(a, undefined)).toBe(0); expect(cosine(new Map(), b)).toBe(0); expect(cosine(a, b)).toBeCloseTo(.8, 12); expect(cosine(b, a)).toBeCloseTo(.8, 12); + expect(centroid([undefined, new Map()]).size).toBe(0); + const mean = centroid([a, undefined, b]); expect([...mean.keys()]).toEqual(["query", "graph"]); expect(Math.hypot(...mean.values())).toBeCloseTo(1, 12); + }); +}); + +describe("semantic placement", () => { + test("semantic map separates two lexical groups along one axis with a deterministic sign", () => { + const { vectors } = graphEmbedding(planted()), ids = [...vectors.keys()], map = semanticMap(ids, vectors); + expect(map.size).toBe(6); + const group = prefix => ids.filter(id => id.startsWith(prefix)).map(id => map.get(id)), mean = points => points.reduce(([x, y], point) => [x + point[0] / points.length, y + point[1] / points.length], [0, 0]); + const a = group("a"), b = group("b"), ma = mean(a), mb = mean(b), spread = [...a.map(point => Math.hypot(point[0] - ma[0], point[1] - ma[1])), ...b.map(point => Math.hypot(point[0] - mb[0], point[1] - mb[1]))].reduce((sum, value) => sum + value, 0) / 6; + expect(Math.hypot(ma[0] - mb[0], ma[1] - mb[1])).toBeGreaterThan(2 * spread); + expect(a.every(([x]) => x < 0)).toBe(true); expect(b.every(([x]) => x > 0)).toBe(true); + expect(Math.sqrt([...map.values()].reduce((sum, [x, y]) => sum + x * x + y * y, 0) / map.size)).toBeCloseTo(1, 6); + }); + test("semantic map rejects uninformative input and places two points on the x axis", () => { + const graph = new Map([["graph", 1]]), vectors = new Map([["a", graph], ["b", graph], ["c", graph], ["d", new Map()], ["e", new Map([["store", 1]])]]); + expect(semanticMap(["a"], vectors)).toBeUndefined(); expect(semanticMap(["a", "b", "c"], vectors)).toBeUndefined(); expect(semanticMap(["d", "e", "missing"], vectors)).toBeUndefined(); + const pair = new Map([["a", new Map([["graph", .8], ["query", .6]])], ["b", new Map([["graph", .6], ["store", .8]])]]); + expect([...semanticMap(["b", "a"], pair)]).toEqual([["a", [-1, 0]], ["b", [1, 0]]]); + }); + test("semantic map is the similarity map of cosine similarity", () => { + const { vectors } = graphEmbedding(structured()), ids = [...vectors.keys()].reverse(); + expect(semanticMap(ids, vectors)).toEqual(similarityMap(ids, (a, b) => cosine(vectors.get(a), vectors.get(b)))); + }); + test("similarity map keeps the two largest eigenvalues of an indefinite similarity", () => { + // A planar Gram matrix (eigenvalues 10 and 6) minus a stronger negative direction: the largest magnitudes would be the wrong axes. + const points = { a: [-2, 0], b: [-1, 1], c: [0, 0], d: [1, 1], e: [2, 0], f: [0, -2] }, negative = { a: 1, b: 0, c: -2, d: 0, e: 1, f: 0 }, ids = Object.keys(points); + for (const strength of [0, 20, 60]) { + const similarity = (p, q) => points[p][0] * points[q][0] + points[p][1] * points[q][1] - strength * negative[p] * negative[q] / 6; + const map = similarityMap([...ids].reverse(), similarity); + expect([...map.keys()]).toEqual(ids); + // Map distances are the planar distances at one scale: RMS radius 1 instead of √(16 / 6). + for (const p of ids) for (const q of ids) if (p < q) { + const distance = Math.hypot(map.get(p)[0] - map.get(q)[0], map.get(p)[1] - map.get(q)[1]); + expect(distance / Math.hypot(points[p][0] - points[q][0], points[p][1] - points[q][1])).toBeCloseTo(Math.sqrt(6 / 16), 5); + } + expect(similarityMap(ids, similarity)).toEqual(map); + } + expect(similarityMap(["a", "b", "c"], () => 1)).toBeUndefined(); expect(similarityMap(["a", "b", "c"], (p, q) => p === q ? 1 : 0)).toBeUndefined(); + }); + test("semantic families recover planted groups with labels from shared terms", () => { + const { vectors } = graphEmbedding(planted()), ids = [...vectors.keys()], families = semanticFamilies(ids, vectors); + expect(families.map(({ key, label, terms, members }) => ({ key, label, terms, members }))).toEqual([ + { key: "family:query+graph", label: "query · graph", terms: ["query", "graph"], members: ["a1", "a2", "a3"] }, + { key: "family:vector+store", label: "vector · store", terms: ["vector", "store"], members: ["b1", "b2", "b3"] }]); + for (const family of families) expect(family.cohesion).toBeGreaterThanOrEqual(.22); + expect(semanticFamilies(ids, vectors, { threshold: .5 })).toEqual([]); expect(semanticFamilies(ids, vectors, { limit: 1 })).toHaveLength(1); + }); + test("semantic families keep distinct keys for repeated term labels", () => { + const rest = Math.sqrt(1 - 2 * .45 ** 2 - .4 ** 2), member = (extra, own) => new Map([["graph", .45], ["query", .45], [extra, .4], [own, rest]]); + const vectors = new Map([["x1", member("left", "x1")], ["x2", member("left", "x2")], ["y1", member("right", "y1")], ["y2", member("right", "y2")]]); + expect(semanticFamilies([...vectors.keys()].reverse(), vectors, { threshold: .5 }).map(family => [family.key, family.label, family.members])) + .toEqual([["family:graph+query", "graph · query", ["x1", "x2"]], ["family:graph+query#2", "graph · query", ["y1", "y2"]]]); + }); + test("semantic neighbours are symmetric-deduplicated and canonical", () => { + const { vectors } = graphEmbedding(planted()), ids = [...vectors.keys()].reverse(), pairs = result => result.map(({ a, b }) => [a, b]); + expect(pairs(semanticNeighbours(ids, vectors))).toEqual([["a1", "a2"], ["a1", "a3"], ["a2", "a3"], ["b1", "b2"], ["b1", "b3"], ["b2", "b3"]]); + expect(pairs(semanticNeighbours(ids, vectors, { k: 1 }))).toEqual([["a1", "a2"], ["a1", "a3"], ["b1", "b2"], ["b1", "b3"]]); + expect(semanticNeighbours(ids, vectors, { minimum: .5 })).toEqual([]); + for (const { similarity } of semanticNeighbours(ids, vectors)) expect(similarity).toBeCloseTo(cosine(vectors.get("a1"), vectors.get("a2")), 12); + }); +}); diff --git a/plugins/semctx/adapters/omp/graph-data/layout.d.ts b/plugins/semctx/adapters/omp/graph-data/layout.d.ts new file mode 100644 index 0000000..58bf6b5 --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-data/layout.d.ts @@ -0,0 +1,43 @@ +import type { GraphData } from "../graph"; + +export interface GraphCategory { key: string; label: string; type: string } +export function category(node: GraphData["nodes"][number]): GraphCategory; +export interface SharedConceptFamily { key: string; label: string; members: string[] } +/** Bound doc concept families with at least two members; largest first, then key. Members are sorted unique IDs. */ +export function sharedConceptFamilies(nodes: readonly GraphData["nodes"][number][]): SharedConceptFamily[]; +export function exactActivity(node: GraphData["nodes"][number], targets: ReadonlySet, codebaseId: string | null): boolean; + +/** Aggregate size for drawing only, never wire data. */ +export function bindDrawingWeight(item: object, weight: number): void; +export function drawingWeight(item: object): number | undefined; + +export type GraphRelationKind = "containment" | "association" | "import" | "reference" | "generic"; +export interface GraphRelationStyle { readonly label: string; readonly dash: readonly number[]; } +export const relations: Readonly>; +export function relationKind(edge: GraphData["edges"][number]): GraphRelationKind; +export function relationStyle(edge: GraphData["edges"][number]): GraphRelationStyle; +export function representativeLinks(data: GraphData): Array; + +export interface GraphPosition { + node: GraphData["nodes"][number]; + x: number; + y: number; + radius: number; + category: string; + degree: number; +} +export interface GraphLayout { + positions: Map; + width: number; + height: number; + bounds: { x: number; y: number; width: number; height: number }; +} +export function layoutGraph(data: GraphData, availableWidth?: number): GraphLayout; +export function edgeGeometry(edges: GraphData["edges"], index: number, positions: Map): { + from: [number, number]; + to: [number, number]; + control: number[]; + self: boolean; + x: number; + y: number; +} | null; diff --git a/plugins/semctx/adapters/omp/graph-data/layout.js b/plugins/semctx/adapters/omp/graph-data/layout.js new file mode 100644 index 0000000..f073432 --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-data/layout.js @@ -0,0 +1,307 @@ +// Deterministic force geometry for native graph surfaces and settled previews. +import { conceptFamily } from "./doc-concepts.js"; +import { graphEmbedding, semanticMap, semanticNeighbours, semanticFamilies, centroid, cosine } from "./embedding.js"; + +// Aggregate size for drawing only, never wire data. +const drawingWeights = new WeakMap(); +export function bindDrawingWeight(item, weight) { if (Number.isFinite(weight) && weight > 0) drawingWeights.set(item, weight); } +export function drawingWeight(item) { return drawingWeights.get(item); } + +const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0; +const hash = text => { let value = 2166136261; for (const char of text) value = Math.imul(value ^ char.charCodeAt(0), 16777619); return value >>> 0; }; +const normalizePath = value => value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, ""); +export function category(node) { + if ((node.category === "file" || node.category === "directory") && typeof node.path === "string") { + const project = normalizePath(node.project ?? ""), path = normalizePath(node.path); + const relative = project && (path === project || path.startsWith(project + "/")) ? path.slice(project.length).replace(/^\//, "") : path; + const directory = node.category === "directory" ? relative : relative.slice(0, Math.max(0, relative.lastIndexOf("/"))); + const parts = directory.split("/").filter(Boolean), depth = parts[0] === "plugins" ? 3 : 2, branch = parts.slice(0, depth).join("/"); + return { key: `module:${JSON.stringify([project, branch])}`, label: branch || project || ".", type: "Directory" }; + } + const project = node.project ?? node.hit?.project, path = node.path ?? node.hit?.path; + if (project !== undefined && project !== null) return { key: `project:${project}`, label: project || ".", type: "Project" }; + if (path) { const normalized = path.replace(/\\/g, "/"), cut = normalized.lastIndexOf("/"), directory = cut < 0 ? "." : normalized.slice(0, cut) || "/"; return { key: `path:${directory}`, label: directory, type: "Directory" }; } + const kind = node.category ?? node.hit?.kind; + return kind ? { key: `kind:${kind}`, label: kind, type: "Kind" } : { key: "structure", label: "Graph structure", type: "" }; +} +/** Bound doc concept families with at least two members; largest first, then key. */ +export function sharedConceptFamilies(nodes) { + const families = new Map(); + for (const node of [...nodes].sort((a, b) => compare(a.id, b.id))) { + const family = conceptFamily(node); + if (!family) continue; + if (!families.has(family.key)) families.set(family.key, { key: family.key, label: family.label, members: [] }); + const members = families.get(family.key).members; + if (members.at(-1) !== node.id) members.push(node.id); + } + return [...families.values()].filter(family => family.members.length > 1).sort((a, b) => b.members.length - a.members.length || compare(a.key, b.key)); +} +// Relation identity is independent of group color. Only known legacy labels are promoted. +export const relations = Object.freeze({ + containment: { label: "Containment", dash: [] }, + association: { label: "Project association", dash: [1, 4] }, + import: { label: "Import", dash: [6, 4] }, + reference: { label: "Resolved reference", dash: [] }, + generic: { label: "Other relation", dash: [] }, +}); +export function relationKind(edge) { + if (edge.kind) return Object.hasOwn(relations, edge.kind) ? edge.kind : "generic"; + if (edge.label === "contains" || edge.label === "contains reported child") return "containment"; + if (edge.label === "within project root") return "association"; + return edge.label === "imports" ? "import" : "generic"; +} +export function relationStyle(edge) { return relations[relationKind(edge)]; } +/** One route per exact endpoint/direction/kind; every original edge remains inspectable. */ +export function representativeLinks(data) { + const pairs = new Map(); + data.edges.forEach((edge, index) => { + const kind = relationKind(edge), key = JSON.stringify([edge.from, edge.to, kind]); + if (!pairs.has(key)) pairs.set(key, { ...edge, kind, count: 0, indices: [] }); + const pair = pairs.get(key); pair.count++; pair.indices.push(index); + }); + return [...pairs.values()].sort((a, b) => compare(a.from, b.from) || compare(a.to, b.to) || compare(a.kind, b.kind)).map((pair, index) => ({ ...pair, index, symbol: pair.count === 1 ? pair.symbol : null, moniker: pair.count === 1 ? pair.moniker : null, label: pair.count === 1 ? pair.label : relations[pair.kind].label })); +} +function tree(points) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const point of points) { minX = Math.min(minX, point.x); minY = Math.min(minY, point.y); maxX = Math.max(maxX, point.x); maxY = Math.max(maxY, point.y); } + const size = Math.max(1, maxX - minX, maxY - minY) + 1; + const cell = (x, y, size) => ({ x, y, size, points: [], children: null, mass: 0, cx: 0, cy: 0 }); + const root = cell(Number.isFinite(minX) ? minX - .5 : 0, Number.isFinite(minY) ? minY - .5 : 0, size); + function insert(branch, point, depth) { + if (!branch.children && (branch.points.length < 4 || depth === 24)) { branch.points.push(point); return; } + if (!branch.children) { + const half = branch.size / 2; + branch.children = [cell(branch.x, branch.y, half), cell(branch.x + half, branch.y, half), cell(branch.x, branch.y + half, half), cell(branch.x + half, branch.y + half, half)]; + for (const old of branch.points) insert(branch.children[(old.x >= branch.x + half ? 1 : 0) + (old.y >= branch.y + half ? 2 : 0)], old, depth + 1); + branch.points = []; + } + const half = branch.size / 2; + insert(branch.children[(point.x >= branch.x + half ? 1 : 0) + (point.y >= branch.y + half ? 2 : 0)], point, depth + 1); + } + for (const point of points) insert(root, point, 0); + function accumulate(branch) { + if (branch.children) for (const child of branch.children) { accumulate(child); branch.mass += child.mass; branch.cx += child.cx * child.mass; branch.cy += child.cy * child.mass; } + else for (const point of branch.points) { branch.mass++; branch.cx += point.x; branch.cy += point.y; } + if (branch.mass) { branch.cx /= branch.mass; branch.cy /= branch.mass; } + } + accumulate(root); return root; +} +function groupAnchors(groups, data, categoryFor, vectors) { + const byId = new Map(data.nodes.map(node => [node.id, categoryFor(node).key])); + const neighbours = new Map(groups.map(group => [group.key, new Map()])); + for (const edge of data.edges) { + const from = byId.get(edge.from), to = byId.get(edge.to), kind = relationKind(edge); + // Redundant project membership must not overwhelm dependency or hierarchy evidence. + if (from === to || kind === "association" || !neighbours.has(from) || !neighbours.has(to)) continue; + const weight = kind === "reference" ? 4 : kind === "import" ? 2 : 1; + neighbours.get(from).set(to, (neighbours.get(from).get(to) ?? 0) + weight); + neighbours.get(to).set(from, (neighbours.get(to).get(from) ?? 0) + weight); + } + const degrees = new Map([...neighbours].map(([key, values]) => [key, [...values.values()].reduce((sum, weight) => sum + weight, 0)])); + const remaining = new Map(groups.map(group => [group.key, group])), order = [], frontier = new Map(); + while (remaining.size) { + let next; + for (const candidate of remaining.values()) { + if (!next || (frontier.get(candidate.key) ?? 0) > (frontier.get(next.key) ?? 0) || ((frontier.get(candidate.key) ?? 0) === (frontier.get(next.key) ?? 0) && (degrees.get(candidate.key) > degrees.get(next.key) || (degrees.get(candidate.key) === degrees.get(next.key) && compare(candidate.key, next.key) < 0)))) next = candidate; + } + remaining.delete(next.key); order.push(next); + for (const [key, weight] of neighbours.get(next.key)) frontier.set(key, (frontier.get(key) ?? 0) + weight); + } + const radius = group => Math.max(30, Math.sqrt(group.nodes.length) * 30); + const targetWidth = Math.sqrt(groups.reduce((area, group) => area + (radius(group) * 2 + 70) ** 2, 0)) * 1.3; + const groupVectors = vectors && groups.length <= 128 ? new Map(groups.map(group => [group.key, centroid(group.nodes.map(node => vectors.get(node.id)))])) : undefined; + const mapped = groups.length >= 3 && groupVectors && [...groupVectors.values()].filter(vector => vector.size).length >= 3 ? semanticMap(groups.map(group => group.key), groupVectors) : undefined; + const anchors = new Map(); + let meanX = 0, meanY = 0, spread = 0; + if (mapped) { + for (const group of groups) { const point = mapped.get(group.key); meanX += point[0] / groups.length; meanY += point[1] / groups.length; } + for (const group of groups) { const point = mapped.get(group.key); spread += (point[0] - meanX) ** 2 + (point[1] - meanY) ** 2; } + spread = Math.sqrt(spread / groups.length); + } + if (mapped && spread > 0) { + const scale = targetWidth / 2.6 / spread; + for (const group of groups) { const point = mapped.get(group.key); anchors.set(group.key, { x: (point[0] - meanX) * scale, y: (point[1] - meanY) * scale, radius: radius(group) }); } + } else { + let x = 0, y = 0, rowHeight = 0; + for (const group of order) { + const r = radius(group), width = r * 2 + 70; + if (x && x + width > targetWidth) { x = 0; y += rowHeight; rowHeight = 0; } + anchors.set(group.key, { x: x + r + 35, y: y + r + 35, radius: r }); + x += width; rowHeight = Math.max(rowHeight, width); + } + } + // Bounded coarse relaxation: dependencies and shared terms pull groups together, envelopes repel. + // Large category sets keep the topology-first packing rather than doing quadratic work. + if (groups.length <= 128) for (let pass = 0; pass < 64; pass++) { + const moves = new Map(groups.map(group => [group.key, { x: 0, y: 0 }])); + for (let a = 0; a < groups.length; a++) for (let b = a + 1; b < groups.length; b++) { + const left = groups[a].key, right = groups[b].key, p = anchors.get(left), q = anchors.get(right); + let dx = q.x - p.x, dy = q.y - p.y; + if (dx === 0 && dy === 0) { const angle = hash(JSON.stringify([left, right])) * .01; dx = Math.cos(angle); dy = Math.sin(angle); } + const distance = Math.hypot(dx, dy) || 1, wanted = p.radius + q.radius + 70; + const weight = neighbours.get(left).get(right) ?? 0; + const strength = weight / Math.max(1, Math.sqrt(degrees.get(left) * degrees.get(right))) + (groupVectors ? .9 * Math.max(0, cosine(groupVectors.get(left), groupVectors.get(right)) - .12) : 0); + const force = distance < wanted ? (distance - wanted) * .3 : (distance - wanted) * strength * .055; + const mx = dx / distance * force, my = dy / distance * force; + moves.get(left).x += mx; moves.get(left).y += my; moves.get(right).x -= mx; moves.get(right).y -= my; + } + for (const [key, move] of moves) { const anchor = anchors.get(key); anchor.x += Math.max(-12, Math.min(12, move.x)); anchor.y += Math.max(-12, Math.min(12, move.y)); } + } + let centerX = 0, centerY = 0, count = 0; + for (const group of groups) { const anchor = anchors.get(group.key); centerX += anchor.x * group.nodes.length; centerY += anchor.y * group.nodes.length; count += group.nodes.length; } + for (const anchor of anchors.values()) { anchor.x -= centerX / Math.max(1, count); anchor.y -= centerY / Math.max(1, count); } + return { anchors, order }; +} +export function createSimulation(data, previousPositions, categoryFor = category) { + const ordered = [...data.nodes].sort((a, b) => compare(a.id, b.id)), roots = new Set(data.roots), groups = new Map(); + // A shared doc concept family is the anchor of its members, so a family settles as one region. + const concepts = sharedConceptFamilies(ordered), familyOf = new Map(concepts.flatMap(family => family.members.map(id => [id, family]))); + const anchorFor = node => { const family = familyOf.get(node.id); return family ? { key: family.key, label: family.label, type: "Concept family" } : categoryFor(node); }; + for (const node of ordered) { const info = anchorFor(node); if (!groups.has(info.key)) groups.set(info.key, { ...info, nodes: [] }); groups.get(info.key).nodes.push(node); } + const sorted = [...groups.values()].sort((a, b) => compare(a.key, b.key)), positions = new Map(); + const modular = ordered.some(node => node.category && typeof node.path === "string"); + const embedding = ordered.length <= 256 ? graphEmbedding(data) : undefined, vectors = embedding?.informative ? embedding.vectors : undefined; + const ids = vectors ? ordered.map(node => node.id) : [], mapped = vectors ? semanticMap(ids, vectors) : undefined; + const { anchors, order: categories } = groupAnchors(sorted, data, anchorFor, vectors); + const maxWeight = ordered.reduce((maximum, node) => Math.max(maximum, drawingWeight(node) ?? 0), 0); + for (const group of categories) { + const anchor = anchors.get(group.key); + let meanX = 0, meanY = 0, extent = 0; + if (mapped) { + for (const node of group.nodes) { const point = mapped.get(node.id); meanX += point[0] / group.nodes.length; meanY += point[1] / group.nodes.length; } + for (const node of group.nodes) { const point = mapped.get(node.id); extent = Math.max(extent, Math.hypot(point[0] - meanX, point[1] - meanY)); } + } + const scale = extent ? (7 + Math.sqrt(group.nodes.length) * 13) / extent : 0; + group.nodes.forEach((node, at) => { + const phase = (hash(node.id) % 1000) / 1000 * .3, angle = at * 2.399963229728653 + phase, distance = 7 + Math.sqrt(at) * 13, old = previousPositions?.get(node.id), point = mapped?.get(node.id); + const x = point ? group.nodes.length === 1 ? anchor.x : anchor.x + (point[0] - meanX) * scale + Math.cos(angle) * .3 : anchor.x + Math.cos(angle) * distance; + const y = point ? group.nodes.length === 1 ? anchor.y : anchor.y + (point[1] - meanY) * scale + Math.sin(angle) * .3 : anchor.y + Math.sin(angle) * distance; + positions.set(node.id, { node, category: group.key, x: old?.x ?? x, y: old?.y ?? y, vx: 0, vy: 0, radius: roots.has(node.id) ? 4 : 2.2, degree: 0, forceDegree: 0 }); + }); + } + const links = data.edges.map((edge, index) => ({ ...edge, index, source: positions.get(edge.from), target: positions.get(edge.to) })); + for (const link of links) { + link.source.degree++; link.target.degree++; + if (!modular || (link.kind !== "association" && link.label !== "within project root")) { link.source.forceDegree++; link.target.forceDegree++; } + } + const points = [...positions.values()]; + for (const point of points) { + const weight = drawingWeight(point.node); + point.radius = weight === undefined ? Math.max(point.radius, Math.min(4.5, 2 + Math.log2(point.degree + 1) * .35)) : 2.5 + 5.5 * Math.sqrt(weight / maxWeight); + } + const forceLinks = [...links].sort((a, b) => compare(a.from, b.from) || compare(a.to, b.to) || compare(a.kind ?? "", b.kind ?? "") || compare(a.label ?? "", b.label ?? "")); + const semanticLinks = vectors ? semanticNeighbours(ids, vectors, { k: 3, minimum: .2 }).map(pair => ({ source: positions.get(pair.a), target: positions.get(pair.b), similarity: pair.similarity })) : []; + // Cohesion uses doc concept families when nodes share one; otherwise lexical families from the term vectors. + // The room is a disc with the padded area of all members, so cohesion never packs large weighted dots onto each other. + // Only bound weights size the room. An unweighted radius grows with every link, project associations included, so it takes its largest value. + const families = (concepts.length ? concepts.map(family => family.members) : vectors ? semanticFamilies(ids, vectors).map(family => family.members) : []).map(memberIds => { + const members = memberIds.map(id => positions.get(id)); + return { members, memberSet: new Set(members), room: Math.sqrt(members.reduce((sum, point) => sum + ((drawingWeight(point.node) === undefined ? 4.5 : point.radius) + 9) ** 2, 0)) }; + }); + let spatial = tree(points), alpha = 1; + const stats = { visits: 0, ticks: 0 }; + function tick() { + if (alpha < .005) return false; + stats.visits = 0; stats.ticks++; + for (const point of points) { + function repel(branch) { + stats.visits++; + if (!branch.mass) return; + let dx = point.x - branch.cx, dy = point.y - branch.cy, square = dx * dx + dy * dy; + const contains = point.x >= branch.x && point.x <= branch.x + branch.size && point.y >= branch.y && point.y <= branch.y + branch.size; + if (branch.children && (contains || branch.size * branch.size > square * .72)) { for (const child of branch.children) repel(child); return; } + if (!branch.children && branch.points.length > 4) { + if (square < .01) { const angle = hash(point.node.id) * .01; dx = Math.cos(angle) * .1; dy = Math.sin(angle) * .1; square = .02; } + const force = 32 * alpha * (branch.mass - (contains ? 1 : 0)) / Math.max(20, square); point.vx += dx * force; point.vy += dy * force; return; + } + if (!branch.children) { + for (const other of branch.points) if (other !== point) { + dx = point.x - other.x; dy = point.y - other.y; + if (dx === 0 && dy === 0) { const angle = hash(point.node.id) * .01; dx = Math.cos(angle) * .1; dy = Math.sin(angle) * .1; } + const force = 32 * alpha / Math.max(20, dx * dx + dy * dy); point.vx += dx * force; point.vy += dy * force; + } + } else { const force = 32 * alpha * branch.mass / Math.max(20, square); point.vx += dx * force; point.vy += dy * force; } + } + repel(spatial); + const anchor = anchors.get(point.category); + point.vx += (anchor.x - point.x) * (modular ? .045 : .002) * alpha - point.x * (modular ? 0 : .0004) * alpha; + point.vy += (anchor.y - point.y) * (modular ? .045 : .002) * alpha - point.y * (modular ? 0 : .0004) * alpha; + } + for (const { source: a, target: b, label, kind } of forceLinks) if (a !== b && (!modular || (kind !== "association" && label !== "within project root"))) { + const dx = b.x - a.x, dy = b.y - a.y, distance = Math.hypot(dx, dy) || 1, local = a.category === b.category, wanted = local ? 42 : modular ? 100 : 72; + const reference = kind === "import" || kind === "reference" || label === "imports"; + const strength = !modular ? .018 : local ? reference ? .008 : .012 : reference ? .0004 : .0012; + const force = (distance - wanted) / distance * strength * alpha, balance = Math.sqrt(Math.max(1, a.forceDegree, b.forceDegree)); + a.vx += dx * force / balance; a.vy += dy * force / balance; b.vx -= dx * force / balance; b.vy -= dy * force / balance; + } + for (const { source: a, target: b, similarity } of semanticLinks) { + const dx = b.x - a.x, dy = b.y - a.y, distance = Math.hypot(dx, dy) || 1, local = a.category === b.category, wanted = local ? 30 + (1 - similarity) * 40 : 60 + (1 - similarity) * 80; + if (distance <= wanted) continue; + const strength = (local ? .006 : .0025) * similarity * alpha, balance = Math.sqrt(Math.max(1, a.forceDegree, b.forceDegree)), force = (distance - wanted) / distance * strength / balance; + a.vx += dx * force; a.vy += dy * force; b.vx -= dx * force; b.vy -= dy * force; + } + for (const { members, memberSet, room } of families) { + let x = 0, y = 0, radius = 0; + for (const point of members) { x += point.x / members.length; y += point.y / members.length; } + for (const point of members) { + const distance = Math.hypot(point.x - x, point.y - y), pull = distance > room ? (1 - room / distance) * .05 * alpha : 0; + radius = Math.max(radius, distance); + point.vx += (x - point.x) * pull; point.vy += (y - point.y) * pull; + } + radius += 18; + for (const point of points) if (!memberSet.has(point)) { + let dx = point.x - x, dy = point.y - y; + const distance = Math.hypot(dx, dy); + if (distance >= radius) continue; + if (distance) { dx /= distance; dy /= distance; } + else { const angle = hash(point.node.id) * .01; dx = Math.cos(angle); dy = Math.sin(angle); } + const force = (radius - distance) * .3 * alpha; + point.vx += dx * force; point.vy += dy * force; + } + } + for (const point of points) { + point.vx = Math.max(-12, Math.min(12, point.vx * .72)); point.vy = Math.max(-12, Math.min(12, point.vy * .72)); + point.x += point.vx; point.y += point.vy; + } + alpha *= .94; spatial = tree(points); return alpha >= .005; + } + function bounds(subset = points) { + let left = Infinity, right = -Infinity, top = Infinity, bottom = -Infinity; + for (const point of subset) { left = Math.min(left, point.x - point.radius); right = Math.max(right, point.x + point.radius); top = Math.min(top, point.y - point.radius); bottom = Math.max(bottom, point.y + point.radius); } + return Number.isFinite(left) ? { x: left, y: top, width: Math.max(1, right - left), height: Math.max(1, bottom - top) } : { x: 0, y: 0, width: 1, height: 1 }; + } + return { positions, links, categories, stats, get alpha() { return alpha; }, tick, bounds }; +} +export function layoutGraph(data, availableWidth = 1000) { + const simulation = createSimulation(data); + while (simulation.tick()) { /* Finite cooling; no wall-clock dependent stopping. */ } + const bounds = simulation.bounds(); + return { positions: simulation.positions, bounds, width: bounds.width, height: bounds.height }; +} +const edgePlans = new WeakMap(); +export function edgeGeometry(edges, index, positions) { + let cached = edgePlans.get(positions); + if (!cached || cached.edges !== edges) { + const groups = new Map(); + edges.forEach((edge, at) => { const key = JSON.stringify([edge.from, edge.to]); if (!groups.has(key)) groups.set(key, []); groups.get(key).push(at); }); + const lanes = new Map(); + for (const indices of groups.values()) indices.forEach((at, lane) => { const edge = edges[at], reverse = groups.has(JSON.stringify([edge.to, edge.from])); lanes.set(at, { lane, count: indices.length, reverse }); }); + cached = { edges, lanes }; edgePlans.set(positions, cached); + } + const edge = edges[index], a = positions.get(edge.from), b = positions.get(edge.to); + if (!a || !b) return null; + const { lane, count, reverse } = cached.lanes.get(index); + if (a === b) { + const size = a.radius + 13 + lane * 5, from = [a.x - a.radius * .7, a.y - a.radius * .7], to = [a.x + a.radius * .7, a.y - a.radius * .7], control = [a.x - size, a.y - size * 2, a.x + size, a.y - size * 2]; + return { from, to, control, self: true, x: a.x, y: a.y - size * 1.5 }; + } + const dx = b.x - a.x, dy = b.y - a.y, length = Math.hypot(dx, dy) || 1, offset = (lane - (count - 1) / 2) * 10 + (reverse ? 15 : 0); + const cx = (a.x + b.x) / 2 - dy / length * offset, cy = (a.y + b.y) / 2 + dx / length * offset; + const clip = (point, x, y) => { const length = Math.hypot(x - point.x, y - point.y) || 1; return [point.x + (x - point.x) / length * (point.radius + 1), point.y + (y - point.y) / length * (point.radius + 1)]; }; + const from = clip(a, cx, cy), to = clip(b, cx, cy); + return { from, to, control: [cx, cy], self: false, x: (from[0] + 2 * cx + to[0]) / 4, y: (from[1] + 2 * cy + to[1]) / 4 }; +} +export function exactActivity(node, targets, codebaseId) { + if (node.hit?.codebaseId && node.hit.codebaseId !== codebaseId) return false; + return [node.id, node.label, node.path, node.hit?.symbol, node.hit?.qualifiedName, node.hit?.path].some(value => typeof value === "string" && targets.has(value)); +} diff --git a/plugins/semctx/adapters/omp/graph-data/layout.test.js b/plugins/semctx/adapters/omp/graph-data/layout.test.js new file mode 100644 index 0000000..e10b747 --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-data/layout.test.js @@ -0,0 +1,238 @@ +import { describe, expect, test } from "bun:test"; +import { bindDrawingWeight, drawingWeight, category, createSimulation, layoutGraph, edgeGeometry, representativeLinks, relationKind, relationStyle, exactActivity, sharedConceptFamilies } from "./layout.js"; +import { bindConceptFamily } from "./doc-concepts.js"; +import { bindTermVector, graphEmbedding, semanticFamilies } from "./embedding.js"; + +const hit = (values = {}) => ({ path: "src/graph.js", project: null, kind: "function", symbol: "walk", qualifiedName: "Graph.walk", codebaseId: "local", score: null, snippet: null, snippetStartLine: null, snippetEndLine: null, snippetStartByte: null, snippetEndByte: null, focusLine: null, focusByte: null, binding: { state: "bound", reason: null }, ...values }); +const node = (id, values = {}) => ({ id, label: id, detail: null, hit: hit(values) }); +function fixture() { + return { nodes: [node("A", { project: "core" }), node("B", { project: "core" }), node("C", { path: "ui/view.js" }), node("D", { codebaseId: "foreign" }), { id: "isolated", label: "Isolated", detail: null, hit: null }], edges: [{ from: "A", to: "B", label: "calls" }, { from: "A", to: "B", label: "also calls" }, { from: "B", to: "A", label: "returns" }, { from: "C", to: "B", label: "shares" }, { from: "B", to: "B", label: "recurses" }, { from: "B", to: "B", label: "also recurses" }, { from: "A", to: "D", label: "external" }], roots: ["A", "C"], caveats: ["Bounded"], truncated: false }; +} +const coordinates = positions => [...positions].map(([id, point]) => [id, point.x, point.y, point.radius, point.category]); + +describe("deterministic force layout", () => { + test("settles to finite stable coordinates independent of viewport or input node and edge order", () => { + const data = fixture(), original = JSON.stringify(data), first = layoutGraph(data, 360), second = layoutGraph({ ...data, nodes: [...data.nodes].reverse(), edges: [...data.edges].reverse() }, 1400); + expect(coordinates(first.positions)).toEqual(coordinates(second.positions)); expect(JSON.stringify(data)).toBe(original); + for (const point of first.positions.values()) { expect(Number.isFinite(point.x)).toBe(true); expect(Number.isFinite(point.y)).toBe(true); expect(point.radius).toBeGreaterThan(0); expect(point.x - point.radius).toBeGreaterThanOrEqual(first.bounds.x); expect(point.y - point.radius).toBeGreaterThanOrEqual(first.bounds.y); expect(point.x + point.radius).toBeLessThanOrEqual(first.bounds.x + first.width + .000001); expect(point.y + point.radius).toBeLessThanOrEqual(first.bounds.y + first.height + .000001); } + }); + test("retains isolated nodes, mixed codebases and all exact directed relationships", () => { + const data = fixture(), simulation = createSimulation(data); + expect(simulation.positions.size).toBe(data.nodes.length); expect(simulation.links.map(link => [link.from, link.to, link.label])).toEqual(data.edges.map(edge => [edge.from, edge.to, edge.label])); + expect(simulation.positions.get("D").node.hit.codebaseId).toBe("foreign"); expect(simulation.positions.has("isolated")).toBe(true); + }); + test("only real metadata determines category anchors; query scores do not imply similarity", () => { + expect(category({ id: "file", category: "file", path: "lib\\part\\file.ts", project: "engine", hit: hit({ project: "wrong" }) }).key).toBe('module:["engine","lib/part"]'); + expect(category({ id: "root", category: "project", path: "", project: "", hit: null })).toEqual({ key: "project:", label: ".", type: "Project" }); + expect(category({ category: "file", path: "lib\\part\\file.ts", hit: null }).key).toBe('module:["","lib/part"]'); + expect(category(node("kind", { path: null, kind: "trait" })).key).toBe("kind:trait"); expect(category({ hit: null }).key).toBe("structure"); + const data = fixture(), changed = { ...data, nodes: data.nodes.map(item => ({ ...item, hit: item.hit ? { ...item.hit, score: 999 } : null })) }; + expect(coordinates(createSimulation(data).positions)).toEqual(coordinates(createSimulation(changed).positions)); + }); + test("reuses previous positions without relayout when a graph gains a node", () => { + const data = fixture(), settled = layoutGraph(data), updated = createSimulation({ ...data, nodes: [...data.nodes, node("new")] }, settled.positions); + for (const [id, old] of settled.positions) { expect(updated.positions.get(id).x).toBe(old.x); expect(updated.positions.get(id).y).toBe(old.y); } + }); + test("finite cooling stops the layout without changing settled positions", () => { + const simulation = createSimulation(fixture()); let ticks = 0; + while (simulation.tick()) { if (++ticks > 100) throw new Error("Force simulation did not stop."); } + expect(simulation.alpha).toBeLessThan(.005); const before = coordinates(simulation.positions); expect(simulation.tick()).toBe(false); expect(coordinates(simulation.positions)).toEqual(before); + }); + test("Barnes–Hut repulsion visits substantially fewer cells than all-pairs for large maps", () => { + const count = 2048, data = { nodes: Array.from({ length: count }, (_, index) => ({ id: `file-${String(index).padStart(4, "0")}`, label: `File ${index}`, category: "file", path: `src/file-${index}.ts`, hit: null })), edges: [], roots: [] }; + const simulation = createSimulation(data); simulation.tick(); expect(simulation.stats.visits).toBeLessThan(count * count / 4); expect(simulation.positions.size).toBe(count); + }); + test("parallel, reciprocal and repeated self-links have distinct finite curved geometry", () => { + const data = fixture(), result = layoutGraph(data), geometry = data.edges.map((_, index) => edgeGeometry(data.edges, index, result.positions)); + expect(new Set(geometry.map(route => JSON.stringify([route.from, route.control, route.to]))).size).toBe(data.edges.length); + for (const route of geometry) { expect(route.from.every(Number.isFinite)).toBe(true); expect(route.to.every(Number.isFinite)).toBe(true); expect(route.control.every(Number.isFinite)).toBe(true); } + expect(geometry[4].self).toBe(true); expect(geometry[5].self).toBe(true); + const old = geometry[0]; result.positions.get("A").x += 100; expect(edgeGeometry(data.edges, 0, result.positions)).not.toEqual(old); + }); + test("directory nodes belong to their own reported branch, within a project namespace", () => { + const directory = { id: "d", category: "directory", path: "crates/semctl/src/query", project: "crates/semctl", hit: null }; + const child = { ...directory, id: "f", category: "file", path: directory.path + "/parser/reader.rs" }; + expect(category(directory).key).toBe(category(child).key); + expect(category(child).label).toBe("src/query"); + expect(category({ ...child, project: "another-project" }).key).not.toBe(category(child).key); + expect(category({ id: "adapter", category: "file", path: "plugins/semctx/adapters/omp/index.ts", project: "", hit: null }).label).toBe("plugins/semctx/adapters"); + expect(category(node("symbol", { project: "engine", path: "lib/part/file.ts" })).key).toBe("project:engine"); + }); + test("unconnected groups with shared identifier terms stay nearer than unrelated groups", () => { + const nodes = [ + { id: "dom-frame", label: "RenderFrame SurfacePainter", category: "file", path: "src/render-dom/frame.ts", hit: null }, + { id: "dom-surface", label: "RenderSurface FramePainter", category: "file", path: "src/render-dom/surface.ts", hit: null }, + { id: "gpu-frame", label: "RenderFrame SurfacePainter", category: "file", path: "src/render-gpu/frame.ts", hit: null }, + { id: "gpu-surface", label: "RenderSurface FramePainter", category: "file", path: "src/render-gpu/surface.ts", hit: null }, + { id: "storage-journal", label: "TransactionJournal Checkpoint", category: "file", path: "src/storage/journal.ts", hit: null }, + { id: "storage-record", label: "TransactionRecord Checkpoint", category: "file", path: "src/storage/record.ts", hit: null }, + ]; + const data = { nodes, edges: [], roots: [] }, positions = layoutGraph(data).positions; + const mean = branch => { + const points = [...positions.values()].filter(point => point.node.path.startsWith(`src/${branch}/`)); + return { x: points.reduce((sum, point) => sum + point.x, 0) / points.length, y: points.reduce((sum, point) => sum + point.y, 0) / points.length }; + }; + const dom = mean("render-dom"), gpu = mean("render-gpu"), storage = mean("storage"), distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); + expect(distance(dom, gpu)).toBeLessThan(distance(dom, storage)); expect(distance(dom, gpu)).toBeLessThan(distance(gpu, storage)); + expect(coordinates(positions)).toEqual(coordinates(layoutGraph({ ...data, nodes: [...nodes].reverse() }).positions)); + }); + test("lexical families cohere and exclude unrelated connected nodes from their member radius", () => { + const nodes = [ + ...["render-a", "render-b", "render-c"].map(id => ({ id, label: "RenderFrame SurfacePainter", hit: null })), + ...["storage-a", "storage-b", "storage-c"].map(id => ({ id, label: "TransactionJournal RecordWriter", hit: null })), + { id: "router", label: "BoundaryRouter", hit: null }, { id: "metrics", label: "MetricsCounter", hit: null }, + ]; + for (const node of nodes) bindTermVector(node, new Map([[node.id.startsWith("render-") ? "renderer" : node.id.startsWith("storage-") ? "transaction" : node.id, 1]])); + const edges = [ + ...nodes.filter(node => node.id.startsWith("render-")).map(node => ({ from: "router", to: node.id, label: "calls" })), + ...nodes.filter(node => node.id.startsWith("storage-")).map(node => ({ from: "metrics", to: node.id, label: "calls" })), + { from: "router", to: "metrics", label: "calls" }, + ]; + const data = { nodes, edges, roots: [] }, previous = new Map([ + ["render-a", { x: -120, y: -30 }], ["render-b", { x: -20, y: -30 }], ["render-c", { x: -70, y: 60 }], + ["storage-a", { x: 20, y: -30 }], ["storage-b", { x: 120, y: -30 }], ["storage-c", { x: 70, y: 60 }], + ["router", { x: -70, y: 0 }], ["metrics", { x: 70, y: 0 }], + ]); + const ids = nodes.map(node => node.id).sort(), families = semanticFamilies(ids, graphEmbedding(data).vectors), simulation = createSimulation(data, previous); + expect(families.map(family => family.members)).toEqual([["render-a", "render-b", "render-c"], ["storage-a", "storage-b", "storage-c"]]); + while (simulation.tick()) { /* Use the same finite cooling as settled previews. */ } + for (const family of families) { + const members = family.members.map(id => simulation.positions.get(id)), memberIds = new Set(family.members); + const x = members.reduce((sum, point) => sum + point.x / members.length, 0), y = members.reduce((sum, point) => sum + point.y / members.length, 0); + const memberRadius = Math.max(...members.map(point => Math.hypot(point.x - x, point.y - y))); + for (const point of simulation.positions.values()) if (!memberIds.has(point.node.id)) expect(Math.hypot(point.x - x, point.y - y)).toBeGreaterThan(memberRadius); + } + const shuffled = createSimulation({ ...data, nodes: [...nodes].reverse(), edges: [...edges].reverse() }, previous); + while (shuffled.tick()) { /* Use the same finite cooling as settled previews. */ } + expect(coordinates(simulation.positions)).toEqual(coordinates(shuffled.positions)); + }); + test("shared doc concept families keep families with two or more members, largest first", () => { + const family = key => ({ key: `family:${key}`, label: key }), nodes = ["c", "a", "b", "d", "e", "f"].map(id => ({ id, label: id, hit: null })); + bindConceptFamily(nodes[0], family("docs/zeta")); bindConceptFamily(nodes[1], family("docs/zeta")); + for (const node of nodes.slice(2, 5)) bindConceptFamily(node, family("docs/alpha")); + bindConceptFamily(nodes[5], family("docs/single")); + expect(sharedConceptFamilies(nodes)).toEqual([ + { key: "family:docs/alpha", label: "docs/alpha", members: ["b", "d", "e"] }, + { key: "family:docs/zeta", label: "docs/zeta", members: ["a", "c"] }]); + expect(sharedConceptFamilies([...nodes].reverse())).toEqual(sharedConceptFamilies(nodes)); + expect(sharedConceptFamilies(nodes.map(node => ({ ...node })))).toEqual([]); + }); + test("doc concept families anchor and cohere their members without packing weighted dots together", () => { + const family = key => ({ key: `family:${key}`, label: key }); + const concepts = () => { + const nodes = ["arch-a", "arch-b", "arch-c", "policy-a", "policy-b", "policy-c", "tests", "tools"].map((id, index) => { + const node = { id, label: id, category: "group", hit: null, detail: null }; + bindTermVector(node, new Map([[id.split("-")[0], 1]])); + if (id.includes("-")) bindConceptFamily(node, family(id.startsWith("arch") ? "docs/architecture" : "docs/policy")); + bindDrawingWeight(node, 4 + index); + return node; + }); + const edges = [["arch-a", "arch-b"], ["arch-b", "arch-c"], ["policy-a", "policy-b"], ["tests", "arch-a"], ["tests", "policy-c"], ["tools", "tests"], ["arch-c", "policy-a"]] + .map(([from, to]) => ({ from, to, label: "imports", kind: "import" })); + return { nodes, edges, roots: [] }; + }; + const settle = data => { const simulation = createSimulation(data); while (simulation.tick()) { /* Finite cooling. */ } return simulation; }; + const simulation = settle(concepts()); + for (const [key, prefix] of [["family:docs/architecture", "arch-"], ["family:docs/policy", "policy-"]]) { + const members = [...simulation.positions.values()].filter(point => point.category === key); + expect(members.map(point => point.node.id).sort()).toEqual(["a", "b", "c"].map(suffix => prefix + suffix)); + const x = members.reduce((sum, point) => sum + point.x / members.length, 0), y = members.reduce((sum, point) => sum + point.y / members.length, 0); + const memberRadius = Math.max(...members.map(point => Math.hypot(point.x - x, point.y - y))); + for (const point of simulation.positions.values()) if (!point.node.id.startsWith(prefix)) expect(Math.hypot(point.x - x, point.y - y)).toBeGreaterThan(memberRadius); + for (let a = 0; a < members.length; a++) for (let b = a + 1; b < members.length; b++) { + expect(Math.hypot(members[a].x - members[b].x, members[a].y - members[b].y)).toBeGreaterThan(members[a].radius + members[b].radius + 10); + } + } + const reversed = concepts(); + reversed.nodes.reverse(); reversed.edges.reverse(); + expect(coordinates(settle(reversed).positions)).toEqual(coordinates(simulation.positions)); + }); + test("drawing weights stay out of wire data and ignore invalid values", () => { + const item = { id: "aggregate" }, edge = { from: "a", to: "b" }, original = JSON.stringify([item, edge]); + expect(drawingWeight(item)).toBeUndefined(); + for (const weight of [NaN, Infinity, -Infinity, 0, -1]) { bindDrawingWeight(item, weight); expect(drawingWeight(item)).toBeUndefined(); } + bindDrawingWeight(item, 4); bindDrawingWeight(edge, .5); + expect(drawingWeight(item)).toBe(4); expect(drawingWeight(edge)).toBe(.5); + for (const weight of [NaN, Infinity, -Infinity, 0, -1]) { bindDrawingWeight(item, weight); expect(drawingWeight(item)).toBe(4); } + expect(JSON.stringify([item, edge])).toBe(original); + }); + test("bound aggregate weights give larger simulation radii than unweighted siblings", () => { + const heavy = { id: "heavy", label: "Heavy", hit: null }, light = { id: "light", label: "Light", hit: null }, plain = { id: "plain", label: "Plain", hit: null }; + bindDrawingWeight(heavy, 9); bindDrawingWeight(light, 1); + const positions = createSimulation({ nodes: [plain, light, heavy], edges: [], roots: [] }).positions; + expect(positions.get("heavy").radius).toBe(8); expect(positions.get("light").radius).toBeCloseTo(2.5 + 5.5 / 3); + expect(positions.get("heavy").radius).toBeGreaterThan(positions.get("light").radius); expect(positions.get("light").radius).toBeGreaterThan(positions.get("plain").radius); + }); + test("weighted module anchors stay separated even with cross-module imports", () => { + const nodes = ["query", "engine"].flatMap(branch => Array.from({ length: 8 }, (_, index) => ({ id: `${branch}-${index}`, label: `${branch}-${index}`, category: "file", project: "", path: `src/${branch}/file-${index}.rs`, hit: null }))); + const edges = []; + for (let index = 0; index < 8; index++) for (let target = 0; target < 8; target++) edges.push({ from: `query-${index}`, to: `engine-${target}`, label: "imports", kind: "import" }); + const data = { nodes, edges, roots: [] }, result = layoutGraph(data); + const clouds = ["query", "engine"].map(branch => { + const points = [...result.positions.values()].filter(point => point.node.path.startsWith(`src/${branch}/`)), x = points.reduce((sum, point) => sum + point.x, 0) / points.length, y = points.reduce((sum, point) => sum + point.y, 0) / points.length; + return { x, y, radius: points.reduce((sum, point) => sum + Math.hypot(point.x - x, point.y - y), 0) / points.length }; + }); + expect(Math.hypot(clouds[0].x - clouds[1].x, clouds[0].y - clouds[1].y)).toBeGreaterThan(2 * (clouds[0].radius + clouds[1].radius)); + const augmented = layoutGraph({ ...data, edges: [...edges, ...nodes.map(item => ({ from: "query-0", to: item.id, label: "within project root", kind: "association" }))] }); + expect([...result.positions].map(([id, point]) => [id, point.x, point.y])).toEqual([...augmented.positions].map(([id, point]) => [id, point.x, point.y])); + }); + test("group placement follows dependency topology, not alphabetical packing or transport order", () => { + const nodes = ["a-isolate", "b-leaf", "c-hub", "d-leaf", "e-isolate"].map(id => ({ id, label: id, category: "file", path: `${id}/node.rs`, hit: null })); + const edges = [{ from: "c-hub", to: "b-leaf", kind: "reference", label: "references" }, { from: "c-hub", to: "d-leaf", kind: "reference", label: "references" }]; + const data = { nodes, edges, roots: [] }, simulation = createSimulation(data), shuffled = createSimulation({ ...data, nodes: [...nodes].reverse(), edges: [...edges].reverse() }); + expect(simulation.categories.map(group => group.label)).toEqual(["c-hub", "b-leaf", "d-leaf", "a-isolate", "e-isolate"]); + expect(coordinates(simulation.positions)).toEqual(coordinates(shuffled.positions)); + expect(coordinates(simulation.positions)).not.toEqual(coordinates(createSimulation({ ...data, edges: [] }).positions)); + expect(simulation.links).toHaveLength(edges.length); + }); + test("only explicit relation kinds and known legacy labels acquire a typed pattern", () => { + expect(relationKind({ label: "calls" })).toBe("generic"); + expect(relationKind({ label: "inherits" })).toBe("generic"); + expect(relationKind({ label: "references" })).toBe("generic"); + expect(relationKind({ kind: "unknown", label: "imports" })).toBe("generic"); + expect(relationKind({ label: "contains reported child" })).toBe("containment"); + expect(relationKind({ label: "within project root" })).toBe("association"); + expect(relationKind({ label: "imports" })).toBe("import"); + expect(relationKind({ kind: "reference", label: "calls" })).toBe("reference"); + expect(relationStyle({ label: "calls" }).dash).toEqual([]); + expect(relationStyle({ kind: "reference", label: "calls" }).dash).toEqual([]); + expect(relationStyle({ kind: "containment", label: "contains" }).dash).toEqual([]); + expect(relationStyle({ kind: "association", label: "" }).dash.length).toBeGreaterThan(0); + expect(relationStyle({ kind: "import", label: "" }).dash.length).toBeGreaterThan(0); + expect(relationStyle({ kind: "association", label: "" }).dash).not.toEqual(relationStyle({ kind: "import", label: "" }).dash); + }); + test("representative routes collapse parallel links only within exact endpoints, direction and kind", () => { + const nodes = ["a", "b"].map(id => ({ id, label: id, hit: null })); + const edges = [ + { from: "a", to: "b", kind: "reference", label: "references", symbol: "First", moniker: "real::First" }, + { from: "a", to: "b", kind: "reference", label: "references", symbol: "Second", moniker: "real::Second" }, + { from: "a", to: "b", kind: "import", label: "imports" }, + { from: "b", to: "a", kind: "reference", label: "references", symbol: "Reverse", moniker: "real::Reverse" }, + { from: "a", to: "a", kind: "reference", label: "references", symbol: "Self", moniker: "real::Self" }, + ]; + const data = { nodes, edges, roots: [] }, original = JSON.stringify(data), pairs = representativeLinks(data); + expect(pairs).toHaveLength(4); + expect(pairs.flatMap(pair => pair.indices).sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 4]); + for (const pair of pairs) { + expect(pair.count).toBe(pair.indices.length); + for (const index of pair.indices) expect([edges[index].from, edges[index].to, relationKind(edges[index])]).toEqual([pair.from, pair.to, pair.kind]); + } + const parallel = pairs.find(pair => pair.count === 2); + expect(parallel.indices).toEqual([0, 1]); expect(parallel.symbol).toBeNull(); expect(parallel.moniker).toBeNull(); + const summary = values => values.map(pair => [pair.from, pair.to, pair.kind, pair.count, pair.symbol, pair.moniker]); + expect(summary(pairs)).toEqual(summary(representativeLinks({ ...data, edges: [...edges].reverse() }))); + expect(JSON.stringify(data)).toBe(original); + const positions = createSimulation(data).positions; + const routes = pairs.map((_, index) => edgeGeometry(pairs, index, positions)); + expect(new Set(routes.map(route => JSON.stringify([route.from, route.control, route.to]))).size).toBe(pairs.length); + }); +}); + +describe("local interaction data", () => { + test("observed activity requires exact targets and refuses foreign-codebase matches", () => { + const item = node("A"); expect(exactActivity(item, new Set(["graph.js", "src/", "Graph"]), "local")).toBe(false); + expect(exactActivity(item, new Set(["src/graph.js"]), "local")).toBe(true); + expect(exactActivity({ ...item, hit: { ...item.hit, codebaseId: "other" } }, new Set(["A", "src/graph.js", "Graph.walk"]), "local")).toBe(false); + expect(exactActivity({ id: "file", path: "lib.rs", hit: null }, new Set(["lib.rs"]), "local")).toBe(true); + }); +}); diff --git a/plugins/semctx/adapters/omp/graph-data/matches.d.ts b/plugins/semctx/adapters/omp/graph-data/matches.d.ts new file mode 100644 index 0000000..c65113f --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-data/matches.d.ts @@ -0,0 +1,9 @@ +export type ScoreScale = "ratio" | "range"; +export function scoreScale(rows: readonly { score: number | null }[]): ScoreScale | null; +export function strengths(rows: readonly { score: number | null }[]): (number | null)[]; +export function strengthScale(row: object): ScoreScale | null; +export const SCORE_WIDTH_PX: number; +export const SCORE_WIDTH_CH: number; +export const SCORE_HEIGHT_PX: number; +export function scoreTitle(score: number, strength: number | null, index: number, scale?: ScoreScale | null): string; +export function verificationTitle(binding: { state: string; reason: string | null }): string; diff --git a/plugins/semctx/adapters/omp/graph-data/matches.js b/plugins/semctx/adapters/omp/graph-data/matches.js new file mode 100644 index 0000000..85fa82d --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-data/matches.js @@ -0,0 +1,41 @@ +// Scale kind of each row's latest `strengths` result. Renderers receive only (hit, strength), so the +// tooltip wording is looked up by row identity; the most recent `strengths` call for a row wins. +const rowScales = new WeakMap(); + +/** Query-relative scale kind: "ratio" for nonnegative sets, "range" for signed sets, null when unscalable. */ +export function scoreScale(rows) { + if (rows.some(h => typeof h.score !== "number" || !Number.isFinite(h.score))) return null; + const scores = rows.map(h => h.score), max = Math.max(...scores), min = Math.min(...scores); + if (min >= 0) return max > 0 ? "ratio" : null; + return max - min > 1e-12 ? "range" : null; +} + +/** Query-relative visual strength only; never a validity/confidence score. */ +export function strengths(rows) { + const scale = scoreScale(rows); + const scores = rows.map(h => h.score), max = Math.max(...scores), min = Math.min(...scores); + return rows.map(h => { + if (scale === null) { rowScales.delete(h); return null; } + rowScales.set(h, scale); + return scale === "ratio" ? h.score / max : 0.1 + 0.9 * (h.score - min) / (max - min); + }); +} + +/** Scale kind recorded for `row` by its latest `strengths` call, or null. */ +export function strengthScale(row) { + return rowScales.get(row) ?? null; +} + +// Native small meters have a 40 px minimum rail. Keep native and image bars aligned. +export const SCORE_WIDTH_PX = 40; +export const SCORE_WIDTH_CH = 6; +export const SCORE_HEIGHT_PX = 4; +export function scoreTitle(score, strength, index, scale = "ratio") { + const basis = strength === null ? "Relative strength unavailable" + : scale === "range" ? "Bar: position between lowest and highest result scores" : "Bar: relative to highest result score"; + return `Rank ${index + 1} · Score ${score}\n${basis} · not confidence.`; +} +const bindingLabels = { bound: "Source bytes verified", "changed-local": "Local changes", "range-mismatch": "Range mismatch", unverified: "Unverified source", "not-probed": "Source not checked" }; +export function verificationTitle(binding) { + return `${bindingLabels[binding.state] ?? binding.state}${binding.reason ? `\n${binding.reason}` : ""}${binding.state === "bound" ? "\nSymbol and focus identity not checked." : ""}`; +} diff --git a/plugins/semctx/adapters/omp/graph-data/matches.test.js b/plugins/semctx/adapters/omp/graph-data/matches.test.js new file mode 100644 index 0000000..3fbefb4 --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-data/matches.test.js @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import { scoreScale, scoreTitle, strengthScale, strengths, verificationTitle } from "./matches.js"; + +describe("shared match indicators", () => { + test("preserves relative strength and original rank", () => { + const rows = [{ score: 8 }, { score: 2 }, { score: 0 }]; + const scales = strengths(rows); + expect(scales).toEqual([1, 0.25, 0]); + expect(scoreTitle(rows[1].score, scales[1], 1)).toContain("Rank 2"); + expect(scoreTitle(rows[1].score, scales[1], 1)).toContain("Score 2"); + }); + test("retains unknown strength for missing, non-finite or all-zero scores without inventing confidence", () => { + for (const scores of [[0, 0], [2, null], [-2, null], [2, Infinity], [-2, -Infinity]]) { + const rows = scores.map(score => ({ score })); + expect(strengths(rows)).toEqual(scores.map(() => null)); + expect(scoreScale(rows)).toBeNull(); + for (const row of rows) expect(strengthScale(row)).toBeNull(); + } + expect(strengths([])).toEqual([]); + expect(scoreScale([])).toBeNull(); + const title = scoreTitle(0, null, 0); + expect(title).toContain("Score 0"); + expect(title).toContain("unavailable"); + expect(title).toContain("not confidence"); + }); + test("signed score sets use a floored range scale between the lowest and highest scores", () => { + const mixed = [{ score: 2 }, { score: -1 }]; + expect(scoreScale(mixed)).toBe("range"); + expect(strengths(mixed)).toEqual([1, 0.1]); + expect(strengthScale(mixed[0])).toBe("range"); + const rows = [{ score: -1.59 }, { score: -6.14 }, { score: -3.865 }]; + const scales = strengths(rows); + expect(scales[0]).toBe(1); + expect(scales[1]).toBe(0.1); + expect(scales[2]).toBeCloseTo(0.55, 12); + for (const row of rows) expect(strengthScale(row)).toBe("range"); + for (const scores of [[-3, -3], [-1.5, -1.5 + 1e-13]]) { + const equal = scores.map(score => ({ score })); + expect(strengths(equal)).toEqual([null, null]); + expect(scoreScale(equal)).toBeNull(); + expect(strengthScale(equal[0])).toBeNull(); + } + }); + test("tooltips name the ratio or range basis and never claim confidence", () => { + const ratio = [{ score: 8 }, { score: 2 }], range = [{ score: -1 }, { score: -5 }]; + expect(scoreScale(ratio)).toBe("ratio"); + const ratioTitle = scoreTitle(2, strengths(ratio)[1], 1, strengthScale(ratio[1])); + expect(ratioTitle).toContain("Bar: relative to highest result score"); + expect(ratioTitle).toContain("not confidence"); + const rangeTitle = scoreTitle(-5, strengths(range)[1], 1, strengthScale(range[1])); + expect(rangeTitle).toContain("Bar: position between lowest and highest result scores"); + expect(rangeTitle).toContain("Score -5"); + expect(rangeTitle).toContain("not confidence"); + expect(scoreTitle(-5, null, 1, "range")).toContain("Relative strength unavailable"); + expect(scoreTitle(2, 0.25, 1)).toContain("relative to highest result score"); + }); + test("keeps exact raw scores and diagnostic reasons available to tooltip consumers", () => { + const score = 0.127403; + expect(scoreTitle(score, 0.5, 4)).toContain(String(score)); + for (const state of ["bound", "changed-local", "range-mismatch", "unverified", "not-probed"]) { + const reason = `specific-${state}-reason`; + expect(verificationTitle({ state, reason })).toContain(reason); + expect(verificationTitle({ state, reason: null })).not.toContain("null"); + } + const titles = ["bound", "changed-local", "range-mismatch", "unverified", "not-probed"] + .map(state => verificationTitle({ state, reason: null })); + expect(new Set(titles).size).toBe(titles.length); + }); +}); diff --git a/plugins/semctx/adapters/omp/graph-data/palette.d.ts b/plugins/semctx/adapters/omp/graph-data/palette.d.ts new file mode 100644 index 0000000..e5d0c79 --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-data/palette.d.ts @@ -0,0 +1,3 @@ +export const conceptSwatches: readonly { readonly light: string; readonly dark: string }[]; +export function conceptPalette(key: string): { light: string; dark: string }; +export function normalizeConceptPath(path: string | null | undefined): string | undefined; diff --git a/plugins/semctx/adapters/omp/graph-data/palette.js b/plugins/semctx/adapters/omp/graph-data/palette.js new file mode 100644 index 0000000..7c92443 --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-data/palette.js @@ -0,0 +1,15 @@ +// Category colors encode evidence-backed communities, never rank or confidence. +const light = ["#526fbd", "#298174", "#925bb2", "#b6792b", "#22849d", "#b05277", "#6b8935", "#b36042", "#6961a9", "#7c704b"]; +const dark = ["#8ca8ef", "#63bca5", "#bd8cda", "#d6ab62", "#68b9d1", "#dc8dab", "#a2bc6a", "#dc9b7a", "#aaa0df", "#bcb081"]; +export const conceptSwatches = Object.freeze(light.map((color, index) => Object.freeze({ light: color, dark: dark[index] }))); +export function conceptPalette(key) { + let hash = 2166136261; + for (let index = 0; index < key.length; index++) hash = Math.imul(hash ^ key.charCodeAt(index), 16777619); + const index = (hash >>> 0) % light.length; + return { light: light[index], dark: dark[index] }; +} +export function normalizeConceptPath(path) { + if (typeof path !== "string" || !path || path.length > 4096 || /[:\u0000-\u001f\u007f-\u009f]/.test(path)) return undefined; + const normalized = path.replace(/\\/g, "/").replace(/^(\.\/)+/, ""); + return normalized && !normalized.split("/").some(part => !part || part === "." || part === "..") ? normalized : undefined; +} diff --git a/plugins/semctx/adapters/omp/graph-indexing.ts b/plugins/semctx/adapters/omp/graph-indexing.ts new file mode 100644 index 0000000..102b294 --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-indexing.ts @@ -0,0 +1,55 @@ +import type { GraphData } from "./graph"; + +export type GraphIndexing = "pending" | "processing" | "ready" | "failed" | "unknown"; +export type GraphIndexingCounts = Record; +const states: readonly GraphIndexing[] = ["failed", "processing", "pending", "ready", "unknown"]; +const countsByNode = new WeakMap>(); + +/** Aggregate counts stay outside the source protocol node. */ +export function bindIndexingCounts(node: object, counts: Readonly): void { + countsByNode.set(node, counts); +} +export function boundIndexingCounts(node: object): Readonly | undefined { + return countsByNode.get(node); +} + +/** Explicit metadata is evidence; legacy source nodes stay unreported, synthetic hierarchy does not. */ +export function indexingCounts(nodes: readonly GraphData["nodes"][number][]): GraphIndexingCounts { + const counts: GraphIndexingCounts = { pending: 0, processing: 0, ready: 0, failed: 0, unknown: 0, unreported: 0 }; + for (const node of nodes) { + if (node.indexing === undefined && !node.hit && + (node.category === "directory" || node.category === "project" || node.category === "group")) continue; + counts[node.indexing ?? "unreported"]++; + } + return counts; +} +export function indexingSummary(counts: GraphIndexingCounts): string | undefined { + if (!states.some(state => counts[state] > 0)) return undefined; + return `Indexing: ${[...states, "unreported" as const].filter(state => counts[state] > 0).map(state => `${counts[state]} ${state}`).join(" · ")}`; +} +/** Meter shares use reported states only; absent metadata is not an unknown state. */ +export function indexingParts(counts: GraphIndexingCounts): { value: number; token: string; label: string }[] | undefined { + const total = states.reduce((sum, state) => sum + counts[state], 0); + if (!total) return undefined; + return ([["ready", "success"], ["processing", "accent"], ["pending", "muted"], ["failed", "error"], ["unknown", "dim"]] as const) + .filter(([state]) => counts[state] > 0).map(([state, token]) => ({ value: counts[state] / total, token, label: `${counts[state]} ${state}` })); +} +export function groupIndexing(counts: GraphIndexingCounts): GraphIndexing | undefined { + for (const state of ["failed", "processing", "pending"] as const) if (counts[state]) return state; + if (counts.unknown) return "unknown"; + return counts.ready ? counts.unreported ? "unknown" : "ready" : undefined; +} +export function indexingText(node: GraphData["nodes"][number]): string | undefined { + const status = node.indexing; + if (status === undefined) return undefined; + const label = node.category === "group" + ? status === "pending" ? "Group includes pending embeddings (not processing)." + : status === "processing" ? "Group includes processing embeddings." + : status === "failed" ? "Group includes failed embeddings." + : status === "ready" ? "All group embeddings ready." : "Embedding status unknown for some group members." + : status === "pending" ? "Embedding pending (not processing)." + : status === "processing" ? "Embedding processing." + : status === "failed" ? "Embedding failed." + : status === "ready" ? "Embedding ready." : "Embedding status unknown."; + return `${label}${node.indexingError ? `\n${node.indexingError}` : ""}`; +} diff --git a/plugins/semctx/adapters/omp/graph-native-layout.test.ts b/plugins/semctx/adapters/omp/graph-native-layout.test.ts new file mode 100644 index 0000000..47d6337 --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-native-layout.test.ts @@ -0,0 +1,462 @@ +import { expect, test } from "bun:test"; +import type { GraphData } from "./graph"; +import { DEFAULT_GRAPH_APPEARANCE, type GraphAppearance } from "./graph-appearance"; +import { bindConceptFamily } from "./graph-data/doc-concepts.js"; +import { bindTermVector } from "./graph-data/embedding.js"; +import { bindDrawingWeight, layoutGraph } from "./graph-data/layout.js"; +import { conceptPalette } from "./graph-data/palette.js"; +import { nativeGraphLayout, type NativeGraphLayout, type NativeGraphTerritory } from "./graph-native-layout"; +import type { GraphPreviewSize } from "./graph-preview"; + +const choices: GraphAppearance["layout"][] = ["force", "radial", "layers", "components", "map"]; +/** Layouts that encode recorded topology only and draw no territories. */ +const topologyChoices = ["radial", "layers", "components"] as const; +const sizes: GraphPreviewSize[] = ["inline", "compact", "overview"]; +function fixture(count = 24): GraphData { + return { kind: "graph", title: "Geometry", query: null, roots: ["n00"], caveats: ["Local indexed evidence."], truncated: false, + nodes: Array.from({ length: count }, (_, index) => ({ id: `n${String(index).padStart(2, "0")}`, label: `Node ${index}`, + category: "file", path: `src/n${index}.ts`, hit: null, detail: null })), + edges: Array.from({ length: Math.max(0, count - 1) }, (_, index) => ({ from: "n00", to: `n${String(index + 1).padStart(2, "0")}`, label: "calls" })), + }; +} +/** Two lexical identifier families; each node path stem carries its family terms. */ +function lexical(): GraphData { + const families: [string, string, string[]][] = [["cache/entry", "CacheEntry", ["Store", "Read", "Evict", "Write"]], ["query/execution", "QueryExecution", ["Plan", "Run", "Parse", "Explain"]]]; + return { kind: "graph", title: "Lexical", query: null, roots: ["CacheEntryStore"], caveats: [], truncated: false, + nodes: families.flatMap(([directory, prefix, verbs]) => verbs.map(verb => ({ id: `${prefix}${verb}`, label: `${prefix}${verb}`, + category: "file" as const, path: `${directory}/${prefix}${verb}.ts`, hit: null, detail: null }))), + edges: families.flatMap(([, prefix, verbs]) => verbs.slice(1).map(verb => ({ from: `${prefix}${verbs[0]}`, to: `${prefix}${verb}`, label: "calls" }))), + }; +} +/** Overview-like groups: two doc concept families and three groups without a family, with bound vectors and weights. */ +function concepts(): GraphData { + const families = { architecture: { key: "family:docs/architecture", label: "Architecture" }, policy: { key: "family:docs/policy", label: "Policy" } }; + const groups: [string, string[], keyof typeof families | undefined, number][] = [ + ["arch-boot", ["boot", "chain", "vmx"], "architecture", 9], ["arch-dispatch", ["dispatch", "exit", "vmx"], "architecture", 14], + ["arch-memory", ["memory", "translation", "vmx"], "architecture", 6], ["policy-compat", ["compatibility", "cpu", "policy"], "policy", 5], + ["policy-cpu", ["admission", "cpu", "policy"], "policy", 7], ["policy-threat", ["attribution", "threat", "policy"], "policy", 4], + ["tests", ["contract", "test"], undefined, 12], ["tools", ["script", "tool"], undefined, 8], ["windows", ["kernel", "windows"], undefined, 3], + ]; + const nodes = groups.map(([id, terms, family, weight]) => { + const node: GraphData["nodes"][number] = { id, label: id, category: "group", hit: null, detail: null }; + bindTermVector(node, new Map(terms.map(term => [term, 1 / Math.sqrt(terms.length)]))); + if (family) bindConceptFamily(node, families[family]); + bindDrawingWeight(node, weight); + return node; + }); + const links: [string, string, number][] = [["arch-dispatch", "arch-boot", 6], ["arch-dispatch", "arch-memory", 4], ["arch-boot", "arch-memory", 2], + ["policy-cpu", "policy-compat", 3], ["policy-cpu", "policy-threat", 1], ["tests", "arch-dispatch", 5], ["tools", "tests", 2], + ["windows", "arch-memory", 1], ["policy-cpu", "arch-boot", 1]]; + const edges = links.map(([from, to, count]) => { + const edge: GraphData["edges"][number] = { from, to, label: `${count} import links`, kind: "import" }; + bindDrawingWeight(edge, count); + return edge; + }); + return { kind: "graph", title: "Concepts", query: null, roots: [], caveats: [], truncated: false, nodes, edges }; +} +const geometry = (layout: NativeGraphLayout) => layout.dots.map(dot => [dot.node.id, dot.x, dot.y]).sort(); +/** Clockwise from the top around `center`, the same reading order as the radial rings. */ +const clockwise = (layout: NativeGraphLayout, center: string) => { + const hub = layout.dots.find(dot => dot.node.id === center)!; + const angle = (dot: NativeGraphLayout["dots"][number]) => (Math.atan2(dot.y - hub.y, dot.x - hub.x) + Math.PI * 2.5) % (Math.PI * 2); + return layout.dots.filter(dot => dot !== hub).sort((a, b) => angle(a) - angle(b)).map(dot => dot.node.id); +}; +/** Territories never imply membership: member centers inside, every other dot center and territory center outside. */ +function expectStrictTerritories(layout: NativeGraphLayout): void { + const reach = (territory: NativeGraphTerritory, x: number, y: number) => ((x - territory.x) / territory.rx) ** 2 + ((y - territory.y) / territory.ry) ** 2; + for (const territory of layout.territories) { + expect(territory.x - territory.rx).toBeGreaterThanOrEqual(2 - 1e-9); + expect(territory.x + territory.rx).toBeLessThanOrEqual(layout.width - 2 + 1e-9); + expect(territory.y - territory.ry).toBeGreaterThanOrEqual(2 - 1e-9); + expect(territory.y + territory.ry).toBeLessThanOrEqual(layout.height - 2 + 1e-9); + expect(Math.PI * territory.rx * territory.ry).toBeLessThanOrEqual(layout.width * layout.height * .55); + expect(territory.color).toEqual(conceptPalette(territory.key)); + expect(territory.members.length).toBeGreaterThan(1); + for (const dot of layout.dots) { + if (territory.members.includes(dot.node.id)) expect(reach(territory, dot.x, dot.y)).toBeLessThanOrEqual(1); + else expect(reach(territory, dot.x, dot.y)).toBeGreaterThanOrEqual(1); + } + for (const other of layout.territories) if (other !== territory) expect(reach(territory, other.x, other.y)).toBeGreaterThanOrEqual(1); + } +} + +for (const choice of choices) for (const size of sizes) test(`${choice}/${size} is deterministic, bounded and retains disconnected, cyclic and directed evidence`, () => { + const data = fixture(); + data.roots.push("n22"); + data.edges = [ + ...data.edges.filter(edge => Number(edge.to.slice(1)) < 20), + { from: "n01", to: "n00", label: "imports" }, + { from: "n01", to: "n02", label: "calls" }, { from: "n02", to: "n01", label: "calls" }, + { from: "n00", to: "n00", label: "calls" }, { from: "n00", to: "n00", label: "imports" }, + { from: "n20", to: "n21", label: "calls" }, { from: "n21", to: "n20", label: "calls" }, + ]; + const original = structuredClone(data), layout = nativeGraphLayout(data, size, choice); + expect(nativeGraphLayout(data, size, choice)).toBe(layout); + expect(nativeGraphLayout(structuredClone(data), size, choice)).toEqual(layout); + expect(layout.dots.map(dot => dot.node.id).sort()).toEqual(data.nodes.map(node => node.id).sort()); + expect(layout.dots.filter(dot => dot.root).map(dot => dot.node.id).sort()).toEqual([...data.roots].sort()); + expect(layout.routes.flatMap(route => route.link.indices).sort((a, b) => a - b)).toEqual(data.edges.map((_, index) => index)); + for (const dot of layout.dots) { + expect(Number.isFinite(dot.x) && Number.isFinite(dot.y)).toBe(true); + expect(dot.x).toBeGreaterThanOrEqual(12); + expect(dot.x).toBeLessThanOrEqual(layout.width - 12); + expect(dot.y).toBeGreaterThanOrEqual(12); + expect(dot.y).toBeLessThanOrEqual(layout.height - 12); + } + for (const route of layout.routes) { + expect(route.points.length).toBeGreaterThan(1); + for (const [x, y] of route.points) { + expect(Number.isFinite(x) && Number.isFinite(y)).toBe(true); + expect(x).toBeGreaterThanOrEqual(0); expect(x).toBeLessThanOrEqual(layout.width); + expect(y).toBeGreaterThanOrEqual(0); expect(y).toBeLessThanOrEqual(layout.height); + } + const from = layout.dots.find(dot => dot.node.id === route.link.from)!, to = layout.dots.find(dot => dot.node.id === route.link.to)!; + expect(Math.hypot(route.points[0][0] - from.x, route.points[0][1] - from.y)).toBeLessThan(14); + expect(Math.hypot(route.points.at(-1)![0] - to.x, route.points.at(-1)![1] - to.y)).toBeLessThan(14); + } + if (size === "compact") for (let a = 0; a < layout.dots.length; a++) for (let b = a + 1; b < layout.dots.length; b++) { + expect(Math.hypot(layout.dots[a].x - layout.dots[b].x, layout.dots[a].y - layout.dots[b].y)).toBeGreaterThanOrEqual(24.6); + } + const forward = layout.routes.find(route => route.link.from === "n00" && route.link.to === "n01")!; + const reverse = layout.routes.find(route => route.link.from === "n01" && route.link.to === "n00")!; + expect(forward.points).not.toEqual([...reverse.points].reverse()); + const self = layout.routes.filter(route => route.link.from === "n00" && route.link.to === "n00"); + expect(self).toHaveLength(2); + expect(self[0].points).not.toEqual(self[1].points); + expect(data).toEqual(original); +}); + +test("compact force fills the canvas and separates persistent catalog rings without changing hit targets", () => { + const data = fixture(), layout = nativeGraphLayout(data, "compact", "force"); + const xs = layout.dots.map(dot => dot.x), ys = layout.dots.map(dot => dot.y); + expect(Math.max(...xs) - Math.min(...xs)).toBeGreaterThan(200); + expect(Math.max(...ys) - Math.min(...ys)).toBeGreaterThan(110); + for (let a = 0; a < layout.dots.length; a++) for (let b = a + 1; b < layout.dots.length; b++) { + expect(Math.hypot(layout.dots[a].x - layout.dots[b].x, layout.dots[a].y - layout.dots[b].y)).toBeGreaterThanOrEqual(25.7); + } + const pending = { ...data, nodes: data.nodes.map(node => ({ ...node, indexing: "failed" as const, indexingError: "Provider unavailable" })) }; + expect(nativeGraphLayout(pending, "compact", "force").dots.map(({ x, y }) => [x, y])).toEqual(layout.dots.map(({ x, y }) => [x, y])); +}); + +test("layout cache distinguishes the actual layout and size, and the default is the concept map", () => { + expect(DEFAULT_GRAPH_APPEARANCE.layout).toBe("map"); + const data = lexical(), baseline = nativeGraphLayout(data, "overview"); + expect(nativeGraphLayout(data, "overview", "map")).toBe(baseline); + for (const choice of choices.filter(choice => choice !== "map")) { + const changed = nativeGraphLayout(data, "overview", choice); + expect(changed).not.toBe(baseline); + expect(changed.dots.map(({ x, y }) => [x, y])).not.toEqual(baseline.dots.map(({ x, y }) => [x, y])); + expect(nativeGraphLayout(data, "overview", choice)).toBe(changed); + } + const compact = nativeGraphLayout(data, "compact"); + expect(compact).not.toBe(baseline); + expect(nativeGraphLayout(data, "compact", "map")).toBe(compact); +}); + +for (const size of sizes) test(`map/${size} places related nodes nearer, stays bounded and ignores input order and display metadata`, () => { + const data = lexical(); + data.edges.push({ from: "CacheEntryRead", to: "QueryExecutionRun", label: "calls" }, { from: "CacheEntryStore", to: "CacheEntryStore", label: "calls" }); + const layout = nativeGraphLayout(data, size, "map"); + expect(geometry(layout)).not.toEqual(geometry(nativeGraphLayout(data, size, "force"))); + let near = 0, nearPairs = 0, far = 0, farPairs = 0; + for (let a = 0; a < layout.dots.length; a++) for (let b = a + 1; b < layout.dots.length; b++) { + const p = layout.dots[a], q = layout.dots[b], distance = Math.hypot(p.x - q.x, p.y - q.y); + expect(distance).toBeGreaterThanOrEqual(p.radius + q.radius + 10 - .2); + if (p.node.id.startsWith("CacheEntry") === q.node.id.startsWith("CacheEntry")) { near += distance; nearPairs++; } else { far += distance; farPairs++; } + } + expect(near / nearPairs).toBeLessThan(far / farPairs / 1.5); + for (const dot of layout.dots) { + expect(dot.x).toBeGreaterThanOrEqual(12); expect(dot.x).toBeLessThanOrEqual(layout.width - 12); + expect(dot.y).toBeGreaterThanOrEqual(12); expect(dot.y).toBeLessThanOrEqual(layout.height - 12); + } + expect(layout.routes.flatMap(route => route.link.indices).sort((a, b) => a - b)).toEqual(data.edges.map((_, index) => index)); + const changed = structuredClone(data); + changed.nodes.reverse(); changed.edges.reverse(); + for (const node of changed.nodes) { + node.label = "Different label"; node.detail = "Different detail"; node.indexing = "failed"; node.indexingError = "Provider unavailable"; + } + expect(geometry(nativeGraphLayout(changed, size, "map"))).toEqual(geometry(layout)); + expect(nativeGraphLayout(structuredClone(data), size, "map")).toEqual(layout); +}); + +test("concept map orientation is canonical: the heaviest node's side is on the left", () => { + for (const [heavy, left, right] of [["CacheEntryEvict", "CacheEntry", "QueryExecution"], ["QueryExecutionRun", "QueryExecution", "CacheEntry"]]) { + const data = lexical(); + for (const node of data.nodes) bindDrawingWeight(node, node.id === heavy ? 9 : 1); + const layout = nativeGraphLayout(data, "overview", "map"); + const mean = (prefix: string) => layout.dots.filter(dot => dot.node.id.startsWith(prefix)).reduce((sum, dot) => sum + dot.x, 0) / 4; + expect(mean(left)).toBeLessThan(mean(right)); + } +}); + +test("concept map falls back to force geometry without term or family evidence", () => { + for (const size of sizes) { + const data = fixture(12), map = nativeGraphLayout(data, size, "map"); + expect(map).toEqual(nativeGraphLayout(data, size, "force")); + expect(nativeGraphLayout(data, size, "map")).toBe(map); + } + // Identical vectors make every pair equally similar, so the map has no shape of its own. + const same = lexical(); + for (const node of same.nodes) node.path = "cache/entry/CacheEntry.ts"; + expect(nativeGraphLayout(same, "overview", "map")).toEqual(nativeGraphLayout(same, "overview", "force")); + const single = lexical(); + single.nodes = single.nodes.slice(0, 1); single.edges = []; + expect(nativeGraphLayout(single, "overview", "map")).toEqual(nativeGraphLayout(single, "overview", "force")); +}); + +test("Layers ranks the condensation graph, keeping a cycle together rather than inventing a tree", () => { + const data = fixture(4); + data.edges = [{ from: "n00", to: "n01", label: "calls" }, { from: "n01", to: "n00", label: "calls" }, + { from: "n01", to: "n02", label: "calls" }]; + const layout = nativeGraphLayout(data, "overview", "layers"), byId = new Map(layout.dots.map(dot => [dot.node.id, dot])); + expect(byId.get("n00")!.x).toBe(byId.get("n01")!.x); + expect(byId.get("n02")!.x).toBeGreaterThan(byId.get("n01")!.x); + expect(byId.has("n03")).toBe(true); +}); + +test("Layers removes a two-edge crossing without changing directed ranks", () => { + const data = fixture(4); + data.edges = [{ from: "n00", to: "n03", label: "calls" }, { from: "n01", to: "n02", label: "calls" }]; + const layout = nativeGraphLayout(data, "overview", "layers"), byId = new Map(layout.dots.map(dot => [dot.node.id, dot])); + const sourceOrder = byId.get("n00")!.y - byId.get("n01")!.y; + const targetOrder = byId.get("n03")!.y - byId.get("n02")!.y; + expect(sourceOrder * targetOrder).toBeGreaterThan(0); + expect(byId.get("n00")!.x).toBe(byId.get("n01")!.x); + expect(byId.get("n02")!.x).toBe(byId.get("n03")!.x); + expect(byId.get("n03")!.x).toBeGreaterThan(byId.get("n00")!.x); + expect(layout.routes.flatMap(route => route.link.indices).sort()).toEqual([0, 1]); +}); + +test("Layers crossing sweeps keep cycle members adjacent within one rank", () => { + const data = fixture(6); + data.edges = [ + { from: "n00", to: "n01", label: "calls" }, { from: "n01", to: "n00", label: "calls" }, + { from: "n02", to: "n03", label: "calls" }, { from: "n03", to: "n02", label: "calls" }, + { from: "n01", to: "n05", label: "calls" }, { from: "n02", to: "n04", label: "calls" }, + ]; + const layout = nativeGraphLayout(data, "overview", "layers"), byId = new Map(layout.dots.map(dot => [dot.node.id, dot])); + for (const group of [["n00", "n01"], ["n02", "n03"]]) { + const members = group.map(id => byId.get(id)!); + expect(members[0].x).toBe(members[1].x); + const between = layout.dots.filter(dot => dot.x === members[0].x && dot.y > Math.min(...members.map(dot => dot.y)) && dot.y < Math.max(...members.map(dot => dot.y))); + expect(between).toHaveLength(0); + } +}); + +test("Components separates recorded connected sets without inventing a hierarchy", () => { + const data = fixture(8); + data.edges = [ + { from: "n00", to: "n01", label: "calls" }, { from: "n01", to: "n02", label: "calls" }, { from: "n02", to: "n00", label: "calls" }, + { from: "n03", to: "n04", label: "imports" }, { from: "n04", to: "n05", label: "imports" }, + ]; + const layout = nativeGraphLayout(data, "overview", "components"); + const groups = [["n00", "n01", "n02"], ["n03", "n04", "n05"], ["n06"], ["n07"]].map(ids => { + const points = layout.dots.filter(dot => ids.includes(dot.node.id)); + return { left: Math.min(...points.map(dot => dot.x)), right: Math.max(...points.map(dot => dot.x)), + top: Math.min(...points.map(dot => dot.y)), bottom: Math.max(...points.map(dot => dot.y)) }; + }); + for (let a = 0; a < groups.length; a++) for (let b = a + 1; b < groups.length; b++) { + const p = groups[a], q = groups[b]; + expect(p.right < q.left || q.right < p.left || p.bottom < q.top || q.bottom < p.top).toBe(true); + } + expect(layout.dots).toHaveLength(8); + expect(layout.routes.flatMap(route => route.link.indices).sort()).toEqual([0, 1, 2, 3, 4]); +}); + +test("Radial rings isolated nodes around one dominant map instead of a detached row", () => { + const data = fixture(10); + data.edges = data.edges.filter(edge => Number(edge.to.slice(1)) < 7); + const layout = nativeGraphLayout(data, "overview", "radial"), byId = new Map(layout.dots.map(dot => [dot.node.id, dot])); + const root = byId.get("n00")!, isolated = ["n07", "n08", "n09"].map(id => byId.get(id)!); + expect(new Set(isolated.map(dot => dot.y)).size).toBeGreaterThan(1); + for (const axis of ["x", "y"] as const) { + expect(Math.min(...isolated.map(dot => dot[axis]))).toBeLessThan(root[axis]); + expect(Math.max(...isolated.map(dot => dot[axis]))).toBeGreaterThan(root[axis]); + } + const reversed = structuredClone(data); + reversed.nodes.reverse(); reversed.edges.reverse(); + expect(nativeGraphLayout(reversed, "overview", "radial").dots.map(dot => [dot.node.id, dot.x, dot.y]).sort()) + .toEqual(layout.dots.map(dot => [dot.node.id, dot.x, dot.y]).sort()); +}); + +for (const choice of topologyChoices) test(`${choice} ignores input ordering and non-topology metadata`, () => { + const data = fixture(12), baseline = nativeGraphLayout(data, "overview", choice); + const changed = structuredClone(data); + changed.nodes.reverse(); changed.edges.reverse(); + for (const node of changed.nodes) { + node.label = "Different label"; node.path = "another/path.ts"; node.category = "directory"; + node.detail = "Different detail"; node.indexing = "failed"; node.indexingError = "Provider unavailable"; + } + const layout = nativeGraphLayout(changed, "overview", choice); + expect(layout.dots.map(dot => [dot.node.id, dot.x, dot.y]).sort()).toEqual(baseline.dots.map(dot => [dot.node.id, dot.x, dot.y]).sort()); +}); + +for (const choice of ["layers", "components"] as const) test(`${choice} handles empty and fully disconnected evidence`, () => { + const empty = fixture(0); empty.roots = []; + expect(nativeGraphLayout(empty, "compact", choice).dots).toEqual([]); + expect(nativeGraphLayout(empty, "compact", choice).routes).toEqual([]); + const disconnected = fixture(64); disconnected.edges = []; + const layout = nativeGraphLayout(disconnected, "compact", choice); + expect(layout.dots).toHaveLength(64); + expect(layout.routes).toEqual([]); + expect(layout.dots.every(dot => Number.isFinite(dot.x) && Number.isFinite(dot.y))).toBe(true); +}); + +for (const choice of choices) test(`${choice} handles a singleton self-loop and the full native node budget`, () => { + const singleton = fixture(1); + singleton.edges = [{ from: "n00", to: "n00", label: "calls" }]; + const one = nativeGraphLayout(singleton, "compact", choice); + expect(one.dots).toHaveLength(1); expect(one.routes).toHaveLength(1); + expect(new Set(one.routes[0].points.map(point => point.join(","))).size).toBeGreaterThan(2); + const data = fixture(64), full = nativeGraphLayout(data, "compact", choice); + expect(full.dots).toHaveLength(64); expect(full.routes).toHaveLength(63); + for (const dot of full.dots) { + expect(dot.x).toBeGreaterThanOrEqual(12); expect(dot.x).toBeLessThanOrEqual(full.width - 12); + expect(dot.y).toBeGreaterThanOrEqual(12); expect(dot.y).toBeLessThanOrEqual(full.height - 12); + } +}); + +test("inline force uses the wide canvas with a bounded stretch", () => { + const data = fixture(), simulation = [...layoutGraph(data).positions.values()], layout = nativeGraphLayout(data, "inline", "force"); + const extent = (values: number[]) => Math.max(...values) - Math.min(...values); + const sx = extent(layout.dots.map(dot => dot.x)) / extent(simulation.map(point => point.x)); + const sy = extent(layout.dots.map(dot => dot.y)) / extent(simulation.map(point => point.y)); + expect(sx).toBeGreaterThan(sy); + expect(sx / sy).toBeLessThanOrEqual(1.45 + 1e-9); +}); + +test("bound node weights give monotonic dot radii that never overlap in compact", () => { + const data = fixture(); + data.nodes.forEach((node, index) => { if (index) bindDrawingWeight(node, index); }); + for (const choice of choices) { + const layout = nativeGraphLayout(data, "compact", choice), ordered = [...layout.dots].sort((a, b) => (a.weight ?? 0) - (b.weight ?? 0)); + expect(ordered[0].node.id).toBe("n00"); + expect(ordered[0].radius).toBe(3.5); + expect("weight" in ordered[0]).toBe(false); + expect(ordered.at(-1)).toMatchObject({ radius: 9.5, weight: 23 }); + for (let at = 1; at < ordered.length; at++) expect(ordered[at].radius).toBeGreaterThanOrEqual(ordered[at - 1].radius); + expect(ordered[1].radius).toBeLessThan(ordered.at(-1)!.radius); + for (const dot of layout.dots) expect(dot.radius * 2 % 1).toBe(0); + for (let a = 0; a < layout.dots.length; a++) for (let b = a + 1; b < layout.dots.length; b++) { + const p = layout.dots[a], q = layout.dots[b]; + expect(Math.hypot(p.x - q.x, p.y - q.y)).toBeGreaterThanOrEqual(p.radius + q.radius); + } + } +}); + +test("route tiers appear only for bound edge weights and the heaviest route gets tier 4", () => { + const plain = fixture(6); + for (const choice of choices) { + const layout = nativeGraphLayout(plain, "overview", choice); + expect(layout.routes.some(route => "tier" in route)).toBe(false); + expect(layout.dots.some(dot => "weight" in dot)).toBe(false); + } + const weighted = fixture(6); + weighted.edges.push({ from: "n00", to: "n05", label: "calls" }); + [1, 2, 4, 8, 3, 9].forEach((weight, index) => bindDrawingWeight(weighted.edges[index], weight)); + for (const choice of choices) { + const tiers = Object.fromEntries(nativeGraphLayout(weighted, "overview", choice).routes.map(route => [route.link.to, route.tier])); + expect(tiers).toEqual({ n01: 1, n02: 2, n03: 3, n04: 4, n05: 4 }); + } +}); + +for (const size of sizes) test(`${size} lexical territories exist only for force and the concept map, stay inside the canvas and cover only their members`, () => { + for (const choice of ["force", "map"] as const) { + const data = lexical(), layout = nativeGraphLayout(data, size, choice); + if (size !== "compact" || choice === "map") expect(layout.territories.length).toBeGreaterThan(0); + expectStrictTerritories(layout); + for (const territory of layout.territories) { + expect(new Set(territory.members.map(id => id.startsWith("CacheEntry"))).size).toBe(1); + expect(territory.kind).toBe("lexical"); + expect(territory.title).toBe(`${territory.label} · shared terms\nLexical identifier similarity`); + } + expect(nativeGraphLayout(structuredClone(data), size, choice).territories).toEqual(layout.territories); + for (const topology of topologyChoices) expect(nativeGraphLayout(data, size, topology).territories).toEqual([]); + const small = lexical(); + small.nodes = small.nodes.slice(0, 3); small.edges = small.edges.slice(0, 2); + expect(nativeGraphLayout(small, size, choice).territories).toEqual([]); + } +}); + +for (const size of sizes) test(`${size} doc concept families replace lexical territories in force and the concept map`, () => { + for (const choice of ["force", "map"] as const) { + const layout = nativeGraphLayout(concepts(), size, choice); + expect(layout.territories.length).toBeGreaterThan(0); + expectStrictTerritories(layout); + for (const territory of layout.territories) { + const architecture = territory.key === "family:docs/architecture"; + expect(territory.kind).toBe("family"); + expect(territory.label).toBe(architecture ? "Architecture" : "Policy"); + expect(territory.members).toEqual(architecture ? ["arch-boot", "arch-dispatch", "arch-memory"] : ["policy-compat", "policy-cpu", "policy-threat"]); + expect(territory.terms).toEqual([]); + expect(territory.title).toBe(`${territory.label} · concept family\n3 concepts from docs in ${architecture ? "docs/architecture" : "docs/policy"}`); + } + const reordered = concepts(); + reordered.nodes.reverse(); reordered.edges.reverse(); + const again = nativeGraphLayout(reordered, size, choice); + expect(geometry(again)).toEqual(geometry(layout)); + expect(again.territories).toEqual(layout.territories); + } + for (const topology of topologyChoices) expect(nativeGraphLayout(concepts(), size, topology).territories).toEqual([]); +}); + +test("concept map keeps doc concept families together and apart from other families", () => { + const layout = nativeGraphLayout(concepts(), "overview", "map"), byId = new Map(layout.dots.map(dot => [dot.node.id, dot])); + const spread = (ids: string[]) => { + const dots = ids.map(id => byId.get(id)!), x = dots.reduce((sum, dot) => sum + dot.x, 0) / dots.length, y = dots.reduce((sum, dot) => sum + dot.y, 0) / dots.length; + return { x, y, radius: Math.max(...dots.map(dot => Math.hypot(dot.x - x, dot.y - y))) }; + }; + const architecture = spread(["arch-boot", "arch-dispatch", "arch-memory"]), policy = spread(["policy-compat", "policy-cpu", "policy-threat"]); + expect(Math.hypot(architecture.x - policy.x, architecture.y - policy.y)).toBeGreaterThan(architecture.radius + policy.radius); +}); + +for (const size of ["inline", "overview"] as const) test(`map/${size} reserves family islands before placing similar ungrouped hubs`, () => { + const data = concepts(); + // A three-concept family and a pair, surrounded by high-weight structural nodes with the same terms. + data.nodes = data.nodes.filter(node => node.id !== "policy-threat"); + data.edges = data.edges.filter(edge => edge.from !== "policy-threat" && edge.to !== "policy-threat"); + for (let index = 0; index < 16; index++) { + const node: GraphData["nodes"][number] = { id: `structure-${index}`, label: `Structure ${index}`, category: "group", hit: null, detail: null }; + bindTermVector(node, new Map([["vmx", .8], ["policy", .6]])); + bindDrawingWeight(node, index ? 12 : 200); + data.nodes.push(node); + for (const to of ["arch-boot", "arch-dispatch", "arch-memory", "policy-compat", "policy-cpu"]) data.edges.push({ from: node.id, to, label: "imports", kind: "import" }); + } + const layout = nativeGraphLayout(data, size, "map"); + expect(layout.territories.map(territory => territory.key).sort()).toEqual(["family:docs/architecture", "family:docs/policy"]); + expectStrictTerritories(layout); + expect(layout.routes.flatMap(route => route.link.indices).sort((a, b) => a - b)).toEqual(data.edges.map((_, index) => index)); + const reordered = { ...data, nodes: [...data.nodes].reverse(), edges: [...data.edges].reverse() }; + expect(geometry(nativeGraphLayout(reordered, size, "map"))).toEqual(geometry(layout)); + expect(nativeGraphLayout(reordered, size, "map").territories).toEqual(layout.territories); +}); + +test("Radial orders each ring by the concept map angle so that related nodes sit side by side", () => { + const leaves = ["CacheEntryStore", "QueryExecutionPlan", "CacheEntryRead", "QueryExecutionRun", "CacheEntryEvict", "QueryExecutionParse", "CacheEntryWrite", "QueryExecutionExplain"]; + const data: GraphData = { kind: "graph", title: "Radial", query: null, roots: ["hub"], caveats: [], truncated: false, + nodes: [{ id: "hub", label: "hub", category: "file", path: "src/hub.ts", hit: null, detail: null }, ...leaves.map((label, index) => ({ id: `n${index}`, label, + category: "file" as const, path: `${label.startsWith("Cache") ? "cache/entry" : "query/execution"}/${label}.ts`, hit: null, detail: null }))], + edges: leaves.map((_, index) => ({ from: "hub", to: `n${index}`, label: "calls" })) }; + const layout = nativeGraphLayout(data, "overview", "radial"), ring = clockwise(layout, "hub"); + const family = (id: string) => leaves[Number(id.slice(1))].startsWith("Cache"); + // Without the concept map the ring alternates families in ID order; with it each family forms one arc. + expect(ring.filter((id, index) => family(id) !== family(ring[(index + 1) % ring.length]))).toHaveLength(2); + const reversed = structuredClone(data); + reversed.nodes.reverse(); reversed.edges.reverse(); + expect(geometry(nativeGraphLayout(reversed, "overview", "radial"))).toEqual(geometry(layout)); + // Without term evidence the ring keeps the topology order, read from the first ID. + const plain = fixture(9), order = clockwise(nativeGraphLayout(plain, "overview", "radial"), "n00"), start = order.indexOf("n01"); + expect([...order.slice(start), ...order.slice(0, start)]).toEqual(plain.nodes.slice(1).map(node => node.id)); +}); + +for (const size of sizes) for (const choice of ["force", "map"] as const) test(`${choice}/${size} territories drop rather than cover unrelated nodes linked into a family`, () => { + const data = lexical(); + for (let index = 0; index < 6; index++) { + data.nodes.push({ id: `n${index}`, label: `Node ${index}`, category: "file", path: `src/n${index}.ts`, hit: null, detail: null }); + data.edges.push({ from: index < 3 ? "CacheEntryStore" : "QueryExecutionPlan", to: `n${index}`, label: "calls" }); + } + data.edges.push({ from: "CacheEntryRead", to: "QueryExecutionRun", label: "calls" }); + const layout = nativeGraphLayout(data, size, choice); + expectStrictTerritories(layout); + expect(nativeGraphLayout(structuredClone(data), size, choice).territories).toEqual(layout.territories); +}); diff --git a/plugins/semctx/adapters/omp/graph-native-layout.ts b/plugins/semctx/adapters/omp/graph-native-layout.ts new file mode 100644 index 0000000..769c56e --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-native-layout.ts @@ -0,0 +1,606 @@ +import type { GraphData } from "./graph"; +import { DEFAULT_GRAPH_APPEARANCE, type GraphAppearance } from "./graph-appearance"; +import { bindTermVector, centroid, cosine, graphEmbedding, semanticFamilies, semanticMap, similarityMap } from "./graph-data/embedding.js"; +import { bindDrawingWeight, drawingWeight, edgeGeometry, layoutGraph, relationKind, representativeLinks, sharedConceptFamilies, type GraphPosition, type GraphRelationKind, type SharedConceptFamily } from "./graph-data/layout.js"; +import { conceptPalette } from "./graph-data/palette.js"; +import type { GraphPreviewSize } from "./graph-preview"; +import { compactTooltip } from "./tooltip-text"; + +export type NativeGraphPoint = readonly [number, number]; +export interface NativeGraphDot { + node: GraphData["nodes"][number]; + x: number; + y: number; + /** 3.5 to 9.5 px in .5 px steps. */ + radius: number; + degree: number; + root: boolean; + /** Bound drawing weight, for example the member count of an overview group. */ + weight?: number; +} +export type NativeGraphLink = Omit & { + kind: GraphRelationKind; + count: number; + indices: number[]; + index: number; +}; +export interface NativeGraphRoute { + link: NativeGraphLink; + points: NativeGraphPoint[]; + /** Weight relative to the heaviest route, only when edge drawing weights are bound. 4 is the heaviest. */ + tier?: 1 | 2 | 3 | 4; +} +/** + * Family around its member dots: a doc concept family, or else a lexical identifier family. + * It is not the server's embedding and never moves a dot. + */ +export interface NativeGraphTerritory { + key: string; + kind: "family" | "lexical"; + label: string; + /** Hover text for the label chip, already compact. */ + title?: string; + /** Shared identifier terms of a lexical family; doc concept families have none. */ + terms: string[]; + members: string[]; + x: number; + y: number; + rx: number; + ry: number; + /** Mean pairwise cosine of the member term vectors. */ + cohesion: number; + color: { light: string; dark: string }; +} +export interface NativeGraphLayout { + width: number; + height: number; + dots: NativeGraphDot[]; + routes: NativeGraphRoute[]; + /** Force and concept map only; the other layouts return none. */ + territories: NativeGraphTerritory[]; +} +interface NativeCurve { + from: readonly number[]; + to: readonly number[]; + control: number[]; + self: boolean; +} +const layouts = new WeakMap>(); +const conceptMaps = new WeakMap | null>(); +const compare = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0; + +/** Eigenvectors have no sign. The first non-central node in weight/ID order fixes each axis. */ +function orientMap(mapped: ReadonlyMap, rank: readonly string[]): Map { + const sign = (axis: 0 | 1) => { + const lead = rank.find(id => Math.abs(mapped.get(id)![axis]) > 1e-6); + return lead !== undefined && mapped.get(lead)![axis] > 0 ? -1 : 1; + }; + const sx = sign(0), sy = sign(1); + return new Map([...mapped].map(([id, point]): [string, NativeGraphPoint] => [id, [point[0] * sx, point[1] * sy]])); +} + +/** + * Concept similarity S: shared identifier terms .6, recorded links .3 and one shared doc concept family .1. + * Classical MDS on the distance 1 − S places the nodes; the principal axis is horizontal. + * The heaviest node, then the first ID, takes the left and top side of each axis. + * Undefined without term or family evidence, or when all pairs are equally similar. + */ +function conceptMap(data: GraphData): ReadonlyMap | undefined { + const cached = conceptMaps.get(data); + if (cached !== undefined) return cached ?? undefined; + const ids = [...new Set(data.nodes.map(node => node.id))].sort(compare), n = ids.length, at = new Map(ids.map((id, index) => [id, index])); + const { vectors, informative } = graphEmbedding(data), shared = sharedConceptFamilies(data.nodes); + let result: Map | undefined; + if (n >= 2 && n <= 512 && (informative || shared.length)) { + const family = new Map(shared.flatMap(item => item.members.map(id => [id, item.key] as const))); + // Links count in both directions. Project membership repeats the hierarchy, so it stays out. + const pairs = new Map(); + for (const edge of data.edges) { + const a = at.get(edge.from), b = at.get(edge.to); + if (a === undefined || b === undefined || a === b || relationKind(edge) === "association") continue; + const pair = Math.min(a, b) * n + Math.max(a, b), weights = pairs.get(pair); + if (weights) weights.push(drawingWeight(edge) ?? 1); else pairs.set(pair, [drawingWeight(edge) ?? 1]); + } + // Sorted sums keep the totals independent of the edge order. + const affinity = new Map([...pairs].map(([pair, weights]) => [pair, Math.log1p(weights.sort((x, y) => x - y).reduce((sum, weight) => sum + weight, 0))])); + const strongest = Math.max(0, ...affinity.values()), similarity = new Float64Array(n * n); + let low = Infinity, high = -Infinity; + for (let i = 0; i < n; i++) { + similarity[i * n + i] = 1; + for (let j = i + 1; j < n; j++) { + const value = .6 * cosine(vectors.get(ids[i]), vectors.get(ids[j])) + (strongest ? .3 * (affinity.get(i * n + j) ?? 0) / strongest : 0) + + (family.has(ids[i]) && family.get(ids[i]) === family.get(ids[j]) ? .1 : 0); + similarity[i * n + j] = similarity[j * n + i] = value; + low = Math.min(low, value); high = Math.max(high, value); + } + } + // With S′ = 1 − (1 − S)² / 2, the squared map distance 2 − 2·S′ is (1 − S)². + const mapped = high - low > 1e-9 ? similarityMap(ids, (a, b) => 1 - (1 - similarity[at.get(a)! * n + at.get(b)!]) ** 2 / 2) : undefined; + if (mapped) { + const weights = new Map(data.nodes.map(node => [node.id, drawingWeight(node) ?? 0])); + const rank = [...ids].sort((a, b) => weights.get(b)! - weights.get(a)! || compare(a, b)); + result = orientMap(mapped, rank); + } + } + conceptMaps.set(data, result ?? null); + return result; +} + +/** Geometry-only ordering: no activity, embedding state, concept color or theme. */ +function topology(data: GraphData) { + const ids = data.nodes.map(node => node.id).sort(compare), roots = new Set(data.roots); + const outgoing = new Map(ids.map(id => [id, new Set()])), neighbours = new Map(ids.map(id => [id, new Set()])); + for (const edge of data.edges) { + outgoing.get(edge.from)!.add(edge.to); + neighbours.get(edge.from)!.add(edge.to); neighbours.get(edge.to)!.add(edge.from); + } + const order = (a: string, b: string) => Number(roots.has(b)) - Number(roots.has(a)) || compare(a, b); + const unseen = new Set(ids), components: string[][] = []; + for (const id of [...ids].sort(order)) { + if (!unseen.delete(id)) continue; + const component = [id]; + for (let at = 0; at < component.length; at++) for (const next of [...neighbours.get(component[at])!].sort(compare)) { + if (unseen.delete(next)) component.push(next); + } + components.push(component.sort(order)); + } + return { ids, roots, outgoing, neighbours, components, order }; +} + +/** Radial rings encode undirected distance, not a fabricated dependency hierarchy. */ +function radialPositions(data: GraphData): Map { + const { roots, neighbours, components, order } = topology(data), semantic = conceptMap(data), result = new Map(); + const largest = components.reduce((best, component) => !best || component.length > best.length ? component : best, undefined); + // One dominant map carries the isolated nodes on one more outer ring instead of a detached row. + // That ring encodes no distance: an isolated node has no recorded relation. + const isolated = largest && largest.length >= 3 && components.every(component => component === largest || component.length < largest.length) + ? components.filter(component => component.length === 1).map(([id]) => id).sort(order) : []; + let left = 0, top = 0, rowHeight = 0; + const blocks = components.filter(component => component.length > 1 || !isolated.length).map(component => { + const seeds = component.filter(id => roots.has(id)); + if (!seeds.length) seeds.push([...component].sort((a, b) => neighbours.get(b)!.size - neighbours.get(a)!.size || order(a, b))[0]); + const distance = new Map(seeds.map(id => [id, 0])), queue = [...seeds]; + for (let at = 0; at < queue.length; at++) for (const next of [...neighbours.get(queue[at])!].sort(order)) { + if (!distance.has(next)) { distance.set(next, distance.get(queue[at])! + 1); queue.push(next); } + } + const rings = new Map(); + for (const id of component) { + const depth = distance.get(id)!; + if (!rings.has(depth)) rings.set(depth, []); + rings.get(depth)!.push(id); + } + // Breadth-first depths are contiguous, so the ring index is the undirected distance. + const levels = [...rings].sort(([a], [b]) => a - b).map(([, ring]) => ring.sort(order)); + if (component === largest && isolated.length) levels.push(isolated); + if (semantic) { + // Related nodes sit side by side: each ring follows the concept map angle around the seeds, clockwise from the top. + const cx = seeds.reduce((sum, id) => sum + semantic.get(id)![0], 0) / seeds.length, cy = seeds.reduce((sum, id) => sum + semantic.get(id)![1], 0) / seeds.length; + const angles = new Map(levels.flat().map(id => [id, (Math.atan2(semantic.get(id)![1] - cy, semantic.get(id)![0] - cx) + Math.PI * 2.5) % (Math.PI * 2)])); + for (const ring of levels) ring.sort((a, b) => angles.get(a)! - angles.get(b)! || order(a, b)); + } + let radius = 0; + const points = new Map(); + levels.forEach((ring, depth) => { + radius = depth === 0 && ring.length === 1 ? 0 : Math.max(radius + 42, ring.length * 30 / (2 * Math.PI)); + ring.forEach((id, index) => { + const angle = -Math.PI / 2 + index / ring.length * Math.PI * 2; + points.set(id, [Math.cos(angle) * radius, Math.sin(angle) * radius]); + }); + }); + return { points, radius: radius + 24, extent: Math.max(48, radius * 2 + 48) }; + }); + const targetWidth = Math.sqrt(blocks.reduce((area, block) => area + block.extent ** 2, 0) * 1.6); + for (const block of blocks) { + if (left && left + block.extent > targetWidth) { left = 0; top += rowHeight + 24; rowHeight = 0; } + for (const [id, point] of block.points) result.set(id, [left + block.radius + point[0], top + block.radius + point[1]]); + left += block.extent + 24; rowHeight = Math.max(rowHeight, block.extent); + } + return result; +} + +/** Blocks encode weak connectivity only. Rows within each block have no directed rank. */ +function componentPositions(data: GraphData): Map { + const { neighbours, components, order } = topology(data), result = new Map(); + const blocks = components.sort((a, b) => b.length - a.length || order(a[0], b[0])).map(component => { + const first = [...component].sort((a, b) => neighbours.get(b)!.size - neighbours.get(a)!.size || order(a, b))[0]; + const seen = new Set([first]), queue = [first]; + for (let at = 0; at < queue.length; at++) for (const next of [...neighbours.get(queue[at])!].sort(order)) { + if (!seen.has(next)) { seen.add(next); queue.push(next); } + } + const columns = Math.min(queue.length, Math.ceil(Math.sqrt(queue.length * 1.6))), rows = Math.ceil(queue.length / columns); + return { queue, columns, width: (columns - 1) * 36 + 48, height: (rows - 1) * 36 + 48 }; + }); + const targetWidth = Math.sqrt(blocks.reduce((area, block) => area + block.width * block.height, 0) * 1.6); + let left = 0, top = 0, rowHeight = 0; + for (const block of blocks) { + if (left && left + block.width > targetWidth) { left = 0; top += rowHeight + 36; rowHeight = 0; } + block.queue.forEach((id, at) => result.set(id, [left + 24 + at % block.columns * 36, top + 24 + Math.floor(at / block.columns) * 36])); + left += block.width + 36; rowHeight = Math.max(rowHeight, block.height); + } + return result; +} + +/** Directed SCC condensation gives cycles one rank and retains every disconnected node. */ +function layeredPositions(data: GraphData): Map { + const { ids, outgoing, order } = topology(data), indices = new Map(), low = new Map(); + const stack: string[] = [], stacked = new Set(), components: string[][] = []; + function visit(id: string): void { + indices.set(id, indices.size); low.set(id, indices.get(id)!); stack.push(id); stacked.add(id); + for (const next of [...outgoing.get(id)!].sort(compare)) { + if (!indices.has(next)) { visit(next); low.set(id, Math.min(low.get(id)!, low.get(next)!)); } + else if (stacked.has(next)) low.set(id, Math.min(low.get(id)!, indices.get(next)!)); + } + if (low.get(id) !== indices.get(id)) return; + const component: string[] = []; + let next: string; + do { next = stack.pop()!; stacked.delete(next); component.push(next); } while (next !== id); + components.push(component.sort(order)); + } + for (const id of ids) if (!indices.has(id)) visit(id); + const groupOf = new Map(components.flatMap((group, index) => group.map(id => [id, index] as const))); + const successors = components.map(() => new Set()), predecessors = components.map(() => new Set()); + const indegree = components.map(() => 0), ranks = components.map(() => 0); + for (const [id, targets] of outgoing) for (const target of targets) { + const from = groupOf.get(id)!, to = groupOf.get(target)!; + if (from !== to && !successors[from].has(to)) { successors[from].add(to); predecessors[to].add(from); indegree[to]++; } + } + const groupOrder = (a: number, b: number) => order(components[a][0], components[b][0]); + const ready = components.map((_, index) => index).filter(index => indegree[index] === 0).sort(groupOrder); + while (ready.length) { + const from = ready.shift()!; + for (const to of [...successors[from]].sort(groupOrder)) { + ranks[to] = Math.max(ranks[to], ranks[from] + 1); + if (--indegree[to] === 0) { ready.push(to); ready.sort(groupOrder); } + } + } + const layers = new Map(); + for (const index of components.keys()) { + if (!layers.has(ranks[index])) layers.set(ranks[index], []); + layers.get(ranks[index])!.push(index); + } + const orderedLayers = [...layers].sort(([a], [b]) => a - b); + for (const [, layer] of orderedLayers) layer.sort(groupOrder); + const centers = new Map(); + function center(layer: number[]): void { + let at = -(layer.reduce((count, group) => count + components[group].length, 0) - 1) / 2; + for (const group of layer) { + centers.set(group, at + (components[group].length - 1) / 2); + at += components[group].length; + } + } + for (const [, layer] of orderedLayers) center(layer); + // Bounded barycenter sweeps reduce crossings using only condensation edges. + // Move whole cycle blocks so every member keeps its directed rank and adjacency. + for (let pass = 0; pass < 4; pass++) for (const forward of [true, false]) { + const neighbours = forward ? predecessors : successors; + for (const [, layer] of forward ? orderedLayers : [...orderedLayers].reverse()) { + const scores = new Map(layer.map(group => { + const linked = [...neighbours[group]]; + return [group, linked.length ? linked.reduce((sum, next) => sum + centers.get(next)!, 0) / linked.length : centers.get(group)!] as const; + })); + layer.sort((a, b) => scores.get(a)! - scores.get(b)! || groupOrder(a, b)); + center(layer); + } + } + const result = new Map(); + for (const [rank, layer] of orderedLayers) for (const group of layer) { + components[group].forEach((id, at) => result.set(id, [rank * 72, (centers.get(group)! + at - (components[group].length - 1) / 2) * 36])); + } + return result; +} + +/** Resolve visual collisions without feeding transient state back into the force simulation. */ +function separate(points: GraphPosition[], left: number, top: number, right: number, bottom: number, layered: boolean): void { + const width = right - left, height = bottom - top; + const base = Math.min(26, Math.sqrt(width * height / Math.max(1, points.length) / 1.5)); + // The gap grows with both radii so that large weighted dots never touch. + const gap = (p: GraphPosition, q: GraphPosition) => Math.max(base, p.radius + q.radius + 10); + const ordered = layered ? [...points].sort((a, b) => a.x - b.x || a.y - b.y || compare(a.node.id, b.node.id)) : undefined; + const clamp = (point: GraphPosition) => { point.x = Math.max(left, Math.min(right, point.x)); point.y = Math.max(top, Math.min(bottom, point.y)); }; + for (let pass = 0; pass < 160; pass++) { + let overlap = 0; + for (let a = 0; a < points.length; a++) for (let b = a + 1; b < points.length; b++) { + const p = points[a], q = points[b], wanted = gap(p, q); + let dx = q.x - p.x, dy = q.y - p.y, distance = Math.hypot(dx, dy); + if (distance >= wanted) continue; + if (layered) { + // Preserve directed rank columns while there is enough vertical room. + const shift = (Math.sqrt(Math.max(0, wanted * wanted - dx * dx)) - Math.abs(dy)) / 2, sign = dy < 0 ? -1 : 1; + p.y -= sign * shift; q.y += sign * shift; overlap = Math.max(overlap, shift); + } else { + if (distance < .001) { const angle = (a * 31 + b * 17) * 2.399963229728653; dx = Math.cos(angle); dy = Math.sin(angle); distance = 1; } + const shift = (wanted - distance) / 2; + p.x -= dx / distance * shift; p.y -= dy / distance * shift; + q.x += dx / distance * shift; q.y += dy / distance * shift; overlap = Math.max(overlap, shift); + } + clamp(p); clamp(q); + } + if (overlap < .01) return; + } + // Dense boundary jams use nearest free slots, still ordered by settled geometry. + // This is a drawing-space packing fallback, not a new relationship or grouping. + let jammed = false; + for (let a = 0; a < points.length && !jammed; a++) for (let b = a + 1; b < points.length; b++) { + if (Math.hypot(points[a].x - points[b].x, points[a].y - points[b].y) < gap(points[a], points[b]) - .2) { jammed = true; break; } + } + if (!jammed) return; + const pitch = Math.max(base, 2 * Math.max(...points.map(point => point.radius)) + 10); + const columns = Math.max(1, Math.floor(width / pitch) + 1), rows = Math.max(1, Math.ceil(points.length / columns)); + const slots: NativeGraphPoint[] = []; + for (let y = 0; y < rows; y++) for (let x = 0; x < columns; x++) slots.push([ + columns === 1 ? (left + right) / 2 : left + x * width / (columns - 1), + rows === 1 ? (top + bottom) / 2 : top + y * height / (rows - 1), + ]); + if (ordered) { + // Overfull ranks wrap through neighbouring columns in directed-rank order. + slots.sort((a, b) => a[0] - b[0] || a[1] - b[1]); + ordered.forEach((point, index) => { [point.x, point.y] = slots[index]; }); + return; + } + for (const point of [...points].sort((a, b) => a.x - b.x || a.y - b.y || compare(a.node.id, b.node.id))) { + let best = 0, distance = Infinity; + for (let at = 0; at < slots.length; at++) { + const candidate = Math.hypot(point.x - slots[at][0], point.y - slots[at][1]); + if (candidate < distance) { distance = candidate; best = at; } + } + [point.x, point.y] = slots.splice(best, 1)[0]; + } +} + +/** The same envelope sizes both a family island and its final territory. */ +function memberEllipse(members: readonly { x: number; y: number; radius: number }[], compact: boolean) { + const x = members.reduce((sum, point) => sum + point.x, 0) / members.length, y = members.reduce((sum, point) => sum + point.y, 0) / members.length; + const rx = Math.max(compact ? 14 : 18, ...members.map(point => Math.abs(point.x - x) + point.radius + 10)); + const ry = Math.max(compact ? 12 : 15, ...members.map(point => Math.abs(point.y - y) + point.radius + 9)); + // Points off both axes need more than their axis extents to fit inside an ellipse. + const grow = Math.max(1, ...members.map(point => Math.hypot((Math.abs(point.x - x) + point.radius) / rx, (Math.abs(point.y - y) + point.radius) / ry))); + return { x, y, rx: rx * grow, ry: ry * grow }; +} + +/** + * A family is one outer-map island, not a weak pairwise hint. Its centroid and the ungrouped nodes form the outer MDS. + * Local MDS keeps the family's terms readable. Packing reserves each family's final territory before any singleton is placed. + */ +function familyPositions(data: GraphData, points: GraphPosition[], families: readonly SharedConceptFamily[], left: number, top: number, right: number, bottom: number): boolean { + if (!families.length) return false; + const { vectors } = graphEmbedding(data), byId = new Map(points.map(point => [point.node.id, point])); + const grouped = new Set(families.flatMap(family => family.members)); + const groups = [ + ...families.map(family => ({ key: JSON.stringify(["family", family.key]), members: family.members.map(id => byId.get(id)!) })), + ...points.filter(point => !grouped.has(point.node.id)).map(point => ({ key: JSON.stringify(["node", point.node.id]), members: [point] })), + ].sort((a, b) => compare(a.key, b.key)); + const groupOf = new Map(groups.flatMap(group => group.members.map(point => [point.node.id, group.key] as const))); + const nodes = groups.map(group => { + const node: GraphData["nodes"][number] = { id: group.key, label: "", category: "group", hit: null, detail: null }; + bindTermVector(node, centroid(group.members.map(point => vectors.get(point.node.id)))); + bindDrawingWeight(node, group.members.reduce((sum, point) => sum + (drawingWeight(point.node) ?? 1), 0)); + return node; + }); + const edges = data.edges.flatMap(edge => { + const from = groupOf.get(edge.from)!, to = groupOf.get(edge.to)!; + if (from === to) return []; + const link = { ...edge, from, to }; + bindDrawingWeight(link, drawingWeight(edge) ?? 1); + return [link]; + }); + const mapped = conceptMap({ ...data, nodes, edges, roots: [] }); + // The heaviest original node fixes the outer orientation, even when it belongs to a larger family. + const rank = [...points].sort((a, b) => (drawingWeight(b.node) ?? 0) - (drawingWeight(a.node) ?? 0) || compare(a.node.id, b.node.id)).map(point => groupOf.get(point.node.id)!); + const outer = mapped && orientMap(mapped, rank); + const islands = groups.map((group, groupIndex) => { + const members = group.members; + if (members.length > 1) { + const local = semanticMap(members.map(point => point.node.id), vectors), extent = Math.max(24, Math.sqrt(members.length) * 16); + const spread = local ? Math.max(...[...local.values()].map(([x, y]) => Math.hypot(x, y))) : 1; + members.forEach((point, index) => { + const position = local?.get(point.node.id), angle = index * Math.PI * 2 / members.length; + // A pair reads down the page, leaving room for both node labels beside its dots. + point.x = position ? position[members.length === 2 ? 1 : 0] / spread * extent : Math.cos(angle) * extent; + point.y = position ? position[members.length === 2 ? 0 : 1] / spread * extent : Math.sin(angle) * extent; + }); + separate(members, -extent, -extent, extent, extent, false); + } else { members[0].x = 0; members[0].y = 0; } + const envelope = memberEllipse(members, false); + // Keep the territory border and label chip clear of neighbouring islands. + const rx = members.length > 1 ? envelope.rx + 10 : members[0].radius + 13, ry = members.length > 1 ? envelope.ry + 10 : members[0].radius + 13; + for (const point of members) { point.x -= envelope.x; point.y -= envelope.y; } + const seed = outer?.get(group.key); + return { ...group, rx, ry, seed: seed ?? [groupIndex, 0], x: 0, y: 0 }; + }); + const xs = islands.map(island => island.seed[0]), ys = islands.map(island => island.seed[1]); + const minX = Math.min(...xs), maxX = Math.max(...xs), minY = Math.min(...ys), maxY = Math.max(...ys); + // Largest families go first. Every candidate is a nearest free box edge, so small unrelated groups cannot split a family. + const placed: typeof islands = []; + for (const island of [...islands].sort((a, b) => b.members.length - a.members.length || b.rx * b.ry - a.rx * a.ry || compare(a.key, b.key))) { + const x = minX === maxX ? (left + right) / 2 : left + (island.seed[0] - minX) / (maxX - minX) * (right - left); + const y = minY === maxY ? (top + bottom) / 2 : top + (island.seed[1] - minY) / (maxY - minY) * (bottom - top); + const clampX = (value: number) => Math.max(left + island.rx, Math.min(right - island.rx, value)); + const clampY = (value: number) => Math.max(top + island.ry, Math.min(bottom - island.ry, value)); + const candidatesX = [...new Set([clampX(x), left + island.rx, right - island.rx, ...placed.flatMap(other => [other.x - other.rx - island.rx - 8, other.x + other.rx + island.rx + 8])])] + .filter(value => value >= left + island.rx && value <= right - island.rx).sort((a, b) => Math.abs(a - x) - Math.abs(b - x) || a - b); + const candidatesY = [...new Set([clampY(y), top + island.ry, bottom - island.ry, ...placed.flatMap(other => [other.y - other.ry - island.ry - 8, other.y + other.ry + island.ry + 8])])] + .filter(value => value >= top + island.ry && value <= bottom - island.ry).sort((a, b) => Math.abs(a - y) - Math.abs(b - y) || a - b); + let best = Infinity; + island.x = clampX(x); island.y = clampY(y); + for (const cx of candidatesX) { + if ((cx - x) ** 2 >= best) break; + for (const cy of candidatesY) { + const score = (cx - x) ** 2 + (cy - y) ** 2; + if (score >= best) break; + if (placed.some(other => Math.abs(cx - other.x) < island.rx + other.rx + 7.9 && Math.abs(cy - other.y) < island.ry + other.ry + 7.9)) continue; + best = score; island.x = cx; island.y = cy; + } + } + placed.push(island); + for (const point of island.members) { point.x += island.x; point.y += island.y; } + } + return true; +} + +/** Doc concept families, or else lexical identifier families, around settled dots. They never feed back into dot positions. */ +function territories(data: GraphData, dots: NativeGraphDot[], width: number, height: number, compact: boolean): NativeGraphTerritory[] { + if (dots.length < 4) return []; + const { vectors, informative } = graphEmbedding(data), byId = new Map(dots.map(dot => [dot.node.id, dot])), concepts = sharedConceptFamilies(data.nodes); + type Family = Pick; + // A doc concept family names a concept from the docs; lexical families only show shared identifier words. + const families: Family[] = concepts.length ? concepts.slice(0, 6).map((family): Family => { + let total = 0, pairs = 0; + for (let a = 0; a < family.members.length; a++) for (let b = a + 1; b < family.members.length; b++) { + total += cosine(vectors.get(family.members[a]), vectors.get(family.members[b])); pairs++; + } + const folder = family.key.startsWith("family:") ? family.key.slice("family:".length) : ""; + return { key: family.key, kind: "family", label: family.label, terms: [], members: family.members, cohesion: total / pairs, + title: compactTooltip(`${family.label} · concept family`, `${family.members.length} concepts from docs in ${folder || "the repository root"}`) }; + }) : informative ? semanticFamilies([...byId.keys()].sort(compare), vectors).map((family): Family => ({ ...family, kind: "lexical", + title: compactTooltip(`${family.label} · shared terms`, "Lexical identifier similarity") })) : []; + // Cut the overflow of one axis at the canvas margin and center the radius on what remains. + const fit = (center: number, radius: number, limit: number) => { + const low = Math.max(2, center - radius), high = Math.min(limit - 2, center + radius); + return [(low + high) / 2, (high - low) / 2] as const; + }; + // Squared normalized distance from the ellipse center: below 1 is inside, above 1 is outside. + const reach = (ellipse: { x: number; y: number; rx: number; ry: number }, x: number, y: number) => ((x - ellipse.x) / ellipse.rx) ** 2 + ((y - ellipse.y) / ellipse.ry) ** 2; + const candidates = families.flatMap((family): NativeGraphTerritory[] => { + const members = family.members.map(id => byId.get(id)!), own = new Set(family.members); + const bounds = memberEllipse(members, compact); + const [x, fx] = fit(bounds.x, bounds.rx, width), [y, fy] = fit(bounds.y, bounds.ry, height), ellipse = { x, y, rx: fx, ry: fy }; + // The canvas cut can expose a disc edge, but every member center must stay inside and every other center outside. + // A territory that covers most of the canvas, misses a member or covers an unrelated node shows a false grouping. + if (Math.PI * fx * fy > width * height * .55 || dots.some(dot => own.has(dot.node.id) ? reach(ellipse, dot.x, dot.y) > 1 : reach(ellipse, dot.x, dot.y) < 1)) return []; + return [{ key: family.key, kind: family.kind, label: family.label, title: family.title, terms: family.terms, members: family.members, ...ellipse, cohesion: family.cohesion, color: conceptPalette(family.key) }]; + }); + // Where two territories cover each other's centers, the family with more members stays; the key breaks ties. + const kept: NativeGraphTerritory[] = []; + for (const candidate of candidates.sort((a, b) => b.members.length - a.members.length || compare(a.key, b.key))) { + if (kept.every(other => reach(other, candidate.x, candidate.y) >= 1 && reach(candidate, other.x, other.y) >= 1)) kept.push(candidate); + } + return kept; +} + +/** Settled evidence geometry only; activity, selection, theme and actions never enter this cache. */ +export function nativeGraphLayout(data: GraphData, size: GraphPreviewSize, choice: GraphAppearance["layout"] = DEFAULT_GRAPH_APPEARANCE.layout): NativeGraphLayout { + let variants = layouts.get(data); + const key = `${size}:${choice}`, previous = variants?.get(key); + if (previous) return previous; + // Without term or family evidence the concept map draws the force geometry. Families use their own two-level map. + const families = choice === "map" ? sharedConceptFamilies(data.nodes) : []; + const mapped = choice === "map" && !families.length ? conceptMap(data) : undefined, mode = choice === "map" && !mapped && !families.length ? "force" : choice; + const links = representativeLinks(data), roots = new Set(data.roots); + const weights = new Map(data.nodes.map(node => [node.id, drawingWeight(node)])); + const heaviest = Math.max(0, ...[...weights.values()].map(weight => weight ?? 0)); + // Bound weights size dots by area. Without them, roots and hubs get the larger fixed dot. + const radiusOf = (point: GraphPosition) => { + if (!heaviest) return roots.has(point.node.id) || point.degree >= 4 ? 5 : 3.5; + const weight = weights.get(point.node.id); + return weight ? Math.round((3.5 + 6 * Math.sqrt(weight / heaviest)) * 2) / 2 : 3.5; + }; + const width = size === "compact" ? 280 : size === "overview" ? 760 : 560; + const height = size === "compact" ? 176 : size === "overview" ? 320 : data.nodes.length <= 8 ? 156 : data.nodes.length <= 24 ? 216 : 264; + let positions: Map, project: (x: number, y: number) => NativeGraphPoint; + let geometries: NativeCurve[]; + if (mode === "force" && size !== "compact") { + // Preserve established inline/tool-response geometry when no new mode is requested. + const layout = layoutGraph(data); + positions = layout.positions; + geometries = links.map((_, index) => edgeGeometry(links, index, positions)!); + let left = layout.bounds.x, top = layout.bounds.y, right = left + layout.width, bottom = top + layout.height; + for (const edge of geometries) for (let at = 0; at < edge.control.length; at += 2) { + left = Math.min(left, edge.control[at]); right = Math.max(right, edge.control[at]); + top = Math.min(top, edge.control[at + 1]); bottom = Math.max(bottom, edge.control[at + 1]); + } + // Fit each axis to the wide canvas, but limit the stretch so that the topology reads the same. + let sx = Math.min(2.4, (width - 64) / Math.max(1, right - left)), sy = Math.min(2.4, (height - 48) / Math.max(1, bottom - top)); + sx = Math.min(sx, sy * 1.45); sy = Math.min(sy, sx * 1.45); + project = (x, y) => [(x - (left + right) / 2) * sx + width / 2, (y - (top + bottom) / 2) * sy + height / 2]; + } else { + if (mode === "force") positions = new Map([...layoutGraph(data).positions].map(([id, point]) => [id, { ...point }])); + else { + const coordinates = mapped ?? (mode === "map" ? undefined : mode === "radial" ? radialPositions(data) : mode === "components" ? componentPositions(data) : layeredPositions(data)); + positions = new Map([...data.nodes].sort((a, b) => compare(a.id, b.id)).map(node => [node.id, { + node, x: coordinates?.get(node.id)?.[0] ?? 0, y: coordinates?.get(node.id)?.[1] ?? 0, radius: 0, degree: 0, category: "", + }])); + for (const edge of data.edges) { positions.get(edge.from)!.degree++; positions.get(edge.to)!.degree++; } + } + const points = [...positions.values()]; + for (const point of points) point.radius = radiusOf(point); + const pairs = new Map(); + for (const link of links) { const pair = JSON.stringify([link.from, link.to]); pairs.set(pair, (pairs.get(pair) ?? 0) + 1); } + let inset = 20, headroom = 20; + for (const link of links) { + const count = pairs.get(JSON.stringify([link.from, link.to]))!; + if (link.from === link.to) { + const size = positions.get(link.from)!.radius + 13 + (count - 1) * 5; + inset = Math.max(inset, size + 4); headroom = Math.max(headroom, size * 2 + 4); + } else { + const reverse = pairs.has(JSON.stringify([link.to, link.from])); + inset = Math.max(inset, (count - 1) * 5 + (reverse ? 15 : 0) + 4); + } + } + const left = inset, right = width - inset, top = Math.max(inset, headroom), bottom = height - inset; + // Each axis fills the canvas. The concept map places half by value and half by rank, so one distant cluster cannot squeeze the rest. + const fill = (values: number[], from: number, to: number) => { + const low = Math.min(...values), high = Math.max(...values); + if (high === low) return values.map(() => (from + to) / 2); + const first = new Map(), last = new Map(); + if (mode === "map") [...values].sort((a, b) => a - b).forEach((value, index) => { if (!first.has(value)) first.set(value, index); last.set(value, index); }); + return values.map(value => { + const linear = (value - low) / (high - low); + return from + (mode === "map" ? (linear + (first.get(value)! + last.get(value)!) / 2 / (values.length - 1)) / 2 : linear) * (to - from); + }); + }; + if (mode !== "map" || !familyPositions(data, points, families, left, top, right, bottom)) { + const xs = fill(points.map(point => point.x), left, right), ys = fill(points.map(point => point.y), top, bottom); + points.forEach((point, index) => { point.x = xs[index]; point.y = ys[index]; }); + } + separate(points, left, top, right, bottom, mode === "layers"); + geometries = links.map((_, index) => edgeGeometry(links, index, positions)!); + project = (x, y) => [x, y]; + } + const dots = [...positions.values()].map((point): NativeGraphDot => { + const [x, y] = project(point.x, point.y), weight = weights.get(point.node.id); + return { node: point.node, x, y, radius: radiusOf(point), degree: point.degree, root: roots.has(point.node.id), ...(weight === undefined ? {} : { weight }) }; + }); + const byId = new Map(dots.map(dot => [dot.node.id, dot])); + // Route tiers exist only when edge drawing weights are bound. An unweighted original edge counts once. + const routeWeights = data.edges.some(edge => drawingWeight(edge) !== undefined) + ? links.map(link => link.indices.reduce((sum, index) => sum + (drawingWeight(data.edges[index]) ?? 1), 0)) : undefined; + const heaviestRoute = Math.max(0, ...routeWeights ?? []); + const routes = geometries.map((edge, index): NativeGraphRoute => { + const controls: NativeGraphPoint[] = [project(edge.from[0], edge.from[1])]; + for (let at = 0; at < edge.control.length; at += 2) controls.push(project(edge.control[at], edge.control[at + 1])); + controls.push(project(edge.to[0], edge.to[1])); + if (!edge.self) { + // Clip to the rendered dots, not the simulation's differently scaled radii. + for (const [at, id, adjacent] of [[0, links[index].from, 1], [controls.length - 1, links[index].to, controls.length - 2]] as const) { + const dot = byId.get(id)!, tangent = controls[adjacent], dx = tangent[0] - dot.x, dy = tangent[1] - dot.y; + const distance = Math.hypot(dx, dy) || 1; + controls[at] = [dot.x + dx / distance * (dot.radius + 1), dot.y + dy / distance * (dot.radius + 1)]; + } + } + const points: NativeGraphPoint[] = [controls[0]]; + // De Casteljau subdivision: <=.12px curve error and <=48px chords. + // Native CSS residual classes keep positional rounding below .13px per axis at 760px. + function subdivide(curve: NativeGraphPoint[], depth: number): void { + const start = curve[0], end = curve.at(-1)!, dx = end[0] - start[0], dy = end[1] - start[1]; + const length = Math.hypot(dx, dy); + const deviation = Math.max(...curve.slice(1, -1).map(point => length ? Math.abs(dy * (point[0] - start[0]) - dx * (point[1] - start[1])) / length : Math.hypot(point[0] - start[0], point[1] - start[1]))); + if (depth === 8 || (length <= 48 && deviation <= .12)) { points.push(end); return; } + const first: NativeGraphPoint[] = [start], last: NativeGraphPoint[] = [end]; + let level = curve; + while (level.length > 1) { + const next: NativeGraphPoint[] = []; + for (let at = 1; at < level.length; at++) next.push([(level[at - 1][0] + level[at][0]) / 2, (level[at - 1][1] + level[at][1]) / 2]); + first.push(next[0]); last.unshift(next.at(-1)!); level = next; + } + subdivide(first, depth + 1); subdivide(last, depth + 1); + } + subdivide(controls, 0); + if (!routeWeights) return { link: links[index], points }; + const ratio = routeWeights[index] / heaviestRoute; + return { link: links[index], points, tier: ratio >= .6 ? 4 : ratio >= .3 ? 3 : ratio >= .12 ? 2 : 1 }; + }); + const prepared = { width, height, dots, routes, territories: mode === "force" || mode === "map" ? territories(data, dots, width, height, size === "compact") : [] }; + if (!variants) { variants = new Map(); layouts.set(data, variants); } + variants.set(key, prepared); + return prepared; +} diff --git a/plugins/semctx/adapters/omp/graph-native-styles.ts b/plugins/semctx/adapters/omp/graph-native-styles.ts new file mode 100644 index 0000000..9757810 --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-native-styles.ts @@ -0,0 +1,156 @@ +import { conceptSwatches } from "./graph-data/palette.js"; +import type { NativeStylesheet } from "./native-surface"; + +// A single surface-lifetime sheet, independent of graph identities, labels and history. +// Coarse quarter-percent classes plus 1/32-percent residuals keep the shared sheet +// bounded while avoiding visible stair steps at the largest (760px) surface. +export const POSITION_STEPS = 400; +export const ANGLE_STEPS = 720; +const SUBPIXEL_STEPS = 8; +const PERCENT_STEP = 100 / (POSITION_STEPS * SUBPIXEL_STEPS); +const position = (value: number) => Math.max(0, Math.min(POSITION_STEPS * SUBPIXEL_STEPS, Math.round(value * POSITION_STEPS * SUBPIXEL_STEPS))); +export function positionClasses(x: number, y: number, width: number, height: number): string { + const px = position(x / width), py = position(y / height); + return `sg-x${Math.floor(px / SUBPIXEL_STEPS)} sg-y${Math.floor(py / SUBPIXEL_STEPS)} sg-fx${px % SUBPIXEL_STEPS} sg-fy${py % SUBPIXEL_STEPS}`; +} +export interface QuantizedGraphSegment { + /** Percentages of surface width/height; angle in degrees. */ + x: number; + y: number; + length: number; + angle: number; +} +export function quantizedSegment(from: readonly number[], to: readonly number[], width: number, height: number): QuantizedGraphSegment { + const angle = (Math.round(Math.atan2(to[1] - from[1], to[0] - from[0]) * 360 / Math.PI) + ANGLE_STEPS) % ANGLE_STEPS; + const length = Math.round(Math.hypot(to[0] - from[0], to[1] - from[1]) / width * POSITION_STEPS * SUBPIXEL_STEPS); + return { x: position(from[0] / width) * PERCENT_STEP, y: position(from[1] / height) * PERCENT_STEP, length: Math.min(568 * SUBPIXEL_STEPS, length) * PERCENT_STEP, angle: angle / 2 }; +} + +export interface NativeGraphRouteClasses { + readonly total: string; + readonly segments: readonly string[]; +} + +/** One route-wide strip span, with each chord sampling its cumulative quantized arclength. */ +export function routeStrokeClasses(points: readonly (readonly number[])[], width: number, height: number): NativeGraphRouteClasses { + const segments: string[] = []; + let arc = 0; + for (let index = 1; index < points.length; index++) { + const segment = quantizedSegment(points[index - 1], points[index], width, height); + const x = Math.round(segment.x / PERCENT_STEP), y = Math.round(segment.y / PERCENT_STEP), length = Math.round(segment.length / PERCENT_STEP); + const coarseArc = Math.floor(arc / SUBPIXEL_STEPS); + segments.push(`sg-x${Math.floor(x / SUBPIXEL_STEPS)} sg-y${Math.floor(y / SUBPIXEL_STEPS)} sg-l${Math.floor(length / SUBPIXEL_STEPS)} sg-a${segment.angle * 2} sg-fx${x % SUBPIXEL_STEPS} sg-fy${y % SUBPIXEL_STEPS} sg-fl${length % SUBPIXEL_STEPS} sg-c${coarseArc % POSITION_STEPS} sg-cc${Math.floor(coarseArc / POSITION_STEPS)} sg-fc${arc % SUBPIXEL_STEPS}`); + arc += length; + } + const coarseTotal = Math.floor(arc / SUBPIXEL_STEPS); + return { total: `sg-t${coarseTotal % POSITION_STEPS} sg-tc${Math.floor(coarseTotal / POSITION_STEPS)} sg-ft${arc % SUBPIXEL_STEPS}`, segments }; +} +const geometry: string[] = []; +for (let step = 0; step <= POSITION_STEPS; step++) { + geometry.push(`.semctl-graph .sg-x${step}{left:calc(${step / 4}% + var(--sg-fx,0%))}.semctl-graph .sg-y${step}{top:calc(${step / 4}% + var(--sg-fy,0%))}`); +} +for (let step = 0; step <= 568; step++) geometry.push(`.semctl-graph .sg-l${step}{--sg-span-base:${step / 4}%}`); +for (let step = 0; step < ANGLE_STEPS; step++) geometry.push(`.semctl-graph .sg-a${step}{transform:rotate(${step / 2}deg)}`); +for (let step = 0; step < SUBPIXEL_STEPS; step++) geometry.push(`.semctl-graph .sg-fx${step}{--sg-fx:${step * PERCENT_STEP}%}.semctl-graph .sg-fy${step}{--sg-fy:${step * PERCENT_STEP}%}.semctl-graph .sg-fl${step}{--sg-span-fine:${step * PERCENT_STEP}%}.semctl-graph .sg-fc${step}{--sg-arc-fine:${step / (POSITION_STEPS * SUBPIXEL_STEPS)}}.semctl-graph .sg-ft${step}{--sg-total-fine:${step / (POSITION_STEPS * SUBPIXEL_STEPS)}}`); +for (let step = 0; step < POSITION_STEPS; step++) geometry.push(`.semctl-graph .sg-c${step}{--sg-arc-base:${step / POSITION_STEPS}}.semctl-graph .sg-t${step}{--sg-total-base:${step / POSITION_STEPS}}`); +for (let step = 0; step <= 8; step++) geometry.push(`.semctl-graph .sg-cc${step}{--sg-arc-carry:${step}}.semctl-graph .sg-tc${step}{--sg-total-carry:${step}}`); +for (let step = 7; step <= 19; step++) geometry.push(`.semctl-graph .sg-target.sg-s${step}{--sg-d:${step}px}`); +for (let step = 0; step <= 200; step++) geometry.push(`.semctl-graph .sg-tw${step}{width:${step / 2}%}.semctl-graph .sg-th${step}{height:${step / 2}%}`); +for (const swatch of conceptSwatches) for (const tint of [swatch.light, swatch.dark]) geometry.push(`.semctl-graph .sg-tint-${tint.slice(1)}{--sg-tint:${tint}}`); +for (const [index, scale] of [1, 1.35, 1.8, 2.4].entries()) geometry.push(`.semctl-graph .sg-route.sg-w${index + 1}{--sg-weight-opacity:${[.55, .85, 1.1, 1.4][index]}}.semctl-graph .sg-w${index + 1} .sg-stroke{transform:scaleY(${scale})}`); +for (let step = 0; step < 8; step++) geometry.push(`.semctl-graph[data-motion=full] .sg-d${step} .sg-dot{animation-delay:${step * 40}ms}.semctl-graph[data-motion=full] .sg-d${step} .sg-label{animation-delay:${step * 40 + 380}ms}`); + +export const GRAPH_STYLES: readonly NativeStylesheet[] = Object.freeze([Object.freeze({ + name: "semctl-native-graph", + css: ` +.semctl-graph-well{--sg-well:color-mix(in oklab, var(--panel) 95%, var(--t1));--sg-bg:var(--sg-well);display:flex;justify-content:center;border-radius:8px;background:var(--sg-well);padding:6px;border:0} +.semctl-graph{position:relative;width:100%;max-width:560px;color:var(--sg-node);--sg-fg:var(--sf-p-text,var(--t1));--sg-node:color-mix(in oklab, var(--sg-fg) 45%, var(--sg-bg));--sg-edge:var(--sf-muted);--sg-accent:var(--sf-accent,var(--accent));--sg-positive:var(--sf-p-success,var(--sf-c-success,var(--ok)));--sg-negative:var(--sf-p-error,var(--sf-c-error,var(--bad)));--sg-bg:var(--sg-well,var(--panel));isolation:isolate} +.semctl-graph-well .semctl-graph{flex:1 1 auto;width:100%} +.semctl-graph.sg-compact{max-width:280px} +.semctl-graph.sg-overview{max-width:760px} +.semctl-graph .sg-ratio{height:0;padding-bottom:47.142857%} +.semctl-graph.sg-small .sg-ratio{padding-bottom:27.857143%} +.semctl-graph.sg-medium .sg-ratio{padding-bottom:38.571429%} +.semctl-graph.sg-compact .sg-ratio{padding-bottom:62.857143%} +.semctl-graph.sg-overview .sg-ratio{padding-bottom:42.105263%} +.semctl-graph .sg-plane,.semctl-graph .sg-territories,.semctl-graph .sg-routes,.semctl-graph .sg-route,.semctl-graph .sg-node,.semctl-graph .sg-hover-links{position:absolute;inset:0;pointer-events:none} +.semctl-graph .sg-plane{overflow:hidden} +.semctl-graph .sg-territory{position:absolute;transform:translate(-50%,-50%);box-sizing:border-box;border-radius:50%;background:radial-gradient(closest-side,rgb(from var(--sg-tint) r g b / 12%),rgb(from var(--sg-tint) r g b / 5%) 72%,transparent);border:1px solid rgb(from var(--sg-tint) r g b / 16%);pointer-events:none} +.semctl-graph.sg-compact .sg-territory{background:radial-gradient(closest-side,rgb(from var(--sg-tint) r g b / 9%),rgb(from var(--sg-tint) r g b / 4%) 72%,transparent)} +.semctl-graph .sg-territory-label{position:absolute;left:50%;top:0;transform:translate(-50%,-50%);box-sizing:border-box;height:14px;max-width:160px;padding:0 5px;border:1px solid rgb(from var(--sg-tint) r g b / 16%);border-radius:999px;background:color-mix(in oklab, var(--sg-bg) 85%, transparent);color:var(--sg-tint);font:600 10px/12px var(--sans,system-ui,sans-serif);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:auto} +.semctl-graph .sg-territory-label.sg-territory-label-bottom{top:100%} +.semctl-graph .sg-territory.sg-territory-family{background:radial-gradient(closest-side,rgb(from var(--sg-tint) r g b / 16%),rgb(from var(--sg-tint) r g b / 8%) 74%,rgb(from var(--sg-tint) r g b / 3%));border:1px solid rgb(from var(--sg-tint) r g b / 26%)} +.semctl-graph.sg-compact .sg-territory.sg-territory-family{background:radial-gradient(closest-side,rgb(from var(--sg-tint) r g b / 12%),rgb(from var(--sg-tint) r g b / 6%) 74%,rgb(from var(--sg-tint) r g b / 2%))} +.semctl-graph .sg-territory-family .sg-territory-label{border:1px solid rgb(from var(--sg-tint) r g b / 30%);background:color-mix(in oklab, var(--sg-tint) 10%, var(--sg-bg))} +.semctl-graph.sg-neutral .sg-territory{--sg-tint:var(--sg-edge)} +.semctl-graph.sg-focused .sg-territories{--sg-layer-opacity:.3;opacity:.3} +.semctl-graph .sg-route{color:var(--sg-edge);opacity:calc(.28 * var(--sg-weight-opacity,1))} +.semctl-graph.sg-links-soft .sg-route{opacity:calc(.28 * var(--sg-weight-opacity,1))} +.semctl-graph.sg-links-clear .sg-route{opacity:calc(.42 * var(--sg-weight-opacity,1))} +.semctl-graph.sg-compact .sg-route{opacity:calc(.2 * var(--sg-weight-opacity,1))} +.semctl-graph .sg-route.sg-containment,.semctl-graph .sg-route.sg-association{opacity:calc(.08 * var(--sg-weight-opacity,1))} +.semctl-graph.sg-focused .sg-route{opacity:.06} +.semctl-graph .sg-route.sg-related{color:var(--sg-route-ink,var(--sg-accent));opacity:.9;z-index:1} +.semctl-graph .sg-route.sg-observed{--sg-route-ink:var(--sg-positive);color:var(--sg-positive)} +.semctl-graph .sg-route{--sg-total:calc(var(--sg-total-carry,0) + var(--sg-total-base,0) + var(--sg-total-fine,0))} +.semctl-graph .sg-segment{--sg-span:calc(var(--sg-span-base,0%) + var(--sg-span-fine,0%));--sg-arc:calc(var(--sg-arc-carry,0) + var(--sg-arc-base,0) + var(--sg-arc-fine,0));position:absolute;width:100%;height:6px;margin-top:-3px;transform-origin:0 3px;pointer-events:none} +.semctl-graph .sg-hit{position:absolute;left:0;top:0;width:var(--sg-span);height:6px;pointer-events:auto} +.semctl-graph .sg-stroke{position:absolute;top:2.4px;left:0;width:100%;height:1.2px;clip-path:inset(0 calc(100% - var(--sg-span) - .5px) 0 0 round 1px);mask-image:linear-gradient(to bottom,transparent,#fff .1px,#fff 1.1px,transparent 1.2px);mask-size:100% 100%;mask-repeat:no-repeat;pointer-events:none} +.semctl-graph .sg-ink{position:absolute;left:calc(-100% * var(--sg-arc));top:0;width:calc(100% * var(--sg-total));height:100%;background:currentColor;pointer-events:none} +.semctl-graph .sg-import{--sg-dash:max(8px,calc(8px / var(--sf-zoom,1)));--sg-gap:max(6px,calc(6px / var(--sf-zoom,1)))} +.semctl-graph .sg-association{--sg-dash:max(4px,calc(4px / var(--sf-zoom,1)));--sg-gap:max(8px,calc(8px / var(--sf-zoom,1)))} +.semctl-graph .sg-import .sg-ink,.semctl-graph .sg-association .sg-ink{--sg-period:calc(var(--sg-dash) + var(--sg-gap));mask-image:linear-gradient(to right,#fff 0 var(--sg-dash),transparent var(--sg-dash) var(--sg-period));mask-size:var(--sg-period) 100%;mask-position:0 0;mask-repeat:space no-repeat} +.semctl-graph .sg-arrow{position:absolute;left:calc(var(--sg-span) - 3px);top:1.5px;width:3px;height:3px;border-top:1px solid currentColor;border-right:1px solid currentColor;transform:rotate(45deg);pointer-events:none} +.semctl-graph.sg-compact .sg-arrow{visibility:hidden} +.semctl-graph .sg-route.sg-related .sg-arrow{visibility:visible} +.semctl-graph .sg-target{--sg-d:7px;position:absolute;width:max(24px,calc(var(--sg-d) + 8px));height:max(24px,calc(var(--sg-d) + 8px));transform:translate(-50%,-50%);padding:0;border:0;border-radius:50%;background:transparent;pointer-events:auto;z-index:2} +.semctl-graph .sg-target.sg-action{cursor:pointer} +.semctl-graph.sg-neutral .sg-target{color:var(--sg-node)} +.semctl-graph .sg-dot{position:absolute;left:50%;top:50%;width:var(--sg-d);height:var(--sg-d);transform:translate(-50%,-50%) scale(var(--sg-dot-scale,1));border-radius:50%;background:currentColor;box-shadow:0 0 0 1.5px var(--sg-bg);pointer-events:none} +.semctl-graph .sg-root .sg-dot{outline:1px solid var(--sg-accent);outline-offset:1px} +.semctl-graph .sg-halo{position:absolute;left:50%;top:50%;width:calc(var(--sg-d) + 10px);height:calc(var(--sg-d) + 10px);transform:translate(-50%,-50%);border:1px solid currentColor;border-radius:50%;opacity:0;pointer-events:none} +.semctl-graph .sg-active .sg-halo{color:var(--sg-positive);opacity:.65} +.semctl-graph .sg-feedback{position:absolute;left:50%;top:50%;width:calc(var(--sg-d) + 11px);height:calc(var(--sg-d) + 11px);transform:translate(-50%,-50%);border:2px solid currentColor;border-radius:50%;opacity:.75;pointer-events:none} +.semctl-graph .sg-feedback.sg-returned{color:var(--sg-positive)} +.semctl-graph .sg-feedback.sg-failed{color:var(--sg-negative)} +.semctl-graph .sg-indexing-state{position:absolute;left:calc(50% + (var(--sg-d) / 2 + 2px) * .62);top:calc(50% + (var(--sg-d) / 2 + 2px) * .62);box-sizing:border-box;width:5px;height:5px;transform:translate(-50%,-50%);border:0;border-radius:50%;background:transparent;box-shadow:0 0 0 1.5px var(--sg-bg);pointer-events:none;z-index:1} +.semctl-graph .sg-indexing-pending .sg-indexing-state{border:1px solid var(--sg-edge)} +.semctl-graph .sg-indexing-processing .sg-indexing-state{background:var(--sg-accent)} +.semctl-graph .sg-indexing-failed .sg-indexing-state{background:var(--sg-negative)} +.semctl-graph .sg-pip-faint .sg-indexing-state{--sg-pip-opacity:.4;opacity:.4;width:4px;height:4px} +.semctl-graph .sg-pip-mid .sg-indexing-state{--sg-pip-opacity:.75;opacity:.75} +.semctl-graph .sg-pip-strong .sg-indexing-state{--sg-pip-opacity:1;opacity:1;width:6px;height:6px} +.semctl-graph .sg-selected .sg-dot{outline:2px solid var(--sg-accent);outline-offset:2px} +.semctl-graph .sg-target:hover .sg-dot,.semctl-graph .sg-target:focus-visible .sg-dot{--sg-dot-scale:1.18} +.semctl-graph .sg-muted .sg-dot{--sg-dot-opacity:.28;opacity:.28} +.semctl-graph .sg-label{position:absolute;left:calc(50% + var(--sg-d) / 2 + 5px);bottom:calc(50% + var(--sg-d) / 2 - 1px);max-width:180px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font:500 11px/14px var(--sans,system-ui,sans-serif);color:var(--t2);filter:drop-shadow(1px 0 1px var(--sg-bg)) drop-shadow(-1px 0 1px var(--sg-bg));pointer-events:none;text-align:left} +.semctl-graph.sg-compact .sg-label{font-size:10.5px} +.semctl-graph .sg-label.sg-label-left{left:auto;right:calc(50% + var(--sg-d) / 2 + 5px);text-align:right} +.semctl-graph .sg-label.sg-label-below{bottom:auto;top:calc(50% + var(--sg-d) / 2 + 3px)} +.semctl-graph .sg-selected .sg-label,.semctl-graph .sg-target:hover .sg-label,.semctl-graph .sg-target:focus-visible .sg-label{color:var(--t1);font-weight:600} +.semctl-graph .sg-label.sg-label-transient{visibility:hidden} +.semctl-graph .sg-selected .sg-label-transient,.semctl-graph .sg-target:hover .sg-label-transient,.semctl-graph .sg-target:focus-visible .sg-label-transient{visibility:visible} +.semctl-graph .sg-target:hover,.semctl-graph .sg-target:focus-visible,.semctl-graph .sg-target.sg-selected{z-index:3} +.semctl-graph .sg-hover-links{opacity:0;z-index:1} +.semctl-graph .sg-hover-links .sg-hit{pointer-events:none} +.semctl-graph .sg-node:hover .sg-hover-links,.semctl-graph .sg-node:focus-within .sg-hover-links{opacity:1} +.semctl-graph[data-motion=full] .sg-target{transition:left 560ms spring(560ms,.08),top 560ms spring(560ms,.08)} +.semctl-graph[data-motion=full] .sg-dot{transition:transform 120ms ease-out,opacity 160ms ease-out,outline-offset 160ms ease-out;animation:semctl-graph-dot-arrive 380ms cubic-bezier(.2,.8,.2,1) 1 backwards} +.semctl-graph[data-motion=full] .sg-label{animation:semctl-graph-fade 180ms ease-out 380ms 1 backwards} +.semctl-graph[data-motion=full] .sg-routes{animation:semctl-graph-fade 240ms ease-out 360ms 1 backwards} +.semctl-graph[data-motion=full] .sg-territories{transition:opacity 160ms ease-out;animation:semctl-graph-fade 300ms ease-out 200ms 1 backwards} +.semctl-graph[data-motion=full] .sg-halo{transition:opacity 180ms ease-out} +.semctl-graph[data-motion=full] .sg-active .sg-halo{animation:semctl-graph-active 3200ms ease-in-out 400ms infinite} +.semctl-graph[data-motion=full] .sg-hover-links{transition:opacity 100ms ease-out} +.semctl-graph[data-motion=full] .sg-route{transition:opacity 140ms ease-out} +.semctl-graph[data-motion=full] .sg-feedback{animation:semctl-graph-receipt 1000ms ease-out 1 both} +.semctl-graph[data-motion=full] .sg-indexing-failed .sg-indexing-state{animation:semctl-graph-failure 180ms ease-out 1} +@keyframes semctl-graph-dot-arrive{from{opacity:0;transform:translate(-50%,-50%) scale(.35)}to{opacity:var(--sg-dot-opacity,1);transform:translate(-50%,-50%) scale(var(--sg-dot-scale,1))}} +@keyframes semctl-graph-fade{from{opacity:0}to{opacity:var(--sg-layer-opacity,1)}} +@keyframes semctl-graph-failure{from{opacity:.2}to{opacity:var(--sg-pip-opacity,1)}} +@keyframes semctl-graph-receipt{from{opacity:.75}to{opacity:0}} +@keyframes semctl-graph-active{0%,100%{opacity:.65}50%{opacity:.5}} +.semctl-graph[data-motion=reduced] .sg-target,.semctl-graph[data-motion=reduced] .sg-dot,.semctl-graph[data-motion=reduced] .sg-label,.semctl-graph[data-motion=reduced] .sg-routes,.semctl-graph[data-motion=reduced] .sg-territories,.semctl-graph[data-motion=reduced] .sg-route,.semctl-graph[data-motion=reduced] .sg-halo,.semctl-graph[data-motion=reduced] .sg-hover-links,.semctl-graph[data-motion=reduced] .sg-feedback,.semctl-graph[data-motion=reduced] .sg-indexing-state{transition:none;animation:none} +@media (prefers-reduced-motion:reduce){.semctl-graph[data-motion=full] .sg-target,.semctl-graph[data-motion=full] .sg-dot,.semctl-graph[data-motion=full] .sg-label,.semctl-graph[data-motion=full] .sg-routes,.semctl-graph[data-motion=full] .sg-territories,.semctl-graph[data-motion=full] .sg-route,.semctl-graph[data-motion=full] .sg-halo,.semctl-graph[data-motion=full] .sg-active .sg-halo,.semctl-graph[data-motion=full] .sg-hover-links,.semctl-graph[data-motion=full] .sg-feedback,.semctl-graph[data-motion=full] .sg-indexing-failed .sg-indexing-state{transition:none;animation:none}} +${geometry.join("")}`, +})]); diff --git a/plugins/semctx/adapters/omp/graph-page.ts b/plugins/semctx/adapters/omp/graph-page.ts new file mode 100644 index 0000000..1212dfd --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-page.ts @@ -0,0 +1,236 @@ +import * as path from "node:path"; +import type { Component, DescribeContext, NativeNode, NativeScroll, NativeUiEvent } from "@oh-my-pi/pi-tui"; +import type { Display } from "./inspector"; +import type { GraphActivity } from "./graph-activity"; +import type { GraphAppearanceStore } from "./graph-appearance"; +import type { GraphAppearanceKeyMatcher } from "./graph-appearance-panel"; +import type { GRAPH_OPERATIONS, GraphOverviewDependencies, GraphRequestState } from "./overview"; +import { nativeElement, supportsElements } from "./native-elements"; +import { graphStatus } from "./graph-status"; +import { compactTooltip } from "./tooltip-text"; + +type Operation = (typeof GRAPH_OPERATIONS)[number]; +type Key = "cancel" | "confirm" | "up" | "down" | "pageUp" | "pageDown"; +interface PageOptions { + operation: Operation; + operations: readonly Operation[]; + cwd: string; + state(): GraphRequestState; + activity(): GraphActivity; + appearance: GraphAppearanceStore; + appearanceKey?: GraphAppearanceKeyMatcher; + createInspector: GraphOverviewDependencies["createInspector"]; + requestRender(): void; + height(): number; + matches(data: string, key: Key): boolean; + close(next?: string): void; + refresh(): void; + showAppearance(): void; +} +const groups = [ + { id: "project", label: "Project", ids: ["overview", "projects", "imports", "symbol-edges", "external-links"] }, + { id: "symbols", label: "Symbols", ids: ["trace", "definitions", "references", "who-calls", "implementations", "search-symbols", "batch"] }, + { id: "calls", label: "Calls", ids: ["call-graph", "call-path", "cycles"] }, + { id: "flow", label: "Flow & types", ids: ["reaches", "flows-into", "flows-between", "type-hierarchy"] }, + { id: "quality", label: "Quality", ids: ["duplicates", "unused"] }, + { id: "source", label: "Source", ids: ["outline", "symbol-at-position", "expand"] }, +] as const; +const clean = (value: string) => value.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, ""); +const text = (value: string, p: Record = {}): NativeNode => ({ k: "text", p: { text: clean(value), wrap: "word", ...p } }); + +/** A busy button stays disabled and shows a native spinner. Callers set busy only when motion is allowed. */ +function button(cx: DescribeContext, id: string, icon: "diagram" | "repeat" | "settings" | "info" | "x", label: string, hint: string, selected = false, disabled = false, busy = false): NativeNode { + const actions = disabled ? undefined : { click: id }, title = compactTooltip(hint); + const spinning = busy && cx.supports("spinner"); + if (supportsElements(cx) && cx.feature("styles")) return nativeElement({ k: "el", key: id, p: { + tag: "button", class: "semctl-icon-action", actions, title, aria: title, + attrs: { "aria-pressed": selected, "aria-disabled": disabled, "aria-busy": spinning }, + }, c: [spinning ? { k: "spinner", p: { style: "orbit", aria: "Refreshing" } } : cx.supports("icon") ? { k: "icon", p: { name: icon } } : text(label)] }); + const p = { actions, title, aria: title, tone: selected ? "accent" as const : "muted" as const, shrink: 0 }; + return spinning ? { k: "spinner", key: id, p: { ...p, style: "orbit" } } + : cx.supports("icon") ? { k: "icon", key: id, p: { ...p, name: icon } } : { ...text(label, p), key: id }; +} + +/** Full project page state belongs to this view, not to preference or activity updates. */ +export class GraphPage implements Component { + readonly nativeOverlay = { role: "omp.overlay.semctl-graph", size: "lg" as const }; + private menu = false; + private group: number; + private selectedMenu: string; + private menuRevision = 0; + private diagnostics = false; + private readonly expanded = new Set(); + private scroll: NativeScroll = { by: "start", n: 0 }; + private offset = 0; + private inspector: Component | undefined; + private lastDisplay: Display | undefined; + private disposed = false; + private readonly unsubscribe: () => void; + constructor(private readonly options: PageOptions) { + this.group = Math.max(0, groups.findIndex(group => group.ids.some(id => id === options.operation.id))); + this.selectedMenu = options.operation.id; + this.unsubscribe = options.appearance.subscribe(() => { if (!this.disposed) options.requestRender(); }); + void options.appearance.load(); + } + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.unsubscribe(); + this.inspector?.dispose?.(); + } + invalidate(): void { this.inspector?.invalidate?.(); } + private togglePanel(panel: "menu" | "diagnostics"): void { + const open = !this[panel]; + this.menu = panel === "menu" && open; + this.diagnostics = panel === "diagnostics" && open; + this.scroll = { by: "start", n: this.scroll.n + 1 }; + this.options.requestRender(); + } + private groupOperations(): readonly Operation[] { + return groups[this.group].ids.flatMap(id => this.options.operations.filter(operation => operation.id === id)); + } + private chooseGroup(index: number): void { + if (!groups[index]) return; + this.group = index; + if (!this.groupOperations().some(operation => operation.id === this.selectedMenu)) this.selectedMenu = this.groupOperations()[0].id; + this.menuRevision++; + this.options.requestRender(); + } + private chooser(cx: DescribeContext): NativeNode { + const operations = this.groupOperations(); + return { k: "col", key: "chooser", p: { gap: "xs", aria: "Choose read-only graph analysis" }, c: [ + cx.supports("tabs") ? { k: "tabs", key: "analysis-groups", p: { + active: groups[this.group].id, items: groups.map(group => ({ id: group.id, label: group.label })), aria: "Analysis groups", + } } : { k: "row", key: "analysis-groups", p: { gap: "sm", wrap: true, aria: "Analysis groups" }, c: groups.map((group, index) => + ({ ...text(group.label, { tone: this.group === index ? "accent" : "muted", actions: { click: `analysis-group:${index}` }, + mark: this.group === index ? "pick" : undefined, aria: `${group.label} analyses${this.group === index ? ", selected" : ""}` }), key: group.id })) }, + cx.supports("list") ? { k: "list", key: "analyses", p: { selected: this.selectedMenu }, c: operations.map(operation => ({ + k: "item", key: operation.id, reveal: operation.id === this.selectedMenu ? { at: "nearest", n: this.menuRevision } : undefined, + p: { label: operation.label, title: compactTooltip(operation.fields.length ? `Arguments: ${operation.fields.join(", ")}` : "No arguments") }, + })) } : { k: "col", c: operations.map(operation => ({ ...text(operation.label, { + tone: this.selectedMenu === operation.id ? "accent" : "muted", actions: { click: `choose:${operation.id}` }, aria: `Run ${operation.label}`, + }), key: operation.id })) }, + ] }; + } + private syncInspector(): void { + const state = this.options.state(); + if (state.display !== this.lastDisplay) { + this.inspector?.dispose?.(); + this.lastDisplay = state.display; + this.inspector = state.display ? this.options.createInspector(state.display, this.options.requestRender, state.text, "overview") : undefined; + } + if (this.inspector && "setAppearance" in this.inspector && typeof this.inspector.setAppearance === "function") this.inspector.setAppearance(this.options.appearance.state.value); + if (this.inspector && "setActivity" in this.inspector && typeof this.inspector.setActivity === "function") this.inspector.setActivity(this.options.activity().targets); + } + private section(cx: DescribeContext, key: string, head: string, body: string): NativeNode { + const open = this.expanded.has(key); + return cx.supports("section") ? { k: "section", key, p: { head, collapsible: true, collapsed: !open }, c: open ? [text(body)] : [] } + : { k: "col", key, c: [text(head, { tone: "muted", actions: { click: `details:${key}` } }), ...(open ? [text(body)] : [])] }; + } + describe(cx: DescribeContext): NativeNode { + this.syncInspector(); + const { operation, cwd, appearance } = this.options, request = this.options.state(), activity = this.options.activity(); + const motion = appearance.state.value.motion !== "off" && !cx.reduceMotion; + const out: NativeNode[] = [{ k: "row", key: "toolbar", p: { role: "semctl.graph.toolbar", gap: "sm", align: "center" }, c: [ + { k: "col", key: "heading", p: { grow: 1, min: { w: 0 } }, c: [ + text(operation.label, { role: "semctl.graph.title", truncate: "end", wrap: "none", title: compactTooltip(`Read-only · configured semctl CLI · ${cwd}`) }), + ...(operation.id === "overview" ? [text(path.basename(cwd), { + role: "semctl.graph.hint", tone: "muted", truncate: "end", wrap: "none", title: compactTooltip(cwd), + })] : []), + ] }, + { k: "row", p: { gap: "xs", shrink: 0, align: "center" }, c: [ + button(cx, "analyses", "diagram", "Analyses", "Choose graph analysis (A)", this.menu), + button(cx, "appearance", "settings", "Appearance", appearance.state.error ? "Graph appearance (S)\nLocal preferences need attention · open for details" : "Graph appearance (S)"), + button(cx, "refresh", "repeat", "Refresh", request.loading ? "Refreshing graph" : "Refresh graph (R)", false, request.loading, request.loading && motion), + button(cx, "info", "info", "Details", "Graph details (I)\nProvenance and diagnostics", this.diagnostics), + button(cx, "close", "x", "Close", "Close graph (Esc)"), + ] }, + ] }]; + const reading = request.loading && !request.text && motion && cx.supports("spinner") && cx.supports("shimmer"); + const body: (NativeNode | Component)[] = this.menu ? [this.chooser(cx)] : this.diagnostics ? [] : this.inspector ? [this.inspector] : + reading ? [{ k: "row", key: "reading", p: { role: "semctl.graph.loading", gap: "sm", align: "center" }, c: [ + { k: "spinner", p: { style: "dots", tone: "muted", aria: "Reading indexed project map" } }, + { k: "shimmer", p: { text: "Reading indexed project map", tone: "muted" } }, + ] }] + : [text(request.text || (request.loading ? "Reading indexed project map…" : "No result text returned."), { tone: request.loading ? "muted" : undefined })]; + if (this.diagnostics) { + body.push(text(`Read-only · configured semctl CLI · ${cwd}`, { tone: "muted" })); + if (activity.operations.length) body.push(text(`Observed semctx requests: ${activity.operations.join(", ")}`, { tone: "muted" })); + if (request.error) body.push(text(request.error, { tone: "error" })); + if (this.inspector) body.push(this.section(cx, "original", "Original CLI result", request.text)); + if (activity.producer) body.push(this.section(cx, "producer", "Producer observation", [activity.producer.text, activity.producer.details].filter(Boolean).join("\n"))); + } + out.push({ k: "col", key: "body", p: { role: "semctl.graph.body", min: { h: "12lines" }, max: { h: "28lines" } }, scroll: this.scroll, c: body }); + out.push(graphStatus(cx, { request, activity, motion: appearance.state.value.motion })); + out.push(text(this.menu ? "←/→ groups · ↑/↓ analyses · Enter run · Esc return to map" + : this.diagnostics ? "PgUp/PgDn scroll · I or Esc return to map" + : "↑/↓ nodes · PgUp/PgDn scroll · A analyses · S appearance · I details · R refresh · Esc close", + { role: "semctl.graph.hint", tone: "muted" })); + return { k: "col", p: { gap: "xs" }, c: out }; + } + render(width: number): readonly string[] { + const state = this.options.state(), rows = [this.options.operation.label, + ...(this.menu ? [groups[this.group].label, ...this.groupOperations().map(operation => `${operation.id === this.selectedMenu ? ">" : " "} ${operation.label}`)] : []), + ...(state.loading ? [state.display ? "Refreshing · last available map retained" : "Reading indexed project map…"] : []), + ...(state.error ? [state.error] : []), ...clean(state.text).split(/\r?\n/), + ...(this.diagnostics ? [`Read-only · configured semctl CLI · ${this.options.cwd}`] : []), + ].flatMap(line => clean(line).split(/\r?\n/)).flatMap(line => line.length ? Array.from({ length: Math.ceil(line.length / Math.max(1, width)) }, (_, index) => line.slice(index * Math.max(1, width), (index + 1) * Math.max(1, width))) : [""]); + this.offset = Math.max(0, Math.min(this.offset, rows.length - this.options.height())); + return rows.slice(this.offset, this.offset + this.options.height()); + } + handleNativeEvent(event: NativeUiEvent): void { + if (this.disposed) return; + if (event.type === "toggle") { + const key = event.key.slice(event.key.lastIndexOf("/") + 1); + if (key === "original" || key === "producer") { if (event.collapsed) this.expanded.delete(key); else this.expanded.add(key); this.options.requestRender(); return; } + } else if (event.type === "select" && this.menu && event.key.endsWith("analysis-groups")) { + this.chooseGroup(groups.findIndex(group => group.id === event.item)); + return; + } else if ((event.type === "select" || event.type === "activate") && this.menu && this.options.operations.some(operation => operation.id === event.item)) { + this.selectedMenu = event.item; + if (event.type === "activate") this.options.close(event.item); else this.options.requestRender(); + return; + } else if (event.type === "action") { + if (event.act === "close") { this.options.close(); return; } + if (event.act === "analyses") { this.togglePanel("menu"); return; } + if (event.act === "appearance") { this.options.showAppearance(); return; } + if (event.act === "info") { this.togglePanel("diagnostics"); return; } + if (event.act === "refresh") { if (!this.options.state().loading) this.options.refresh(); return; } + if (event.act.startsWith("analysis-group:") && this.menu) { this.chooseGroup(Number(event.act.slice(15))); return; } + if (event.act.startsWith("choose:") && this.menu && this.options.operations.some(operation => operation.id === event.act.slice(7))) { this.options.close(event.act.slice(7)); return; } + if (event.act.startsWith("details:")) { const key = event.act.slice(8); if (this.expanded.has(key)) this.expanded.delete(key); else this.expanded.add(key); this.options.requestRender(); return; } + } + this.inspector?.handleNativeEvent?.(event); + } + handleInput(data: string): void { + if (this.disposed) return; + const { matches, requestRender } = this.options; + if (matches(data, "cancel")) { + if (this.menu) this.togglePanel("menu"); + else if (this.diagnostics) this.togglePanel("diagnostics"); + else this.options.close(); + return; + } + if (data === "a" || data === "A") { this.togglePanel("menu"); return; } + if (data === "s" || data === "S") { this.options.showAppearance(); return; } + if (data === "i" || data === "I") { this.togglePanel("diagnostics"); return; } + if ((data === "r" || data === "R") && !this.menu) { if (!this.options.state().loading) this.options.refresh(); return; } + if (this.menu && matches(data, "confirm")) { this.options.close(this.selectedMenu); return; } + if (this.menu && (this.options.appearanceKey?.(data, "left") || data === "\x1b[D" || this.options.appearanceKey?.(data, "right") || data === "\x1b[C" || data === "\t")) { + const left = this.options.appearanceKey?.(data, "left") || data === "\x1b[D"; + this.chooseGroup((this.group + (left ? -1 : 1) + groups.length) % groups.length); return; + } + if (matches(data, "up") || matches(data, "down")) { + const delta = matches(data, "up") ? -1 : 1; + if (this.menu) { + const operations = this.groupOperations(), at = operations.findIndex(operation => operation.id === this.selectedMenu); + this.selectedMenu = operations[Math.max(0, Math.min(operations.length - 1, at + delta))].id; + this.menuRevision++; + } else if (!this.diagnostics && this.inspector && "move" in this.inspector && typeof this.inspector.move === "function") this.inspector.move(delta); + else { this.offset += delta; this.scroll = { by: delta > 0 ? "line-down" : "line-up", n: this.scroll.n + 1 }; } + } else if (matches(data, "pageUp") || matches(data, "pageDown")) { + const down = matches(data, "pageDown"); this.offset += down ? this.options.height() : -this.options.height(); this.scroll = { by: down ? "page-down" : "page-up", n: this.scroll.n + 1 }; + } else return; + requestRender(); + } +} diff --git a/plugins/semctx/adapters/omp/graph-preview.test.ts b/plugins/semctx/adapters/omp/graph-preview.test.ts new file mode 100644 index 0000000..e5bc05f --- /dev/null +++ b/plugins/semctx/adapters/omp/graph-preview.test.ts @@ -0,0 +1,929 @@ +import { afterEach, expect, test } from "bun:test"; +import type { DescribeContext, NativeNode } from "@oh-my-pi/pi-tui"; +import { graphPreview } from "./graph-preview"; +import type { GraphData } from "./graph"; +import { conceptRevision, lookupConcept, resetConcepts, setConceptSnapshot } from "./concept-colors"; +import { conceptClass } from "./native-concepts"; +import { nativeGraphLayout } from "./graph-native-layout"; +import { readDisplay, type Hit } from "./inspector"; +import { GRAPH_STYLES, positionClasses, quantizedSegment, routeStrokeClasses } from "./graph-native-styles"; +import { asNativeElement, type NativeElement } from "./native-elements"; +import { DEFAULT_GRAPH_APPEARANCE, type GraphAppearance } from "./graph-appearance"; +import { projectSummary } from "./graph-projection"; +import { graphColorRevision, lookupGraphColor, setGraphColorMode } from "./graph-colors"; +import { bindDrawingWeight } from "./graph-data/layout.js"; +import { bindIndexingCounts, boundIndexingCounts, indexingCounts } from "./graph-indexing"; + +afterEach(() => { resetConcepts(); setGraphColorMode("concepts"); }); +const cx: DescribeContext = { cols: 80, dark: true, reduceMotion: false, supports: kind => String(kind) === "el", feature: name => name === "styles" }; +function graph(): GraphData { + return { kind: "graph", title: "Graph", query: null, roots: ["a"], caveats: [], truncated: false, + nodes: [{ id: "a", label: "A", detail: "Entry module", hit: null, category: "file", path: "src/a.ts" }, + { id: "b", label: "B", detail: null, hit: null, category: "file", path: "src/b.ts" }], + edges: [{ from: "a", to: "b", label: "calls" }] }; +} +function nodes(root: NativeNode | undefined): NativeNode[] { + if (!root) throw new Error("No native graph preview"); + const result = [root]; + for (const child of root.c ?? []) if (child && typeof child === "object" && "k" in child) result.push(...nodes(child as NativeNode)); + return result; +} +function targets(root: NativeNode | undefined): NativeElement[] { + return nodes(root).map(asNativeElement).filter((node): node is NativeElement => node?.p.attrs?.["data-node"] !== undefined); +} +function links(root: NativeNode | undefined): NativeElement[] { + return nodes(root).map(asNativeElement).filter((node): node is NativeElement => node?.p.attrs?.["data-count"] !== undefined); +} +function elements(root: NativeNode | undefined, className: string): NativeElement[] { + return nodes(root).map(asNativeElement).filter((node): node is NativeElement => node?.p.class?.split(" ").includes(className) ?? false); +} +function expectCompact(title: string | undefined, lines = 3): void { + if (title === undefined) throw new Error("Missing tooltip"); + expect(title.split("\n").length).toBeLessThanOrEqual(lines); + for (const line of title.split("\n")) expect(line.length).toBeLessThanOrEqual(88); +} + +test("native capabilities are required independently, without image fallback", () => { + const data = graph(); + expect(graphPreview(data, { ...cx, supports: () => false })).toBeUndefined(); + expect(graphPreview(data, { ...cx, feature: () => false })).toBeUndefined(); + expect(graphPreview(data, cx)).toBeDefined(); +}); + +test("untrusted names stay plain text and never become resources, markup, attributes or actions", () => { + const data = graph(), malicious = '