Skip to content

refactor: extract presentation layer into src/presentation/ (3.14)#443

Merged
carlos-alm merged 6 commits intomainfrom
refactor/presentation-layer-extraction
Mar 16, 2026
Merged

refactor: extract presentation layer into src/presentation/ (3.14)#443
carlos-alm merged 6 commits intomainfrom
refactor/presentation-layer-extraction

Conversation

@carlos-alm
Copy link
Contributor

Summary

  • Extract all output formatting from domain logic into a dedicated src/presentation/ directory (ROADMAP 3.14)
  • viewer.js: Split into domain (prepareGraphData, data prep) and presentation (renderPlotHTML, color constants, config, HTML generation). Domain file went from 952 → 305 lines of domain logic; presentation is a pure data → HTML transform
  • export.js: Extract 8 pure serializers (renderFileLevelDOT, renderFunctionLevelDOT, renderFileLevelMermaid, renderFunctionLevelMermaid, renderFileLevelGraphML, renderFunctionLevelGraphML, renderFileLevelNeo4jCSV, renderFunctionLevelNeo4jCSV) into presentation/export.js. Shared data loaders (loadFileLevelEdges, loadFunctionLevelEdges, loadDirectoryGroups) eliminate duplicated SQL queries across formats
  • sequence.js: Move sequenceToMermaid() + escapeMermaid() to presentation/sequence-renderer.js
  • result-formatter.js: Move from infrastructure/ to presentation/ (re-export for backward compat)
  • table.js: New shared formatTable(), truncEnd(), truncStart() utilities for CLI table output

Principle

Domain functions return plain data objects. Presentation functions are pure transforms: data → formatted string. Commands wire the two together.

Test plan

  • All 21 viewer tests pass (tests/graph/viewer.test.js)
  • All 32 export tests pass (tests/graph/export.test.js)
  • All 16 sequence tests pass (tests/integration/sequence.test.js)
  • All 10 integration export tests pass (tests/integration/exports.test.js)
  • Index re-exports resolve without errors (tests/unit/index-exports.test.js)
  • Lint clean (biome check — 0 issues)
  • All public API signatures unchanged — backward compatible

…3.14)

Separate all output formatting from domain logic into a dedicated
presentation directory. Domain functions return plain data objects,
presentation functions are pure data → formatted string transforms.

New files:
- presentation/viewer.js: renderPlotHTML(), color constants, config loading
- presentation/export.js: pure DOT/Mermaid/GraphML/Neo4jCSV serializers
- presentation/sequence-renderer.js: sequenceToMermaid() from sequence.js
- presentation/table.js: formatTable(), truncEnd(), truncStart() helpers
- presentation/result-formatter.js: outputResult() moved from infrastructure/

Refactored:
- viewer.js: domain-only (prepareGraphData), delegates HTML to presentation
- export.js: shared data loaders + thin wrappers calling serializers
- sequence.js: re-exports sequenceToMermaid from presentation layer
- infrastructure/result-formatter.js: re-exports for backward compat

Impact: 36 functions changed, 80 affected
@claude
Copy link

claude bot commented Mar 14, 2026

Claude finished @carlos-alm's task —— View job


PR Review: Presentation Layer Extraction ⭐⭐⭐⭐☆ (4/5) - APPROVE

Reviewing the presentation layer extraction refactor (ROADMAP 3.14).


Root Cause Analysis

Problem Being Solved: This PR implements ROADMAP 3.14 — extracting presentation/output formatting logic from domain logic into a dedicated src/presentation/ directory. This architectural separation improves maintainability by creating clear boundaries between data preparation and output formatting.

Why This Approach: The refactor follows a clean separation of concerns:

  • Domain functions (src/export.js, src/viewer.js, src/sequence.js) now focus on data loading and preparation
  • Presentation functions (src/presentation/) are pure transforms: data → formatted string
  • Commands wire the two layers together

Risk Assessment: Very low risk. This is a pure refactoring with no functional changes. All tests pass, lint is clean, and public API signatures are unchanged with backward compatibility maintained.

Configuration Change Detection

No concerning configuration changes detected:

  • No biome/linting rules modified
  • No test timeout changes or coverage threshold adjustments
  • No build configuration changes
  • TypeScript not applicable (pure JavaScript project)
  • All changes are code structure improvements

Test Integrity

Test integrity maintained:

  • PR states "All 21 viewer tests pass", "All 32 export tests pass", etc.
  • No test files were modified in this PR
  • No assertions weakened or test expectations lowered
  • Backward compatibility maintained with re-exports

Code Quality Review

Excellent architectural separation achieved:

  1. Clean data/presentation split (src/viewer.js):

    • prepareGraphData() handles domain logic (952 → 305 lines)
    • renderPlotHTML() is pure data → HTML transform (moved to presentation/viewer.js)
  2. Serializer extraction (src/export.js):

    • 8 pure serializers moved to presentation/export.js
    • Shared data loaders eliminate duplicate SQL queries
    • Clear functional boundaries maintained
  3. Consistent presentation pattern:

    • All presentation functions are pure: no DB access, no side effects
    • Backward compatibility via re-exports

Code organization strengths:

  • Clear file naming and purpose (presentation/export.js, presentation/viewer.js)
  • Shared utilities extracted to reusable modules (table.js)
  • No logic changes - pure refactoring

Backlog Compliance Assessment

  • Zero-dep: ✓ — No new runtime dependencies added
  • Foundation-aligned: ✓ — Aligns with ROADMAP 3.14 architectural refactoring goals
  • Problem-fit: 3 — Improves maintainability but doesn't directly address core "lost AI" problem
  • Breaking: No — All public API signatures unchanged, backward compatible
  • Tier: 1

Critical Concerns

None identified. This is a clean, well-executed refactoring.

Final Recommendation

  • Rating: ⭐⭐⭐⭐☆ (4/5)
  • Action: APPROVE
  • Reasoning: Excellent architectural improvement that successfully separates concerns without breaking changes. High-quality code organization with clear boundaries. The only reason this isn't 5-star is that it's internal refactoring rather than user-facing value.

This PR advances Phase 3.14 of the architectural refactoring roadmap and creates a solid foundation for future presentation layer work.

--- • Branch: refactor/presentation-layer-extraction

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 14, 2026

Greptile Summary

This PR completes ROADMAP item 3.14 by extracting all output formatting from domain logic into a new src/presentation/ layer, enforcing the principle that domain functions return plain data while presentation functions are pure data → string transforms.

Key structural changes:

  • src/export.js now acts as a pure orchestrator: it loads data from the DB via shared helpers (loadFileLevelEdges, loadFunctionLevelEdges, loadDirectoryGroups, loadMermaidDirectoryGroups, loadNodeRoles) and delegates serialization to presentation/export.js. The eight serializers (DOT, Mermaid, GraphML, Neo4j CSV × 2 levels each) are now independently testable pure functions.
  • src/viewer.js retains domain data preparation (prepareGraphData) and calls renderPlotHTML from the presentation layer. Color constants are correctly broken out into presentation/colors.js to avoid a domain→presentation import for constants.
  • src/sequence.js retains BFS traversal and re-exports sequenceToMermaid from presentation/sequence-renderer.js for backward compatibility.
  • New utilities: presentation/table.js (formatTable, truncEnd, truncStart), presentation/colors.js, and re-homed presentation/result-formatter.js with a shim in infrastructure/ for backward compat.

All public API signatures are preserved. The implementation is clean and the 79 tests across 5 test suites confirm behavioral equivalence.

Minor observations:

  • loadDirectoryGroups and loadMermaidDirectoryGroups in src/export.js share nearly identical DB query logic but return different shapes; a shared inner function would reduce future maintenance surface.
  • loadNodeRoles uses a per-node SELECT loop (N+1 pattern) inherited from the old code — now that it is a named, isolated function, a batched IN query would be a straightforward improvement.
  • In prepareFileLevelData, the entry seed strategy silently falls through to showing all nodes (pre-existing behavior); a comment clarifying the intentional fallback would help future readers.

Confidence Score: 4/5

  • Safe to merge — well-tested refactoring with backward-compatible API and no functional regressions found.
  • All 79 tests pass across five test suites covering the main changed paths. The behavioral equivalence between old and new code was verified during review: data loaders reproduce the same SQL with the same filtering order (test filter → totalEdges count → limit slice), serializers produce identical output, and re-export shims preserve backward compat. The only deductions are for the N+1 query pattern in loadNodeRoles (pre-existing, now more visible) and the duplicated directory-loading logic — both style issues, not correctness problems.
  • src/export.js — the two near-identical directory loading functions and the N+1 loadNodeRoles loop are the main areas worth a follow-up refactor.

Important Files Changed

Filename Overview
src/export.js Refactored to extract pure serializers into presentation/export.js; now contains shared DB data loaders (loadFileLevelEdges, loadFunctionLevelEdges, loadDirectoryGroups, loadMermaidDirectoryGroups, loadNodeRoles). Two near-identical directory loading functions exist for DOT vs Mermaid with different return shapes. Behavior is otherwise consistent with the old code.
src/presentation/export.js New pure serializers for DOT, Mermaid, GraphML, Neo4j CSV formats. Node deduplication in renderFunctionLevelDOT is correctly handled. JSDoc is accurate. The renderFileLevelNeo4jCSV correctly uses e.edge_kind after the previous fix.
src/presentation/viewer.js HTML renderer extracted cleanly. Module doc accurately distinguishes renderPlotHTML (pure data→HTML) from loadPlotConfig (filesystem I/O). Color constants imported from colors.js. No issues.
src/viewer.js Domain data preparation layer. Now imports color constants from presentation/colors.js (resolving the previous cross-layer dependency). Still imports renderPlotHTML and DEFAULT_CONFIG from presentation/viewer.js in the generatePlotHTML orchestrator — this is intentional. Minor: prepareFileLevelData treats 'entry' seedStrategy identically to 'all', which is pre-existing behavior.
src/presentation/sequence-renderer.js Clean extraction of sequenceToMermaid and escapeMermaid (now private). Truncation note guard correctly handles empty participants. No issues.
src/presentation/colors.js New shared color constants module. Cleanly resolves the domain→presentation dependency for color constants by providing a neutral shared module. No issues.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    subgraph Commands["Commands / CLI"]
        CMD[CLI commands]
    end

    subgraph Domain["Domain Layer (src/)"]
        EXP[export.js\nloadFileLevelEdges\nloadFunctionLevelEdges\nloadDirectoryGroups\nloadNodeRoles]
        VWR[viewer.js\nprepareGraphData\nprepareFileLevelData\nprepareFunctionLevelData]
        SEQ[sequence.js\nsequenceData]
    end

    subgraph Presentation["Presentation Layer (src/presentation/)"]
        PEXP[export.js\nrenderFileLevelDOT\nrenderFunctionLevelDOT\nrenderFileLevelMermaid\nrenderFunctionLevelMermaid\nrenderFileLevelGraphML\nrenderFunctionLevelGraphML\nrenderFileLevelNeo4jCSV\nrenderFunctionLevelNeo4jCSV]
        PVWR[viewer.js\nrenderPlotHTML\nloadPlotConfig]
        PSEQ[sequence-renderer.js\nsequenceToMermaid]
        TBL[table.js\nformatTable\ntruncEnd\ntruncStart]
        RFMT[result-formatter.js\noutputResult]
        CLR[colors.js\nDEFAULT_NODE_COLORS\nDEFAULT_ROLE_COLORS\nCOMMUNITY_COLORS]
    end

    subgraph Compat["Backward Compat Shims"]
        INFRA[infrastructure/result-formatter.js\nre-export]
        SEQRE[sequence.js re-export\nsequenceToMermaid]
    end

    CMD --> EXP
    CMD --> VWR
    CMD --> SEQ

    EXP --> PEXP
    VWR --> PVWR
    VWR --> CLR
    SEQ --> PSEQ
    PVWR --> CLR

    INFRA --> RFMT
    SEQRE --> PSEQ

    style Presentation fill:#e3f2fd,stroke:#1565c0
    style Domain fill:#e8f5e9,stroke:#2e7d32
    style Compat fill:#fff9c4,stroke:#f9a825
Loading

Comments Outside Diff (2)

  1. src/export.js, line 377-394 (link)

    N+1 query pattern in loadNodeRoles

    For every unique (file, name) pair encountered across all edges, a separate SELECT role FROM nodes WHERE file = ? AND name = ? query is issued. On a graph with 500 unique function nodes this becomes 500 sequential round-trips to SQLite.

    This is identical to the pre-refactor behavior, so it is not a regression, but now that this is a named, isolated function it would be straightforward to replace with a single batched query:

    function loadNodeRoles(db, edges) {
      const pairs = new Set();
      for (const e of edges) {
        pairs.add(`${e.source_file}\x00${e.source_name}`);
        pairs.add(`${e.target_file}\x00${e.target_name}`);
      }
      if (pairs.size === 0) return new Map();
    
      const rows = db.prepare(
        `SELECT file, name, role FROM nodes
         WHERE role IS NOT NULL
           AND (file || char(0) || name) IN (${[...pairs].map(() => '?').join(',')})`,
      ).all([...pairs]);
    
      const roles = new Map();
      for (const r of rows) roles.set(`${r.file}::${r.name}`, r.role);
      return roles;
    }
  2. src/viewer.js, line 303-311 (link)

    entry seed strategy falls through to all in file-level mode

    In prepareFileLevelData, when cfg.seedStrategy === 'entry', all nodes are returned as seeds — the same result as the else branch. This pre-existed the refactor, but since this function is now a clearly-named standalone it is more visible. If a user sets seedStrategy: 'entry' expecting drill-down behaviour (as they get in function-level mode), they will silently see the full graph instead.

    Consider either:

    • documenting the intentional fallback in a comment, or
    • applying the same filter: visNodes.filter(n => n.role === 'entry'), falling back to all if none are found.

Last reviewed commit: 17b6f7d

src/export.js Outdated
Comment on lines +23 to +42
function loadFileLevelEdges(db, { noTests, minConfidence, limit, includeKind = false }) {
const minConf = minConfidence ?? DEFAULT_MIN_CONFIDENCE;
const kindClause = includeKind ? ', e.kind AS edge_kind' : '';
const confidenceClause = includeKind ? ', e.confidence' : '';
let edges = db
.prepare(
`
SELECT DISTINCT n1.file AS source, n2.file AS target${kindClause}${confidenceClause}
FROM edges e
JOIN nodes n1 ON e.source_id = n1.id
JOIN nodes n2 ON e.target_id = n2.id
WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type', 'calls')
AND e.confidence >= ?
`,
)
.all(minConf);
if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
const totalEdges = edges.length;
if (limit && edges.length > limit) edges = edges.slice(0, limit);
return { edges, totalEdges };
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DISTINCT includes confidence for Mermaid

When includeKind: true, this query adds both e.kind AS edge_kind and e.confidence to the SELECT DISTINCT clause. The old Mermaid query only included source, target, edge_kind in DISTINCT (3 columns), but this unified loader now includes confidence as well (4 columns).

This means if two edges between the same file pair have the same kind but different confidence values (e.g. A→B imports @ 0.8 and A→B imports @ 0.9), the old code would return 1 row while the new code returns 2. This inflates totalEdges and may cause the edge limit to be applied before deduplication — meaning fewer unique file pairs survive the limit cut.

In practice, renderFileLevelMermaid deduplicates by source|target when building edge labels, so the rendered output is likely equivalent for most cases. However, the truncation message (showing X of Y edges) could report inflated numbers, and applying limit before deduplication could drop unique file pairs.

Consider splitting includeKind from includeConfidence, or adding a separate Mermaid-specific loader that omits confidence from DISTINCT — matching the old behavior exactly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 134ee84. Split includeKind from includeConfidence in loadFileLevelEdges — Mermaid now passes only includeKind: true (matching the original 3-column DISTINCT: source, target, edge_kind), while Neo4j CSV passes both includeKind: true, includeConfidence: true. This preserves the original dedup/truncation behavior for Mermaid exactly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-issue — the refactored loadFileLevelEdges already separates includeKind and includeConfidence as independent options. The Mermaid caller (line 234) only passes includeKind: true, so confidence is NOT in the DISTINCT clause. Only the Neo4j caller (line 366) passes both. The totalEdges count is accurate for Mermaid.

Comment on lines +368 to +394
/**
* Render file-level graph data as Neo4j bulk-import CSV.
*
* @param {{ edges: Array<{ source: string, target: string, kind: string, confidence: number }> }} data
* @returns {{ nodes: string, relationships: string }}
*/
export function renderFileLevelNeo4jCSV(data) {
const files = new Map();
let idx = 0;
for (const { source, target } of data.edges) {
if (!files.has(source)) files.set(source, idx++);
if (!files.has(target)) files.set(target, idx++);
}

const nodeLines = ['nodeId:ID,name,file:string,:LABEL'];
for (const [file, id] of files) {
nodeLines.push(`${id},${escapeCsv(path.basename(file))},${escapeCsv(file)},File`);
}

const relLines = [':START_ID,:END_ID,:TYPE,confidence:float'];
for (const e of data.edges) {
const edgeType = (e.edge_kind || e.kind).toUpperCase().replace(/-/g, '_');
relLines.push(`${files.get(e.source)},${files.get(e.target)},${edgeType},${e.confidence}`);
}

return { nodes: nodeLines.join('\n'), relationships: relLines.join('\n') };
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSDoc documents wrong field name

The @param type documents kind: string and confidence: number as edge fields, but loadFileLevelEdges with includeKind: true aliases the SQL column as edge_kind (not kind). The defensive fallback (e.edge_kind || e.kind) on line 389 works but masks the mismatch — e.kind will never exist in the data passed from exportNeo4jCSV.

Suggested change
/**
* Render file-level graph data as Neo4j bulk-import CSV.
*
* @param {{ edges: Array<{ source: string, target: string, kind: string, confidence: number }> }} data
* @returns {{ nodes: string, relationships: string }}
*/
export function renderFileLevelNeo4jCSV(data) {
const files = new Map();
let idx = 0;
for (const { source, target } of data.edges) {
if (!files.has(source)) files.set(source, idx++);
if (!files.has(target)) files.set(target, idx++);
}
const nodeLines = ['nodeId:ID,name,file:string,:LABEL'];
for (const [file, id] of files) {
nodeLines.push(`${id},${escapeCsv(path.basename(file))},${escapeCsv(file)},File`);
}
const relLines = [':START_ID,:END_ID,:TYPE,confidence:float'];
for (const e of data.edges) {
const edgeType = (e.edge_kind || e.kind).toUpperCase().replace(/-/g, '_');
relLines.push(`${files.get(e.source)},${files.get(e.target)},${edgeType},${e.confidence}`);
}
return { nodes: nodeLines.join('\n'), relationships: relLines.join('\n') };
}
/**
* Render file-level graph data as Neo4j bulk-import CSV.
*
* @param {{ edges: Array<{ source: string, target: string, edge_kind: string, confidence: number }> }} data
* @returns {{ nodes: string, relationships: string }}
*/
export function renderFileLevelNeo4jCSV(data) {
const files = new Map();
let idx = 0;
for (const { source, target } of data.edges) {
if (!files.has(source)) files.set(source, idx++);
if (!files.has(target)) files.set(target, idx++);
}
const nodeLines = ['nodeId:ID,name,file:string,:LABEL'];
for (const [file, id] of files) {
nodeLines.push(`${id},${escapeCsv(path.basename(file))},${escapeCsv(file)},File`);
}
const relLines = [':START_ID,:END_ID,:TYPE,confidence:float'];
for (const e of data.edges) {
const edgeType = e.edge_kind.toUpperCase().replace(/-/g, '_');
relLines.push(`${files.get(e.source)},${files.get(e.target)},${edgeType},${e.confidence}`);
}
return { nodes: nodeLines.join('\n'), relationships: relLines.join('\n') };
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 134ee84. Updated JSDoc to document edge_kind: string and removed the (e.edge_kind || e.kind) fallback — now just e.edge_kind.toUpperCase() since the data always comes from loadFileLevelEdges with includeKind: true.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-issue — the JSDoc at line 371 already correctly documents edge_kind: string and confidence: number, which matches the actual field names used in the code (e.edge_kind at line 389, e.confidence at line 390). No mismatch.

Comment on lines +1 to +7
/**
* Interactive HTML viewer — presentation layer.
*
* Pure data → HTML transform. Receives prepared graph data and config,
* returns a self-contained HTML string with embedded vis-network.
* No DB access — all data must be pre-loaded via prepareGraphData().
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Module doc contradicts loadPlotConfig I/O

The file header states "No DB access — all data must be pre-loaded" and describes the module as a "Pure data → HTML transform," but loadPlotConfig (lines 74–113) reads from the filesystem via fs.existsSync and fs.readFileSync. This contradicts the stated design principle of "no I/O."

Consider updating the module doc to reflect that config loading is also included, or moving loadPlotConfig to the domain layer (e.g. keeping it in src/viewer.js) and passing the resolved config into renderPlotHTML.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 134ee84. Updated the module doc to accurately describe both responsibilities: renderPlotHTML() is a pure data → HTML transform (no I/O), while loadPlotConfig() reads .plotDotCfg files from disk. Kept loadPlotConfig in the presentation layer since it exclusively configures visual appearance — moving it to domain would create an awkward dependency direction for a viewer-only concern.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 00e9ae6 — updated the module doc to accurately distinguish renderPlotHTML() (pure data→HTML, no I/O) from loadPlotConfig() (reads .plotDotCfg from filesystem).

- Split includeKind from includeConfidence in loadFileLevelEdges to
  preserve original DISTINCT behavior for Mermaid (no confidence column)
  while Neo4j CSV gets both kind and confidence
- Fix JSDoc in renderFileLevelNeo4jCSV: document edge_kind (not kind)
  and remove defensive fallback that masked dead code
- Update presentation/viewer.js module doc to acknowledge loadPlotConfig
  performs filesystem I/O alongside the pure renderPlotHTML transform

Impact: 3 functions changed, 4 affected
@carlos-alm
Copy link
Contributor Author

@greptileai

src/viewer.js Outdated
Comment on lines +5 to +11
import {
COMMUNITY_COLORS,
DEFAULT_CONFIG,
DEFAULT_NODE_COLORS,
DEFAULT_ROLE_COLORS,
renderPlotHTML,
} from './presentation/viewer.js';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Domain layer imports visual constants from presentation layer

prepareFunctionLevelData and prepareFileLevelData use DEFAULT_NODE_COLORS, DEFAULT_ROLE_COLORS, and COMMUNITY_COLORS to compute the initial color property baked into each node object (lines 172–276). These are visual styling constants now living in the presentation layer, creating a domain → presentation dependency — the opposite of the stated "domain returns plain data, presentation formats it" principle in the PR description.

Before this refactor both halves lived in the same file so the coupling was invisible. Now it's an explicit cross-layer import. The cleanest fix would be to move the color computation into renderPlotHTML (the presentation layer already receives cfg.nodeColors / cfg.roleColors / community info) and have prepareGraphData return raw semantic fields (kind, role, community) without baking in colors. Alternatively, if pre-computing colors in the domain is intentional for performance, the color constants could be kept in a shared constants module that neither layer "owns".

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 00e9ae6 — extracted DEFAULT_NODE_COLORS, DEFAULT_ROLE_COLORS, and COMMUNITY_COLORS into a new src/presentation/colors.js module. Both src/viewer.js (domain) and src/presentation/viewer.js (presentation) now import from colors.js. The domain layer no longer imports from the presentation layer.

- Fix module doc in presentation/viewer.js: accurately describes both
  renderPlotHTML (pure transform, no I/O) and loadPlotConfig (filesystem I/O)
  instead of broadly claiming "No DB access" and "Pure data → HTML transform"
- Move color constants (DEFAULT_NODE_COLORS, DEFAULT_ROLE_COLORS,
  COMMUNITY_COLORS) to presentation/colors.js so the domain layer
  (src/viewer.js) no longer imports from the presentation layer; both
  layers now import from the shared colors module
@carlos-alm
Copy link
Contributor Author

@greptileai

Comment on lines +124 to +130
for (const e of data.edges) {
const sId = `${e.source_file}:${e.source_name}`.replace(/[^a-zA-Z0-9_]/g, '_');
const tId = `${e.target_file}:${e.target_name}`.replace(/[^a-zA-Z0-9_]/g, '_');
lines.push(` ${sId} [label="${e.source_name}\\n${path.basename(e.source_file)}"];`);
lines.push(` ${tId} [label="${e.target_name}\\n${path.basename(e.target_file)}"];`);
lines.push(` ${sId} -> ${tId};`);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate node declarations per edge

For each edge, both the source and target node definition lines ([label="..."]) are emitted unconditionally. A node that is the target of 10 different callers will get 10 identical [label=...] declarations in the output. Graphviz handles duplicates (last definition wins), but the output can be significantly bloated for high fan-in nodes.

Now that this is a standalone pure renderer, it's straightforward to deduplicate:

const emittedNodes = new Set();
for (const e of data.edges) {
  const sId = `${e.source_file}:${e.source_name}`.replace(/[^a-zA-Z0-9_]/g, '_');
  const tId = `${e.target_file}:${e.target_name}`.replace(/[^a-zA-Z0-9_]/g, '_');
  if (!emittedNodes.has(sId)) {
    lines.push(`  ${sId} [label="${e.source_name}\\n${path.basename(e.source_file)}"];`);
    emittedNodes.add(sId);
  }
  if (!emittedNodes.has(tId)) {
    lines.push(`  ${tId} [label="${e.target_name}\\n${path.basename(e.target_file)}"];`);
    emittedNodes.add(tId);
  }
  lines.push(`  ${sId} -> ${tId};`);
}

Note: this is the same behavior as the original exportDOT in src/export.js, so it's not a regression introduced here — but extraction into a named pure function is the right moment to clean it up.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 17b6f7d — added an emittedNodes Set to renderFunctionLevelDOT so each node definition is emitted once regardless of how many edges reference it.

High fan-in nodes were emitting duplicate [label=...] declarations
per edge. Use an emittedNodes Set to emit each node definition once.

Impact: 1 functions changed, 0 affected
@carlos-alm
Copy link
Contributor Author

@greptileai

@carlos-alm carlos-alm merged commit 2092b5c into main Mar 16, 2026
13 checks passed
@carlos-alm carlos-alm deleted the refactor/presentation-layer-extraction branch March 16, 2026 02:21
@github-actions github-actions bot locked and limited conversation to collaborators Mar 16, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant