Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions assets/refactor-first-report.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@
<caption>Class relationships to remove, in priority order</caption>
<thead>
<tr>
<th scope="col"{{#classRelationshipsToRemove.tableUi.sortable}} aria-sort="{{classRelationshipsToRemove.tableUi.colSort.0}}"{{/classRelationshipsToRemove.tableUi.sortable}}><button type="button" class="rf-sort-btn" data-rf-sort="class-relationships" data-sort-key="renderedLabel">Class Relationship <span class="rf-sort-indicator" aria-hidden="true">{{classRelationshipsToRemove.tableUi.colIndicator.0}}</span></button></th>
<th scope="col"{{#classRelationshipsToRemove.tableUi.sortable}} aria-sort="{{classRelationshipsToRemove.tableUi.colSort.0}}"{{/classRelationshipsToRemove.tableUi.sortable}}><button type="button" class="rf-sort-btn" data-rf-sort="class-relationships" data-sort-key="classRelationship">Class Relationship <span class="rf-sort-indicator" aria-hidden="true">{{classRelationshipsToRemove.tableUi.colIndicator.0}}</span></button></th>
<th scope="col"{{#classRelationshipsToRemove.tableUi.sortable}} aria-sort="{{classRelationshipsToRemove.tableUi.colSort.1}}"{{/classRelationshipsToRemove.tableUi.sortable}}><button type="button" class="rf-sort-btn" data-rf-sort="class-relationships" data-sort-key="priority">Priority <span class="rf-sort-indicator" aria-hidden="true">{{classRelationshipsToRemove.tableUi.colIndicator.1}}</span></button></th>
<th scope="col"{{#classRelationshipsToRemove.tableUi.sortable}} aria-sort="{{classRelationshipsToRemove.tableUi.colSort.2}}"{{/classRelationshipsToRemove.tableUi.sortable}}><button type="button" class="rf-sort-btn" data-rf-sort="class-relationships" data-sort-key="cycleCount">In Class<br>Cycles <span class="rf-sort-indicator" aria-hidden="true">{{classRelationshipsToRemove.tableUi.colIndicator.2}}</span></button></th>
<th scope="col"{{#classRelationshipsToRemove.tableUi.sortable}} aria-sort="{{classRelationshipsToRemove.tableUi.colSort.3}}"{{/classRelationshipsToRemove.tableUi.sortable}}><button type="button" class="rf-sort-btn" data-rf-sort="class-relationships" data-sort-key="effortRank">Relationship<br>Strength <span class="rf-sort-indicator" aria-hidden="true">{{classRelationshipsToRemove.tableUi.colIndicator.3}}</span></button></th>
Expand Down Expand Up @@ -569,7 +569,7 @@
<caption>Package relationships to remove, in priority order</caption>
<thead>
<tr>
<th scope="col"{{#packageRelationshipsToRemove.tableUi.sortable}} aria-sort="{{packageRelationshipsToRemove.tableUi.colSort.0}}"{{/packageRelationshipsToRemove.tableUi.sortable}}><button type="button" class="rf-sort-btn" data-rf-sort="package-relationships" data-sort-key="renderedLabel">Package Relationship <span class="rf-sort-indicator" aria-hidden="true">{{packageRelationshipsToRemove.tableUi.colIndicator.0}}</span></button></th>
<th scope="col"{{#packageRelationshipsToRemove.tableUi.sortable}} aria-sort="{{packageRelationshipsToRemove.tableUi.colSort.0}}"{{/packageRelationshipsToRemove.tableUi.sortable}}><button type="button" class="rf-sort-btn" data-rf-sort="package-relationships" data-sort-key="packageRelationship">Package Relationship <span class="rf-sort-indicator" aria-hidden="true">{{packageRelationshipsToRemove.tableUi.colIndicator.0}}</span></button></th>
<th scope="col"{{#packageRelationshipsToRemove.tableUi.sortable}} aria-sort="{{packageRelationshipsToRemove.tableUi.colSort.1}}"{{/packageRelationshipsToRemove.tableUi.sortable}}><button type="button" class="rf-sort-btn" data-rf-sort="package-relationships" data-sort-key="priority">Priority <span class="rf-sort-indicator" aria-hidden="true">{{packageRelationshipsToRemove.tableUi.colIndicator.1}}</span></button></th>
<th scope="col"{{#packageRelationshipsToRemove.tableUi.sortable}} aria-sort="{{packageRelationshipsToRemove.tableUi.colSort.2}}"{{/packageRelationshipsToRemove.tableUi.sortable}}><button type="button" class="rf-sort-btn" data-rf-sort="package-relationships" data-sort-key="cycleCount">In Pkg<br>Cycles <span class="rf-sort-indicator" aria-hidden="true">{{packageRelationshipsToRemove.tableUi.colIndicator.2}}</span></button></th>
<th scope="col"{{#packageRelationshipsToRemove.tableUi.sortable}} aria-sort="{{packageRelationshipsToRemove.tableUi.colSort.3}}"{{/packageRelationshipsToRemove.tableUi.sortable}}><button type="button" class="rf-sort-btn" data-rf-sort="package-relationships" data-sort-key="effortRank">Relationship<br>Strength <span class="rf-sort-indicator" aria-hidden="true">{{packageRelationshipsToRemove.tableUi.colIndicator.3}}</span></button></th>
Expand Down Expand Up @@ -805,7 +805,7 @@
<tbody>
{{#classCycles.largestCycle.breakdown}}
<tr>
<td class="rf-text-left">{{{className}}}</td>
<td class="rf-text-left">{{{safeClassName}}}</td>
<td class="rf-text-left">{{{edgesHtml}}}</td>
</tr>
{{/classCycles.largestCycle.breakdown}}
Expand Down
213 changes: 200 additions & 13 deletions lib/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// sanitizes the final HTML.
import Mustache from 'mustache';
import DOMPurify from 'dompurify';
import { sourceUrl } from './utils.js';
import {
TABLE_CONFIG,
REPORT_TABLES,
Expand Down Expand Up @@ -67,6 +68,157 @@ function sanitizeRelationshipLabel(label) {
});
}

/**
* Escapes a value for safe interpolation into HTML text or a double-quoted
* attribute. Relationship fields (repoUrl, class paths, simple names) are
* untrusted report data, so they are escaped before the markup is built.
*
* @param {*} value - Raw value from the report JSON.
* @returns {string} HTML-escaped text.
*/
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

const RELATIONSHIP_ARROW = ' &#8594; ';

/**
* Builds the two-anchor markup shared by class and package relationships:
* linked source name (plus removal marker), the &#8594; arrow, then the linked
* target name (plus removal marker). All values are untrusted report data and
* are escaped before the markup is assembled.
*
* @param {string} repoUrl - Repository URL prefix from project metadata.
* @param {{path: *, name: *, marked: *}} source - Source path, name and marker.
* @param {{path: *, name: *, marked: *}} target - Target path, name and marker.
* @returns {string} The unsanitized relationship markup.
*/
function relationshipAnchorsMarkup(repoUrl, source, target) {
const anchor = (relationshipPath, name) =>
'<a href="' + escapeHtml(sourceUrl(repoUrl, String(relationshipPath ?? '')) ?? '') + '" target="_blank">'
+ escapeHtml(name) + '</a>';
return anchor(source.path, source.name)
+ (source.marked ? '*' : '')
+ RELATIONSHIP_ARROW
+ anchor(target.path, target.name)
+ (target.marked ? '*' : '');
}

/**
* Builds the raw markup of one class relationship from the structured
* ClassRelationshipDTO fields: the hyperlink targets are project.repoUrl plus
* sourceClassPath/targetClassPath (paths relative to the project root), the
* anchor texts are the simple class names, and the two anchors are separated
* by the &#8594; arrow. A class marked for removal is followed by *.
*
* Entries without the structured fields (pre-DTO strings, legacy entries
* carrying only a renderedLabel) yield null so callers can drop them.
*
* @param {*} relationship - Raw ClassRelationshipDTO entry.
* @param {string} repoUrl - Repository URL prefix from project metadata.
* @returns {string|null} The unsanitized relationship markup, or null when the
* entry is not a structured ClassRelationshipDTO.
*/
export function classRelationshipMarkup(relationship, repoUrl) {
if (!relationship || typeof relationship !== 'object') return null;
const hasStructuredFields = relationship.simpleSourceClassName != null
|| relationship.simpleTargetClassName != null;
if (!hasStructuredFields) return null;
return relationshipAnchorsMarkup(
repoUrl,
{ path: relationship.sourceClassPath, name: relationship.simpleSourceClassName, marked: relationship.sourceMarked },
{ path: relationship.targetClassPath, name: relationship.simpleTargetClassName, marked: relationship.targetMarked });
}

/**
* Builds the raw markup of one package relationship from the structured
* PackageRelationshipDTO fields: the hyperlink targets are project.repoUrl plus
* sourcePackagePath/targetPackagePath (package directories relative to the
* project root), and the anchor texts are the package names themselves. As with
* class relationships, the two anchors are separated by the &#8594; arrow and
* a package marked for removal is followed by *.
*
* @param {*} relationship - Raw PackageRelationshipDTO entry.
* @param {string} repoUrl - Repository URL prefix from project metadata.
* @returns {string|null} The unsanitized relationship markup, or null when the
* entry is not a structured PackageRelationshipDTO.
*/
export function packageRelationshipMarkup(relationship, repoUrl) {
if (!relationship || typeof relationship !== 'object') return null;
const hasStructuredFields = relationship.sourcePackage != null
|| relationship.targetPackage != null;
if (!hasStructuredFields) return null;
return relationshipAnchorsMarkup(
repoUrl,
{ path: relationship.sourcePackagePath, name: relationship.sourcePackage, marked: relationship.sourceMarked },
{ path: relationship.targetPackagePath, name: relationship.targetPackage, marked: relationship.targetMarked });
}

/**
* Builds the sanitized class-relationship label for template interpolation.
* The markup is derived from untrusted report data, so it is still
* restricted to links and markers before it reaches the template.
*
* @param {*} relationship - Raw ClassRelationshipDTO entry.
* @param {string} repoUrl - Repository URL prefix from project metadata.
* @returns {string} Sanitized label markup ('' when the entry has no fields).
*/
function safeClassRelationshipLabel(relationship, repoUrl) {
registerLinkSafetyHook();
const markup = classRelationshipMarkup(relationship, repoUrl);
return markup == null ? '' : sanitizeRelationshipLabel(markup);
}

/**
* Builds the source-link markup for one disharmony table cell that carries a
* path relative to the project root: the hyperlink target is project.repoUrl
* plus the path, and the cell's existing text becomes the anchor text. The
* markup is sanitized down to a hardened link before it reaches the template;
* cells without a path are passed through untouched.
*
* @param {object} cell - Raw disharmony table cell.
* @param {string} repoUrl - Repository URL prefix from project metadata.
* @returns {object} The cell, or a copy whose content is the sanitized link.
*/
function prepareDisharmonyCell(cell, repoUrl) {
if (!cell || typeof cell !== 'object' || cell.path == null) return cell;
registerLinkSafetyHook();
const markup = '<a href="' + escapeHtml(sourceUrl(repoUrl, String(cell.path)) ?? '') + '" target="_blank">'
+ escapeHtml(cell.content) + '</a>';
return { ...cell, content: sanitizeRelationshipLabel(markup) };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

/**
* Builds the sanitized class-cell markup for one largest-cycle breakdown row
* from the structured CycleBreakdownRowDTO fields: the hyperlink target is
* project.repoUrl plus classPath, the anchor text is the simple class name,
* and a class marked for removal is followed by *. Rows without a path render
* as plain names (with the marker); the markup is sanitized to links and
* markers before it reaches the template.
*
* @param {object} row - Raw breakdown row.
* @param {string} repoUrl - Repository URL prefix from project metadata.
* @returns {object} Row copy with a restricted safeClassName label.
*/
function prepareCycleBreakdownRow(row, repoUrl) {
if (!row || typeof row !== 'object') return row;
registerLinkSafetyHook();
let markup;
if (row.classPath != null) {
markup = '<a href="' + escapeHtml(sourceUrl(repoUrl, String(row.classPath)) ?? '') + '" target="_blank">'
+ escapeHtml(row.className) + '</a>';
} else {
markup = escapeHtml(row.className);
}
if (row.marked) markup += '*';
return { ...row, safeClassName: sanitizeRelationshipLabel(markup) };
}

/**
* Adapts one "class relationship to break a package cycle" entry for the
* template. Reports serialize structured ClassRelationshipDTO objects, and
Expand All @@ -75,30 +227,50 @@ function sanitizeRelationshipLabel(label) {
* of any other shape (including pre-DTO rendered-label strings) are dropped.
*
* @param {*} entry - Raw class-break entry.
* @param {string} repoUrl - Repository URL prefix from project metadata.
* @returns {string|null} Sanitized label markup, or null when the entry is
* not a valid ClassRelationshipDTO object.
*/
function prepareClassBreakEntry(entry) {
function prepareClassBreakEntry(entry, repoUrl) {
if (!entry || typeof entry !== 'object') return null;
return sanitizeRelationshipLabel(entry.renderedLabel);
const markup = classRelationshipMarkup(entry, repoUrl);
return markup == null ? null : sanitizeRelationshipLabel(markup);
}

/**
* Copies a class relationship with its label markup built from repoUrl, the
* class paths and the simple class names, restricted to links and markers.
* Never trust a precomputed value from JSON.
*
* @param {object} relationship - Raw class relationship data.
* @param {string} repoUrl - Repository URL prefix from project metadata.
* @returns {object} Relationship with a restricted HTML label.
*/
function prepareClassRelationship(relationship, repoUrl) {
return {
...relationship,
safeRenderedLabel: safeClassRelationshipLabel(relationship, repoUrl)
};
}

/**
* Copies a relationship with label markup limited to source links and markers,
* and normalizes its class-break entries the same way. Never trust a
* precomputed value from JSON.
* Copies a package relationship with label markup limited to links and
* markers, and normalizes its class-break entries the same way. Never trust
* a precomputed value from JSON.
*
* @param {object} relationship - Raw relationship data.
* @param {object} relationship - Raw package relationship data.
* @param {string} repoUrl - Repository URL prefix from project metadata.
* @returns {object} Relationship with restricted HTML labels.
*/
function prepareRelationship(relationship) {
function preparePackageRelationship(relationship, repoUrl) {
registerLinkSafetyHook();
const markup = packageRelationshipMarkup(relationship, repoUrl);
return {
...relationship,
safeRenderedLabel: sanitizeRelationshipLabel(relationship.renderedLabel),
safeRenderedLabel: markup == null ? '' : sanitizeRelationshipLabel(markup),
classRelationshipsToBreakPackage: Array.isArray(relationship.classRelationshipsToBreakPackage)
? relationship.classRelationshipsToBreakPackage
.map(prepareClassBreakEntry)
.map(entry => prepareClassBreakEntry(entry, repoUrl))
.filter(entry => entry !== null)
: []
};
Expand Down Expand Up @@ -218,13 +390,15 @@ export function prepareReportData(data, tableStates = {}, config = TABLE_CONFIG)
const result = { ...data };
const stateOf = tableId => tableStates?.[tableId];

const repoUrl = data.project?.repoUrl;

if (Array.isArray(data.classRelationshipsToRemove?.relationships)) {
const descriptor = REPORT_TABLES['class-relationships'];
const { rows, ui } = buildTableSlice(
descriptor, data.classRelationshipsToRemove.relationships, stateOf(descriptor.id), config);
result.classRelationshipsToRemove = {
...data.classRelationshipsToRemove,
relationships: rows.map(prepareRelationship),
relationships: rows.map(row => prepareClassRelationship(row, repoUrl)),
tableUi: ui
};
}
Expand All @@ -235,7 +409,7 @@ export function prepareReportData(data, tableStates = {}, config = TABLE_CONFIG)
descriptor, data.packageRelationshipsToRemove.relationships, stateOf(descriptor.id), config);
result.packageRelationshipsToRemove = {
...data.packageRelationshipsToRemove,
relationships: rows.map(prepareRelationship),
relationships: rows.map(row => preparePackageRelationship(row, repoUrl)),
tableUi: ui
};
}
Expand All @@ -252,7 +426,20 @@ export function prepareReportData(data, tableStates = {}, config = TABLE_CONFIG)
indicator: ui.colIndicator[columnIndex] ?? '',
tableId: ui.tableId
}));
return { ...disharmony, ui, table: { ...disharmony.table, headerObjs, rows } };
return {
...disharmony,
ui,
table: {
...disharmony.table,
headerObjs,
rows: rows.map(row => ({
...row,
cells: Array.isArray(row.cells)
? row.cells.map(cell => prepareDisharmonyCell(cell, repoUrl))
: row.cells
}))
}
};
});
}

Expand All @@ -268,7 +455,7 @@ export function prepareReportData(data, tableStates = {}, config = TABLE_CONFIG)
breakdownDescriptor, largestCycle.breakdown, stateOf(breakdownDescriptor.id), config);
classCycles.largestCycle = {
...largestCycle,
breakdown: breakdown.rows,
breakdown: breakdown.rows.map(row => prepareCycleBreakdownRow(row, repoUrl)),
breakdownUi: breakdown.ui
};
}
Expand Down
Loading
Loading