Files
TREK/client/scripts/lib/pairs.mjs
T
Maurice feef872776 ci(client): lint:pairs keeps a desktop view and its phone twin on one hook
Most features have a desktop view and a phone view, and until now each
carried its own copy of the feature's logic, so a fix on one side drifted
away from the other. With the logic moved into one shared hook per
feature, scripts/feature-pairs.json names every feature with its hooks
and the desktop and phone files that run on them.

lint:pairs fails when a listed view stops importing one of its pair's
hooks (a type only import does not count), when a listed file is gone,
when a hook sits in a view layer rather than under components, hooks,
utils or the folder of a page under pages/, and when a file under
src/mobile/screens is in no pair and not under mobileOnly with the
reason it has no twin. A new phone screen is so either wired to the
desktop feature's hook or marked as phone only on purpose. lint:dup
catches the copies that look alike; this one catches the copies written
anew.

The manifest starts with 120 features and 62 phone only screen files,
each with the reason it stays on the phone alone.

client/CLAUDE.md says how to change a feature, add one with both views
and add a phone screen.
2026-10-09 15:20:10 +02:00

148 lines
6.1 KiB
JavaScript

/*
* The manifest and the checks behind lint:pairs (scripts/pairs-lint.mjs).
*/
import { existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { importsOf } from './imports.mjs';
import { listFiles, readBaseline, readText, TEST_FILE, toKey } from './ratchet.mjs';
/** The phone screens every entry of the manifest is weighed against. */
export const SCREENS = 'src/mobile/screens';
/**
* Where a shared hook may live: below the views that both import it, or next
* to a page in its folder under pages/ (use<Page> and its models), which the
* phone shell of that page imports as well. Never in a view itself.
*/
const SHARED = /^src\/(?:components|hooks|utils)\/|^src\/pages\/[^/]+\//;
const SOURCE = /\.tsx?$/;
const isPath = (value) => typeof value === 'string' && SOURCE.test(value) && !value.includes('\\');
const isPathList = (value) => Array.isArray(value) && value.length > 0 && value.every(isPath);
/**
* scripts/feature-pairs.json: pairs is a list of features, each with the
* shared hooks or models that hold its logic and the desktop and phone files
* that run on them; mobileOnly maps each phone screen file that has no
* desktop twin, or no logic a twin could share, to the reason.
*/
export function manifestShape(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return 'it is not an object';
const keys = Object.keys(value).sort().join(',');
if (keys !== 'mobileOnly,pairs') return 'it needs exactly the keys "pairs" and "mobileOnly"';
if (!Array.isArray(value.pairs)) return '"pairs" is not a list';
const names = new Set();
for (const pair of value.pairs) {
if (!pair || typeof pair !== 'object' || typeof pair.feature !== 'string' || !pair.feature.trim())
return `a pair without a feature name: ${JSON.stringify(pair)}`;
if (names.has(pair.feature)) return `the feature "${pair.feature}" is listed twice`;
names.add(pair.feature);
for (const field of ['hooks', 'desktop', 'mobile'])
if (!isPathList(pair[field]))
return `"${pair.feature}" needs "${field}" as a list of source files from the client root`;
}
const only = value.mobileOnly;
if (!only || typeof only !== 'object' || Array.isArray(only)) return '"mobileOnly" is not an object of file → reason';
for (const [key, reason] of Object.entries(only)) {
if (!isPath(key)) return `"${key}" in "mobileOnly" is not a source file from the client root`;
if (typeof reason !== 'string' || !reason.trim()) return `"${key}" in "mobileOnly" has no reason`;
}
return null;
}
/** The path a relative import lands on from the client root, without its extension. */
function targetOf(root, fromFile, specifier) {
if (!specifier.startsWith('.')) return null;
return toKey(root, resolve(dirname(fromFile), specifier)).replace(/(?:\/index)?(?:\.[cm]?[jt]sx?)?$/, '');
}
const stem = (key) => key.replace(/\.[cm]?[jt]sx?$/, '');
/** The modules a file imports for more than types, as paths from the root without extensions. */
function runtimeImports(root, key) {
const path = join(root, key);
return new Set(
importsOf(readText(path), path)
.filter((imp) => !imp.typeOnly)
.map((imp) => targetOf(root, path, imp.specifier))
.filter(Boolean)
);
}
/**
* Everything wrong with the manifest against the tree, as lines to print:
* a listed file that is gone, a hook outside the shared layers, a view of a
* pair that does not import every hook of the pair, a phone screen listed
* both as a pair and as phone only, and a phone screen that is in neither.
*/
export function problems(root, manifest) {
const found = [];
const listed = new Set();
const present = (key, where) => {
if (existsSync(join(root, key))) return true;
found.push(`${key} is listed under ${where} but does not exist`);
return false;
};
for (const pair of manifest.pairs) {
const where = `"${pair.feature}"`;
const hooks = pair.hooks.filter((key) => present(key, where));
for (const key of hooks)
if (!SHARED.test(key))
found.push(`${key}, the hook of ${where}, is not under src/components, src/hooks, src/utils or a page folder`);
for (const key of [...pair.desktop, ...pair.mobile]) {
if (pair.mobile.includes(key)) listed.add(key);
if (!present(key, where)) continue;
const imported = runtimeImports(root, key);
const missing = hooks.filter((hook) => !imported.has(stem(hook)));
for (const hook of missing) found.push(`${key} is a view of ${where} but does not import ${hook}`);
}
}
for (const key of Object.keys(manifest.mobileOnly)) {
if (!present(key, 'mobileOnly')) continue;
if (listed.has(key)) found.push(`${key} is listed in a pair and under mobileOnly; it is one or the other`);
listed.add(key);
}
const screens = listFiles(
root,
[SCREENS],
(key) => SOURCE.test(key) && !TEST_FILE.test(key) && !key.endsWith('.d.ts')
);
for (const path of screens) {
const key = toKey(root, path);
if (!listed.has(key)) found.push(`${key} is a phone screen file that scripts/feature-pairs.json does not list`);
}
return found;
}
/** Runs the check against the manifest at manifestPath and returns the exit code. */
export function check({
root,
manifestPath = join(root, 'scripts/feature-pairs.json'),
list = false,
log = console.log,
error = console.error,
}) {
const manifest = readBaseline(manifestPath, manifestShape);
const found = problems(root, manifest);
if (list) {
for (const pair of manifest.pairs) log(`${pair.feature}: ${pair.hooks.join(', ')}`);
}
for (const line of found) error(`FAIL ${line}`);
if (found.length) {
error(
'A feature that has a desktop view and a phone view keeps its logic in one shared hook both import. ' +
'Add a new phone screen to scripts/feature-pairs.json: as a view of the pair whose hook it runs on, ' +
'or under mobileOnly with the reason it has no twin. See client/CLAUDE.md, "Desktop and phone views".'
);
}
const only = Object.keys(manifest.mobileOnly).length;
log(
`pairs: ${manifest.pairs.length} shared feature(s), ${only} phone-only screen file(s), ${found.length} problem(s)`
);
return found.length ? 1 : 0;
}