From 6ff5c69f2220da5f3db984e855e95c95d30d2a80 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= <matej.chalk@flowup.cz>
Date: Wed, 7 May 2025 17:13:08 +0200
Subject: [PATCH] feat(utils): generate ascii tree in full markdown report's
 audit details

---
 packages/utils/src/lib/reports/constants.ts   |   4 +
 packages/utils/src/lib/reports/formatting.ts  |  30 +-
 .../src/lib/reports/generate-md-report.ts     |  57 ++--
 .../reports/generate-md-report.unit.test.ts   |  50 +++
 .../ascii/__snapshots__/basic-tree.txt        |   3 +
 .../__snapshots__/basic-tree.with-nesting.txt |  15 +
 .../__snapshots__/basic-tree.with-values.txt  |   3 +
 .../ascii/__snapshots__/coverage-tree.txt     |  15 +
 .../coverage-tree.with-missing-lines.txt      |   8 +
 .../coverage-tree.with-missing-named.txt      |   7 +
 .../utils/src/lib/text-formats/ascii/tree.ts  | 118 +++++++
 .../lib/text-formats/ascii/tree.unit.test.ts  | 289 ++++++++++++++++++
 12 files changed, 568 insertions(+), 31 deletions(-)
 create mode 100644 packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.txt
 create mode 100644 packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.with-nesting.txt
 create mode 100644 packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.with-values.txt
 create mode 100644 packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.txt
 create mode 100644 packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.with-missing-lines.txt
 create mode 100644 packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.with-missing-named.txt
 create mode 100644 packages/utils/src/lib/text-formats/ascii/tree.ts
 create mode 100644 packages/utils/src/lib/text-formats/ascii/tree.unit.test.ts

diff --git a/packages/utils/src/lib/reports/constants.ts b/packages/utils/src/lib/reports/constants.ts
index 07801403e..a40ac5e98 100644
--- a/packages/utils/src/lib/reports/constants.ts
+++ b/packages/utils/src/lib/reports/constants.ts
@@ -1,3 +1,5 @@
+import { HIERARCHY } from '../text-formats/index.js';
+
 // https://stackoverflow.com/questions/4651012/why-is-the-default-terminal-width-80-characters/4651037#4651037
 export const TERMINAL_WIDTH = 80;
 
@@ -20,3 +22,5 @@ export const REPORT_RAW_OVERVIEW_TABLE_HEADERS = [
   'Score',
   'Audits',
 ];
+
+export const AUDIT_DETAILS_HEADING_LEVEL = HIERARCHY.level_4;
diff --git a/packages/utils/src/lib/reports/formatting.ts b/packages/utils/src/lib/reports/formatting.ts
index 08e90a8b3..832107fd5 100644
--- a/packages/utils/src/lib/reports/formatting.ts
+++ b/packages/utils/src/lib/reports/formatting.ts
@@ -9,13 +9,15 @@ import type {
   AuditReport,
   SourceFileLocation,
   Table,
+  Tree,
 } from '@code-pushup/models';
-import { HIERARCHY } from '../text-formats/index.js';
+import { formatAsciiTree } from '../text-formats/ascii/tree.js';
 import {
   columnsToStringArray,
   getColumnAlignments,
   rowToStringArray,
 } from '../text-formats/table.js';
+import { AUDIT_DETAILS_HEADING_LEVEL } from './constants.js';
 import {
   getEnvironmentType,
   getGitHubBaseUrl,
@@ -24,19 +26,19 @@ import {
 import type { MdReportOptions } from './types.js';
 
 export function tableSection(
-  tableData: Table,
+  table: Table,
   options?: {
     level?: HeadingLevel;
   },
 ): MarkdownDocument | null {
-  if (tableData.rows.length === 0) {
+  if (table.rows.length === 0) {
     return null;
   }
-  const { level = HIERARCHY.level_4 } = options ?? {};
-  const columns = columnsToStringArray(tableData);
-  const alignments = getColumnAlignments(tableData);
-  const rows = rowToStringArray(tableData);
-  return new MarkdownDocument().heading(level, tableData.title).table(
+  const { level = AUDIT_DETAILS_HEADING_LEVEL } = options ?? {};
+  const columns = columnsToStringArray(table);
+  const alignments = getColumnAlignments(table);
+  const rows = rowToStringArray(table);
+  return new MarkdownDocument().heading(level, table.title).table(
     columns.map((heading, i) => {
       const alignment = alignments[i];
       if (alignment) {
@@ -48,6 +50,18 @@ export function tableSection(
   );
 }
 
+export function treeSection(
+  tree: Tree,
+  options?: {
+    level?: HeadingLevel;
+  },
+): MarkdownDocument {
+  const { level = AUDIT_DETAILS_HEADING_LEVEL } = options ?? {};
+  return new MarkdownDocument()
+    .heading(level, tree.title)
+    .code(formatAsciiTree(tree));
+}
+
 // @TODO extract `Pick<AuditReport, 'docsUrl' | 'description'>` to a reusable schema and type
 export function metaDescription(
   audit: Pick<AuditReport, 'docsUrl' | 'description'>,
diff --git a/packages/utils/src/lib/reports/generate-md-report.ts b/packages/utils/src/lib/reports/generate-md-report.ts
index c452b22f6..d5e464b9b 100644
--- a/packages/utils/src/lib/reports/generate-md-report.ts
+++ b/packages/utils/src/lib/reports/generate-md-report.ts
@@ -3,6 +3,7 @@ import type { AuditReport, Issue, Report } from '@code-pushup/models';
 import { formatDate, formatDuration } from '../formatting.js';
 import { HIERARCHY } from '../text-formats/index.js';
 import {
+  AUDIT_DETAILS_HEADING_LEVEL,
   FOOTER_PREFIX,
   README_LINK,
   REPORT_HEADLINE_TEXT,
@@ -12,6 +13,7 @@ import {
   linkToLocalSourceForIde,
   metaDescription,
   tableSection,
+  treeSection,
 } from './formatting.js';
 import {
   categoriesDetailsSection,
@@ -73,48 +75,57 @@ export function auditDetailsIssues(
   if (issues.length === 0) {
     return null;
   }
-  return new MarkdownDocument().heading(HIERARCHY.level_4, 'Issues').table(
-    [
-      { heading: 'Severity', alignment: 'center' },
-      { heading: 'Message', alignment: 'left' },
-      { heading: 'Source file', alignment: 'left' },
-      { heading: 'Line(s)', alignment: 'center' },
-    ],
-    issues.map(({ severity: level, message, source }: Issue) => {
-      const severity = md`${severityMarker(level)} ${md.italic(level)}`;
+  return new MarkdownDocument()
+    .heading(AUDIT_DETAILS_HEADING_LEVEL, 'Issues')
+    .table(
+      [
+        { heading: 'Severity', alignment: 'center' },
+        { heading: 'Message', alignment: 'left' },
+        { heading: 'Source file', alignment: 'left' },
+        { heading: 'Line(s)', alignment: 'center' },
+      ],
+      issues.map(({ severity: level, message, source }: Issue) => {
+        const severity = md`${severityMarker(level)} ${md.italic(level)}`;
 
-      if (!source) {
-        return [severity, message];
-      }
-      const file = linkToLocalSourceForIde(source, options);
-      if (!source.position) {
-        return [severity, message, file];
-      }
-      const line = formatSourceLine(source.position);
-      return [severity, message, file, line];
-    }),
-  );
+        if (!source) {
+          return [severity, message];
+        }
+        const file = linkToLocalSourceForIde(source, options);
+        if (!source.position) {
+          return [severity, message, file];
+        }
+        const line = formatSourceLine(source.position);
+        return [severity, message, file, line];
+      }),
+    );
 }
 
 export function auditDetails(
   audit: AuditReport,
   options?: MdReportOptions,
 ): MarkdownDocument {
-  const { table, issues = [] } = audit.details ?? {};
+  const { table, issues = [], trees = [] } = audit.details ?? {};
   const detailsValue = auditDetailsAuditValue(audit);
 
   // undefined details OR empty details (undefined issues OR empty issues AND empty table)
-  if (issues.length === 0 && !table?.rows.length) {
+  if (issues.length === 0 && !table?.rows.length && trees.length === 0) {
     return new MarkdownDocument().paragraph(detailsValue);
   }
 
   const tableSectionContent = table && tableSection(table);
   const issuesSectionContent =
     issues.length > 0 && auditDetailsIssues(issues, options);
+  const treesSectionContent =
+    trees.length > 0 &&
+    new MarkdownDocument().$concat(...trees.map(tree => treeSection(tree)));
 
   return new MarkdownDocument().details(
     detailsValue,
-    new MarkdownDocument().$concat(tableSectionContent, issuesSectionContent),
+    new MarkdownDocument().$concat(
+      tableSectionContent,
+      treesSectionContent,
+      issuesSectionContent,
+    ),
   );
 }
 
diff --git a/packages/utils/src/lib/reports/generate-md-report.unit.test.ts b/packages/utils/src/lib/reports/generate-md-report.unit.test.ts
index 671f6b4b8..daff690c7 100644
--- a/packages/utils/src/lib/reports/generate-md-report.unit.test.ts
+++ b/packages/utils/src/lib/reports/generate-md-report.unit.test.ts
@@ -339,6 +339,56 @@ describe('auditDetails', () => {
     expect(md).not.toMatch('#### Issues');
   });
 
+  it('should display tree section if trees are present', () => {
+    const md = auditDetails({
+      slug: 'line-coverage',
+      title: 'Line coverage',
+      score: 0.7,
+      value: 70,
+      displayValue: '70 %',
+      details: {
+        trees: [
+          {
+            type: 'coverage',
+            title: 'Line coverage',
+            root: {
+              name: '.',
+              values: { coverage: 0.7 },
+              children: [
+                {
+                  name: 'src',
+                  values: { coverage: 0.7 },
+                  children: [
+                    {
+                      name: 'App.tsx',
+                      values: {
+                        coverage: 0.8,
+                        missing: [{ startLine: 42, endLine: 50 }],
+                      },
+                    },
+                    {
+                      name: 'index.ts',
+                      values: {
+                        coverage: 0,
+                        missing: [{ startLine: 1, endLine: 10 }],
+                      },
+                    },
+                  ],
+                },
+              ],
+            },
+          },
+        ],
+      },
+    } as AuditReport).toString();
+    expect(md).toMatch('<details>');
+    expect(md).toMatch('#### Line coverage');
+    expect(md).toContain('```');
+    expect(md).toContain('└── src');
+    expect(md).toContain('├── App.tsx');
+    expect(md).not.toMatch('#### Issues');
+  });
+
   it('should render complete details section', () => {
     expect(
       auditDetails({
diff --git a/packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.txt b/packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.txt
new file mode 100644
index 000000000..ab06b1990
--- /dev/null
+++ b/packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.txt
@@ -0,0 +1,3 @@
+https://example.com
+├── https://example.com/styles/base.css
+└── https://example.com/styles/theme.css
diff --git a/packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.with-nesting.txt b/packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.with-nesting.txt
new file mode 100644
index 000000000..b306ab491
--- /dev/null
+++ b/packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.with-nesting.txt
@@ -0,0 +1,15 @@
+.
+└── src
+    ├── app
+    │   ├── components
+    │   │   ├── login
+    │   │   │   └── login.component.ts
+    │   │   └── platform
+    │   │       └── platform.component.ts
+    │   ├── services
+    │   │   ├── api-client.service.ts
+    │   │   └── auth.service.ts
+    │   ├── app.component.ts
+    │   ├── app.config.ts
+    │   └── app.routes.ts
+    └── main.ts
diff --git a/packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.with-values.txt b/packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.with-values.txt
new file mode 100644
index 000000000..78f4d1c12
--- /dev/null
+++ b/packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.with-values.txt
@@ -0,0 +1,3 @@
+https://example.com
+├── https://example.com/styles/base.css      2 kB   20
+└── https://example.com/styles/theme.css    10 kB  100
diff --git a/packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.txt b/packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.txt
new file mode 100644
index 000000000..1e20c853d
--- /dev/null
+++ b/packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.txt
@@ -0,0 +1,15 @@
+.                                             72.00 %
+└── src                                       72.00 %
+    ├── app                                   88.00 %
+    │   ├── components                        68.00 %
+    │   │   ├── login                         48.00 %
+    │   │   │   └── login.component.ts        48.00 %
+    │   │   └── platform                      74.00 %
+    │   │       └── platform.component.ts     74.00 %
+    │   ├── services                          97.00 %
+    │   │   ├── api-client.service.ts         99.00 %
+    │   │   └── auth.service.ts               94.00 %
+    │   ├── app.component.ts                  92.00 %
+    │   ├── app.config.ts                    100.00 %
+    │   └── app.routes.ts                    100.00 %
+    └── main.ts                                0.00 %
diff --git a/packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.with-missing-lines.txt b/packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.with-missing-lines.txt
new file mode 100644
index 000000000..18b4a9044
--- /dev/null
+++ b/packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.with-missing-lines.txt
@@ -0,0 +1,8 @@
+.                              70.00 %
+└── src                        45.39 %
+    ├── components             97.36 %
+    │   ├── CreateTodo.jsx    100.00 %
+    │   ├── TodoFilter.jsx     90.90 %  18
+    │   └── TodoList.jsx      100.00 %
+    └── hooks                   0.00 %
+        └── useTodos.js         0.00 %  1-73
diff --git a/packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.with-missing-named.txt b/packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.with-missing-named.txt
new file mode 100644
index 000000000..7a8e20d66
--- /dev/null
+++ b/packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.with-missing-named.txt
@@ -0,0 +1,7 @@
+.                          70.00 %
+└── src                    70.00 %
+    ├── components         80.00 %
+    │   ├── App.tsx        75.00 %  login (42-50), logout (52-55)
+    │   └── Layout.tsx    100.00 %
+    ├── index.ts          100.00 %
+    └── utils.ts            0.00 %  ErrorBoundary (1-10)
diff --git a/packages/utils/src/lib/text-formats/ascii/tree.ts b/packages/utils/src/lib/text-formats/ascii/tree.ts
new file mode 100644
index 000000000..0ab2fefa8
--- /dev/null
+++ b/packages/utils/src/lib/text-formats/ascii/tree.ts
@@ -0,0 +1,118 @@
+import type {
+  BasicTreeNode,
+  CoverageTreeNode,
+  Tree,
+} from '@code-pushup/models';
+
+type TreeNode = BasicTreeNode | CoverageTreeNode;
+
+const INDENT_CHARS = 4;
+const COL_GAP = 2;
+
+export function formatAsciiTree(tree: Tree): string {
+  const nodes = flatten(tree.root);
+  const maxWidth = Math.max(
+    ...nodes.map(({ node, level }) => level * INDENT_CHARS + node.name.length),
+  );
+  const keysMaxWidths =
+    tree.type === 'coverage'
+      ? {}
+      : nodes.reduce<Record<string, number>>(
+          (acc, { node }) => ({
+            ...acc,
+            ...Object.fromEntries(
+              Object.entries(node.values ?? {}).map(([key, value]) => [
+                key,
+                Math.max(acc[key] ?? 0, `${value}`.length),
+              ]),
+            ),
+          }),
+          {},
+        );
+
+  return formatTreeNode(tree.root, '', maxWidth, keysMaxWidths)
+    .map(line => `${line}\n`)
+    .join('');
+}
+
+function formatTreeNode(
+  node: TreeNode,
+  prefix: string,
+  maxWidth: number,
+  keysMaxWidths: Record<string, number>,
+): string[] {
+  const childPrefix = prefix.replace(/[└─]/g, ' ').replace(/├/g, '│');
+
+  const prefixedName = `${prefix}${node.name}`;
+  const padding = ' '.repeat(maxWidth + COL_GAP * 2 - prefixedName.length);
+  const values = formatNodeValues(node.values, keysMaxWidths);
+  const offsetValues = values ? `${padding}${values}` : '';
+  const formattedNode = `${prefixedName}${offsetValues}`;
+
+  return [
+    formattedNode,
+    ...(node.children?.flatMap((child, i, arr) =>
+      formatTreeNode(
+        child,
+        i === arr.length - 1 ? `${childPrefix}└── ` : `${childPrefix}├── `,
+        maxWidth,
+        keysMaxWidths,
+      ),
+    ) ?? []),
+  ];
+}
+
+function formatNodeValues(
+  values: TreeNode['values'],
+  keysMaxWidths: Record<string, number>,
+): string {
+  if (!values) {
+    return '';
+  }
+
+  if ('coverage' in values && typeof values.coverage === 'number') {
+    const percentage = coveragePercentage(values.coverage);
+    const maxLength = coveragePercentage(1).length;
+    const formattedCoverage = `${percentage.padStart(maxLength, ' ')} %`;
+    if (!Array.isArray(values.missing) || values.missing.length === 0) {
+      return formattedCoverage;
+    }
+    const formattedMissing = values.missing
+      .map(({ name, startLine, endLine }): string => {
+        const range =
+          startLine === endLine
+            ? startLine.toString()
+            : `${startLine}-${endLine}`;
+        return name ? `${name} (${range})` : range;
+      })
+      .join(', ');
+    return `${formattedCoverage}${' '.repeat(COL_GAP)}${formattedMissing}`;
+  }
+
+  const valuesMap = new Map(
+    Object.entries(values).filter(
+      (pair): pair is [string, string | number] =>
+        typeof pair[1] === 'string' || typeof pair[1] === 'number',
+    ),
+  );
+  return Object.entries(keysMaxWidths)
+    .map(([key, maxWidth]) => {
+      const value = valuesMap.get(key)?.toString() ?? '';
+      return value.padStart(maxWidth, ' ');
+    })
+    .join(' '.repeat(COL_GAP));
+}
+
+function flatten(
+  node: TreeNode,
+  level = 0,
+): { node: TreeNode; level: number }[] {
+  return [
+    { node, level },
+    ...(node.children?.flatMap(child => flatten(child, level + 1)) ?? []),
+  ];
+}
+
+function coveragePercentage(coverage: number): string {
+  return (coverage * 100).toFixed(2);
+}
diff --git a/packages/utils/src/lib/text-formats/ascii/tree.unit.test.ts b/packages/utils/src/lib/text-formats/ascii/tree.unit.test.ts
new file mode 100644
index 000000000..5d6061ed8
--- /dev/null
+++ b/packages/utils/src/lib/text-formats/ascii/tree.unit.test.ts
@@ -0,0 +1,289 @@
+import { formatAsciiTree } from './tree.js';
+
+describe('formatAsciiTree', () => {
+  it('should format basic tree', () => {
+    expect(
+      formatAsciiTree({
+        root: {
+          name: 'https://example.com',
+          children: [
+            { name: 'https://example.com/styles/base.css' },
+            { name: 'https://example.com/styles/theme.css' },
+          ],
+        },
+      }),
+    ).toMatchFileSnapshot('__snapshots__/basic-tree.txt');
+  });
+
+  it('should format basic tree with multi-level nesting', () => {
+    expect(
+      formatAsciiTree({
+        root: {
+          name: '.',
+          children: [
+            {
+              name: 'src',
+              children: [
+                {
+                  name: 'app',
+                  children: [
+                    {
+                      name: 'components',
+                      children: [
+                        {
+                          name: 'login',
+                          children: [{ name: 'login.component.ts' }],
+                        },
+                        {
+                          name: 'platform',
+                          children: [{ name: 'platform.component.ts' }],
+                        },
+                      ],
+                    },
+                    {
+                      name: 'services',
+                      children: [
+                        { name: 'api-client.service.ts' },
+                        { name: 'auth.service.ts' },
+                      ],
+                    },
+                    { name: 'app.component.ts' },
+                    { name: 'app.config.ts' },
+                    { name: 'app.routes.ts' },
+                  ],
+                },
+                { name: 'main.ts' },
+              ],
+            },
+          ],
+        },
+      }),
+    ).toMatchFileSnapshot('__snapshots__/basic-tree.with-nesting.txt');
+  });
+
+  it('should format basic tree with custom node values', () => {
+    expect(
+      formatAsciiTree({
+        root: {
+          name: 'https://example.com',
+          children: [
+            {
+              name: 'https://example.com/styles/base.css',
+              values: { size: '2 kB', duration: 20 },
+            },
+            {
+              name: 'https://example.com/styles/theme.css',
+              values: { size: '10 kB', duration: 100 },
+            },
+          ],
+        },
+      }),
+    ).toMatchFileSnapshot('__snapshots__/basic-tree.with-values.txt');
+  });
+
+  it('should format coverage tree', () => {
+    expect(
+      formatAsciiTree({
+        type: 'coverage',
+        root: {
+          name: '.',
+          values: { coverage: 0.72 },
+          children: [
+            {
+              name: 'src',
+              values: { coverage: 0.72 },
+              children: [
+                {
+                  name: 'app',
+                  values: { coverage: 0.88 },
+                  children: [
+                    {
+                      name: 'components',
+                      values: { coverage: 0.68 },
+                      children: [
+                        {
+                          name: 'login',
+                          values: { coverage: 0.48 },
+                          children: [
+                            {
+                              name: 'login.component.ts',
+                              values: { coverage: 0.48 },
+                            },
+                          ],
+                        },
+                        {
+                          name: 'platform',
+                          values: { coverage: 0.74 },
+                          children: [
+                            {
+                              name: 'platform.component.ts',
+                              values: { coverage: 0.74 },
+                            },
+                          ],
+                        },
+                      ],
+                    },
+                    {
+                      name: 'services',
+                      values: { coverage: 0.97 },
+                      children: [
+                        {
+                          name: 'api-client.service.ts',
+                          values: { coverage: 0.99 },
+                        },
+                        {
+                          name: 'auth.service.ts',
+                          values: { coverage: 0.94 },
+                        },
+                      ],
+                    },
+                    {
+                      name: 'app.component.ts',
+                      values: { coverage: 0.92 },
+                    },
+                    {
+                      name: 'app.config.ts',
+                      values: { coverage: 1 },
+                    },
+                    {
+                      name: 'app.routes.ts',
+                      values: { coverage: 1 },
+                    },
+                  ],
+                },
+                {
+                  name: 'main.ts',
+                  values: { coverage: 0 },
+                },
+              ],
+            },
+          ],
+        },
+      }),
+    ).toMatchFileSnapshot('__snapshots__/coverage-tree.txt');
+  });
+
+  it('should format coverage tree with missing lines', () => {
+    expect(
+      formatAsciiTree({
+        type: 'coverage',
+        title: 'Line coverage',
+        root: {
+          name: '.',
+          values: { coverage: 0.7 },
+          children: [
+            {
+              name: 'src',
+              values: { coverage: 0.4539 },
+              children: [
+                {
+                  name: 'components',
+                  values: { coverage: 0.9736 },
+                  children: [
+                    {
+                      name: 'CreateTodo.jsx',
+                      values: { coverage: 1 },
+                    },
+                    {
+                      name: 'TodoFilter.jsx',
+                      values: {
+                        coverage: 0.909,
+                        missing: [{ startLine: 18, endLine: 18 }],
+                      },
+                    },
+                    {
+                      name: 'TodoList.jsx',
+                      values: { coverage: 1 },
+                    },
+                  ],
+                },
+                {
+                  name: 'hooks',
+                  values: { coverage: 0 },
+                  children: [
+                    {
+                      name: 'useTodos.js',
+                      values: {
+                        coverage: 0,
+                        missing: [{ startLine: 1, endLine: 73 }],
+                      },
+                    },
+                  ],
+                },
+              ],
+            },
+          ],
+        },
+      }),
+    ).toMatchFileSnapshot('__snapshots__/coverage-tree.with-missing-lines.txt');
+  });
+
+  it('should format coverage tree with missing lines referencing entities in code', () => {
+    expect(
+      formatAsciiTree({
+        type: 'coverage',
+        title: 'Docs coverage',
+        root: {
+          name: '.',
+          values: { coverage: 0.7 },
+          children: [
+            {
+              name: 'src',
+              values: { coverage: 0.7 },
+              children: [
+                {
+                  name: 'components',
+                  values: { coverage: 0.8 },
+                  children: [
+                    {
+                      name: 'App.tsx',
+                      values: {
+                        coverage: 0.75,
+                        missing: [
+                          {
+                            startLine: 42,
+                            endLine: 50,
+                            name: 'login',
+                            kind: 'function',
+                          },
+                          {
+                            startLine: 52,
+                            endLine: 55,
+                            name: 'logout',
+                            kind: 'function',
+                          },
+                        ],
+                      },
+                    },
+                    {
+                      name: 'Layout.tsx',
+                      values: { coverage: 1 },
+                    },
+                  ],
+                },
+                {
+                  name: 'index.ts',
+                  values: { coverage: 1 },
+                },
+                {
+                  name: 'utils.ts',
+                  values: {
+                    coverage: 0,
+                    missing: [
+                      {
+                        startLine: 1,
+                        endLine: 10,
+                        name: 'ErrorBoundary',
+                        kind: 'class',
+                      },
+                    ],
+                  },
+                },
+              ],
+            },
+          ],
+        },
+      }),
+    ).toMatchFileSnapshot('__snapshots__/coverage-tree.with-missing-named.txt');
+  });
+});