ci(client): lint:dup holds copied code to a baseline that only shrinks

Most features have a desktop view and a phone view, and each carried its
own copy of the feature's logic, so SonarCloud's duplication condition
was the gate a pull request touching both failed most often, and only
after a scan of twenty minutes. lint:dup finds the same copies before
the push.

It runs jscpd's detector (@jscpd/core with @jscpd/tokenizer) over the
source files under src/ at the thresholds the SonarCloud gate applies to
TypeScript, 100 tokens over at least 10 lines, and counts per file the
lines inside a block that repeats elsewhere or in the same file. Tests
and declaration files are left out as Sonar leaves them out, and .ts and
.tsx are read as one language, so a hook that repeats a view's code is a
copy too. The files are read sorted and with readText, so every machine
counts the same. scripts/dup-baseline.json holds the files that still
hold copies with their count; a file may hold no more than its entry, a
file without an entry none, and an entry above its file or for a file
that is gone fails until --update lowers it. --list prints every block
with its other side.

The engine packages are used instead of the jscpd command, which walks
the tree in file system order, skips files past its size limits without
a word and drops every result once it meets a file it has no format for.
This commit is contained in:
Maurice
2026-10-08 20:54:53 +00:00
parent 2ccdba78d6
commit 88d196bae8
10 changed files with 404 additions and 4 deletions
+37
View File
@@ -0,0 +1,37 @@
{
"src/components/Admin/AddonManager.tsx": 23,
"src/components/Files/FileManagerMarkdownPreviewModal.tsx": 19,
"src/components/Files/FileManagerPdfPreviewModal.tsx": 19,
"src/components/Map/MapView.tsx": 56,
"src/components/Map/MapViewGL.tsx": 51,
"src/components/Map/ReservationOverlay.tsx": 75,
"src/components/Map/RouteCalculator.ts": 27,
"src/components/Map/reservationsMapbox.ts": 72,
"src/components/Packing/ApplyTemplateButton.tsx": 19,
"src/components/Packing/PackingListPanelHeader.tsx": 16,
"src/components/Planner/AddressInput.tsx": 38,
"src/components/Planner/AirportSelect.tsx": 44,
"src/components/Planner/DayPlanSidebar.tsx": 326,
"src/components/Planner/LocationSelect.tsx": 56,
"src/components/Planner/ReservationModal.tsx": 26,
"src/components/Planner/TransportModal.tsx": 26,
"src/components/Studio/StudioInspector.tsx": 19,
"src/components/Studio/StudioTravelInspector.tsx": 19,
"src/components/Studio/TravelElements.tsx": 10,
"src/components/Studio/roadRoute.ts": 17,
"src/components/shared/CustomTimePicker.tsx": 24,
"src/mobile/screens/admin/MAdminAddonManager.tsx": 23,
"src/mobile/screens/admin/MAdminStoragePanel.tsx": 38,
"src/mobile/screens/trip/sheets/MReservationSheet.tsx": 79,
"src/mobile/screens/trip/sheets/MTransportFormSheet.tsx": 139,
"src/mobile/screens/trip/tabs/MBookingsTab.tsx": 88,
"src/mobile/screens/trip/tabs/MTransportsTab.tsx": 90,
"src/mobile/screens/vacay/MVacayInviteSheet.tsx": 21,
"src/mobile/screens/vacay/MVacayShareSheet.tsx": 21,
"src/pages/TripPlannerPage.tsx": 82,
"src/pages/admin/GoogleOptions.tsx": 13,
"src/pages/admin/TrekApiCard.tsx": 14,
"src/pages/atlas/AtlasCountrySearch.tsx": 38,
"src/store/slices/budgetSlice.ts": 16,
"src/store/slices/remoteEventHandler.ts": 17
}
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env node
/*
* lint:dup: keeps copied code from coming back.
*
* Most features have a desktop view and a phone view, and each used to carry
* its own copy of the feature's logic, so a fix reached one of them and the
* other kept the bug. The logic now lives in one shared hook or module per
* feature, and this check holds the copies that remain to what they are. It
* counts, per file under src/, the lines inside a block of code that repeats
* elsewhere (or in the same file), at the thresholds SonarCloud uses on a
* pull request. The files that still hold such lines are listed in
* scripts/dup-baseline.json with their count, and a file may hold no more
* than its entry (a file without an entry holds none). An entry above what
* its file holds now, or for a file that is gone, fails as well until
* --update lowers it.
*
* npm run lint:dup check against the baseline (CI)
* npm run lint:dup -- --list print every copied block with its other side
* npm run lint:dup -- --update lower the baseline to what the files hold now;
* it never raises an entry
*
* The measuring lives in scripts/lib/dup.mjs.
*/
import { fileURLToPath } from 'node:url';
import { check } from './lib/dup.mjs';
import { runCli } from './lib/ratchet.mjs';
const root = fileURLToPath(new URL('..', import.meta.url));
await runCli('lint:dup', (args) => check({ root, update: args.includes('--update'), list: args.includes('--list') }));
+139
View File
@@ -0,0 +1,139 @@
/*
* The measuring and the comparison behind lint:dup (scripts/dup-lint.mjs).
*
* Copies are found by jscpd's detector at the thresholds SonarCloud applies to
* TypeScript: a block counts once MIN_TOKENS tokens repeat over at least
* MIN_LINES lines. Both .ts and .tsx are read as TypeScript, because Sonar
* treats them as one language and a hook in a .ts file that repeats a view's
* .tsx code is a copy all the same. A file's count is the number of its lines
* inside a copied block, the duplicated lines Sonar reports. Which of three
* copies a block is matched against depends on the order the files are read
* in, so they are read sorted by path, and with readText, so every machine
* counts the same.
*/
import { Detector, getModeHandler, MemoryStore } from '@jscpd/core';
import { Tokenizer } from '@jscpd/tokenizer';
import { join } from 'node:path';
import {
countMap,
listFiles,
lowerCounts,
readBaseline,
readText,
reportStale,
staleCounts,
TEST_FILE,
toKey,
writeBaseline,
} from './ratchet.mjs';
export const MIN_TOKENS = 100;
export const MIN_LINES = 10;
export const DIRS = ['src'];
/** The files SonarCloud analyses as source: tests and declaration files are left out. */
export function accepts(key) {
return /\.tsx?$/.test(key) && !key.endsWith('.d.ts') && !TEST_FILE.test(key);
}
/**
* Every copied block under root as a pair of line ranges, one per side. Both
* sides can be the same file.
*
* @returns {Promise<{ key: string, start: number, end: number }[][]>}
*/
export async function findCopies(root) {
const keys = listFiles(root, DIRS, accepts)
.map((path) => toKey(root, path))
.sort();
const detector = new Detector(new Tokenizer(), new MemoryStore(), [], {
minTokens: MIN_TOKENS,
minLines: MIN_LINES,
mode: getModeHandler('mild'),
});
const side = ({ sourceId, start, end }) => ({ key: sourceId, start: start.line, end: end.line });
const copies = [];
for (const key of keys) {
const clones = await detector.detect(key, readText(join(root, key)), 'typescript');
for (const clone of clones) copies.push([side(clone.duplicationA), side(clone.duplicationB)]);
}
return copies;
}
/** Per file in path order, how many of its lines sit inside a copied block. */
export function countLines(copies) {
const lines = {};
for (const pair of copies) {
for (const { key, start, end } of pair) {
const set = (lines[key] ??= new Set());
for (let line = start; line <= end; line++) set.add(line);
}
}
return Object.fromEntries(
Object.entries(lines)
.filter(([, set]) => set.size > 0)
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([key, set]) => [key, set.size])
);
}
/** Per file, its copied blocks as "start-end = other:start-end", in line order. */
export function describeCopies(copies) {
const found = {};
const range = ({ start, end }) => `${start}-${end}`;
for (const [a, b] of copies) {
(found[a.key] ??= []).push({ at: a.start, text: `${range(a)} = ${b.key}:${range(b)}` });
(found[b.key] ??= []).push({ at: b.start, text: `${range(b)} = ${a.key}:${range(a)}` });
}
return Object.fromEntries(
Object.entries(found).map(([key, list]) => [key, list.sort((x, y) => x.at - y.at).map(({ text }) => text)])
);
}
/** Runs the check against the baseline at baselinePath and returns the exit code. */
export async function check({
root,
baselinePath = join(root, 'scripts/dup-baseline.json'),
update = false,
list = false,
log = console.log,
error = console.error,
}) {
const copies = await findCopies(root);
const counts = countLines(copies);
const blocks = describeCopies(copies);
let baseline = readBaseline(baselinePath, countMap);
if (list) {
for (const [key, n] of Object.entries(counts)) {
log(`${key} (${n}, baseline ${baseline[key] ?? 0})`);
for (const line of blocks[key]) log(` ${line}`);
}
}
if (update) {
baseline = lowerCounts(baseline, counts);
writeBaseline(baselinePath, baseline);
}
const grown = Object.entries(counts).filter(([key, n]) => n > (baseline[key] ?? 0));
const stale = staleCounts(baseline, counts);
for (const [key, n] of grown) {
error(`FAIL ${key}: ${n} line(s) in copied blocks, baseline ${baseline[key] ?? 0}:`);
for (const line of blocks[key]) error(` ${line}`);
}
if (grown.length) {
error(
'Put the code both places need in one shared hook or module and import it from both instead of copying it ' +
'(useInstanceSettings and useRangeBypass under src/components/Admin/ are the pattern).'
);
}
reportStale(stale, { file: 'dup-baseline.json', command: 'lint:dup', root, error });
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
log(
`dup: ${sum(counts)} line(s) in copied blocks in ${Object.keys(counts).length} file(s), baseline allows ${sum(baseline)}`
);
return grown.length || stale.length ? 1 : 0;
}