mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
bd267c67f8
lint:size, lint:layers, lint:rtl (both baselines), lint:skips and lint:warnings only failed a count that passed its entry. A count that fell below its entry was logged and nothing more, so a file that shrank could grow back to its old entry unseen, and the entry of a deleted file stayed behind for the next file at that path. lint:format and lint:offline already fail on such a stale entry, and the docs said these counts may only shrink. Each of them now fails on an entry that lowerCounts would lower or drop, naming its count or saying the file is gone, and tells to run --update, which still only lowers. staleCounts and reportStale in lib/ratchet.mjs hold the shared part. Every baseline matches the tree today, so none needed lowering. The client CLAUDE.md says so, and that CI lints through lint:warnings rather than lint:check.
194 lines
7.4 KiB
JavaScript
194 lines
7.4 KiB
JavaScript
/*
|
|
* The matching and the comparison behind lint:rtl (scripts/rtl-lint.mjs).
|
|
*/
|
|
import { join } from 'node:path';
|
|
import ts from 'typescript';
|
|
import {
|
|
countMap,
|
|
listFiles,
|
|
lowerCounts,
|
|
readBaseline,
|
|
readText,
|
|
reportStale,
|
|
staleCounts,
|
|
TEST_FILE,
|
|
toKey,
|
|
writeBaseline,
|
|
} from './ratchet.mjs';
|
|
|
|
// Tailwind utilities that name a physical side, with any variant prefix
|
|
// (sm:, hover:, group-hover:) and the negative form (-ml-2).
|
|
const CLASS = new RegExp(
|
|
String.raw`(?<![\w-])(?:[\w-]+:)*-?(?:` +
|
|
[
|
|
String.raw`(?:ml|mr|pl|pr|left|right|scroll-m[lr]|scroll-p[lr])-(?:\[[^\]\s]+\]|[\w./]+)`,
|
|
String.raw`text-(?:left|right)(?![\w-])`,
|
|
String.raw`border-[lr](?:-[\w[\]#./()-]+)?(?![\w-])`,
|
|
String.raw`rounded-(?:l|r|tl|tr|bl|br)(?:-[\w[\]./]+)?(?![\w-])`,
|
|
String.raw`(?:float|clear)-(?:left|right)(?![\w-])`,
|
|
String.raw`space-x-[\w.]+`,
|
|
].join('|') +
|
|
')',
|
|
'g'
|
|
);
|
|
// The same sides as React style keys, and as properties in a stylesheet.
|
|
const STYLE =
|
|
/\b(?:marginLeft|marginRight|paddingLeft|paddingRight|borderLeft\w*|borderRight\w*|border(?:Top|Bottom)(?:Left|Right)Radius|left|right)\s*:|\b(?:textAlign|float)\s*:\s*['"](?:left|right)['"]/g;
|
|
const CSS =
|
|
/(?<![\w-])(?:margin-left|margin-right|padding-left|padding-right|border-left[\w-]*|border-right[\w-]*|border-(?:top|bottom)-(?:left|right)-radius|left|right)\s*:|(?<![\w-])(?:text-align|float)\s*:\s*(?:left|right)\b/g;
|
|
|
|
export const DISABLE = 'rtl-lint-disable';
|
|
|
|
/**
|
|
* The source with its comments blanked out, so prose like "top right" or
|
|
* "left out" is no side. TypeScript finds the comments in a .ts/.tsx file,
|
|
* which keeps `/*` and `//` inside strings and JSX text where they belong
|
|
* (accept="image/*", a URL); a stylesheet only has block comments.
|
|
*/
|
|
function withoutComments(source, file) {
|
|
const chars = source.split('');
|
|
const blank = (pos, end) => {
|
|
for (let i = pos; i < end; i++) if (chars[i] !== '\n') chars[i] = ' ';
|
|
};
|
|
if (file.endsWith('.css')) {
|
|
for (const m of source.matchAll(/\/\*[\s\S]*?\*\//g)) blank(m.index, m.index + m[0].length);
|
|
return chars.join('');
|
|
}
|
|
const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind);
|
|
const seen = new Set();
|
|
const take = (ranges) => {
|
|
for (const r of ranges ?? []) {
|
|
if (seen.has(r.pos)) continue;
|
|
seen.add(r.pos);
|
|
blank(r.pos, r.end);
|
|
}
|
|
};
|
|
const visit = (node) => {
|
|
take(ts.getLeadingCommentRanges(source, node.pos));
|
|
take(ts.getTrailingCommentRanges(source, node.end));
|
|
// JSX text holds no comments, and asking for them there would read `//` in a URL as one.
|
|
if (!ts.isJsxText(node)) for (const child of node.getChildren(sf)) visit(child);
|
|
};
|
|
visit(sf);
|
|
take(ts.getLeadingCommentRanges(source, sf.endOfFileToken.pos));
|
|
return chars.join('');
|
|
}
|
|
|
|
/** Whether the line carries the marker inside a comment; in a string or in code it is no marker. */
|
|
function markedInComment(line, code) {
|
|
for (let at = line.indexOf(DISABLE); at !== -1; at = line.indexOf(DISABLE, at + 1)) {
|
|
if (!code.slice(at, at + DISABLE.length).trim()) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* One file read once: every physical use as `line: match`, skipping the lines
|
|
* marked as physical on purpose, and how many lines carry that mark.
|
|
*/
|
|
export function inspect(source, file) {
|
|
const isCss = file.endsWith('.css');
|
|
const uses = [];
|
|
let markers = 0;
|
|
const lines = source.split('\n');
|
|
withoutComments(source, file)
|
|
.split('\n')
|
|
.forEach((code, i) => {
|
|
if (markedInComment(lines[i], code)) {
|
|
markers++;
|
|
return;
|
|
}
|
|
const patterns = isCss ? [CSS] : [CLASS, STYLE];
|
|
for (const re of patterns) for (const m of code.match(re) ?? []) uses.push(`${i + 1}: ${m.trim()}`);
|
|
});
|
|
return { uses, markers };
|
|
}
|
|
|
|
/** Every physical use in one file, as `line: match`. */
|
|
export function physicalUses(source, file) {
|
|
return inspect(source, file).uses;
|
|
}
|
|
|
|
/** The physical uses and the disable markers per file under root/src, keyed by the path from src/. */
|
|
export function scan(root) {
|
|
const counts = {};
|
|
const listed = {};
|
|
const markers = {};
|
|
const accept = (key) => /\.(?:tsx?|css)$/.test(key) && !TEST_FILE.test(key);
|
|
for (const path of listFiles(root, ['src'], accept)) {
|
|
const found = inspect(readText(path), path);
|
|
const key = toKey(join(root, 'src'), path);
|
|
if (found.uses.length) {
|
|
counts[key] = found.uses.length;
|
|
listed[key] = found.uses;
|
|
}
|
|
if (found.markers) markers[key] = found.markers;
|
|
}
|
|
return { counts, listed, markers };
|
|
}
|
|
|
|
const sum = (counts) => Object.values(counts).reduce((a, b) => a + b, 0);
|
|
|
|
/**
|
|
* Runs the check against both baselines: rtl-baseline.json for the physical
|
|
* sides, rtl-disable-baseline.json for the lines that opt out of the check. A
|
|
* marker hides a side from the count, so the markers are a ratchet as well:
|
|
* a file holds no more of them than its entry. Returns the exit code.
|
|
*/
|
|
export function check({
|
|
root,
|
|
baselinePath = join(root, 'scripts/rtl-baseline.json'),
|
|
disableBaselinePath = join(root, 'scripts/rtl-disable-baseline.json'),
|
|
update = false,
|
|
list = false,
|
|
log = console.log,
|
|
error = console.error,
|
|
}) {
|
|
const { counts, listed, markers } = scan(root);
|
|
let baseline = readBaseline(baselinePath, countMap);
|
|
let allowedMarkers = readBaseline(disableBaselinePath, countMap);
|
|
|
|
if (list) {
|
|
for (const [file, uses] of Object.entries(listed).sort()) {
|
|
log(`${file} (${uses.length}, baseline ${baseline[file] ?? 0})`);
|
|
for (const use of uses) log(` ${use}`);
|
|
}
|
|
}
|
|
|
|
if (update) {
|
|
baseline = lowerCounts(baseline, counts);
|
|
allowedMarkers = lowerCounts(allowedMarkers, markers);
|
|
writeBaseline(baselinePath, baseline);
|
|
writeBaseline(disableBaselinePath, allowedMarkers);
|
|
}
|
|
|
|
const grown = Object.entries(counts).filter(([file, n]) => n > (baseline[file] ?? 0));
|
|
const marked = Object.entries(markers).filter(([file, n]) => n > (allowedMarkers[file] ?? 0));
|
|
const stale = staleCounts(baseline, counts);
|
|
const staleMarkers = staleCounts(allowedMarkers, markers);
|
|
|
|
for (const [file, n] of grown) {
|
|
error(
|
|
`FAIL ${file}: ${n} physical side(s), baseline ${baseline[file] ?? 0}. ` +
|
|
'Use ms-/me-/ps-/pe-/start-/end-/text-start/text-end/border-s/border-e/rounded-s/rounded-e ' +
|
|
'or marginInlineStart/paddingInlineEnd/insetInlineStart/textAlign: start. ' +
|
|
`If the side is geometry, mark the line with ${DISABLE} in a comment.`
|
|
);
|
|
}
|
|
for (const [file, n] of marked) {
|
|
error(
|
|
`FAIL ${file}: ${n} line(s) marked ${DISABLE}, scripts/rtl-disable-baseline.json allows ${allowedMarkers[file] ?? 0}. ` +
|
|
'Use the logical form where the side follows the text. A side that is geometry on purpose ' +
|
|
'(a map, a measured position, a time axis) needs a reviewer to raise the entry by hand.'
|
|
);
|
|
}
|
|
reportStale(stale, { file: 'rtl-baseline.json', command: 'lint:rtl', root: join(root, 'src'), error });
|
|
reportStale(staleMarkers, { file: 'rtl-disable-baseline.json', command: 'lint:rtl', root: join(root, 'src'), error });
|
|
log(
|
|
`rtl: ${sum(counts)} physical side(s) in ${Object.keys(counts).length} file(s), baseline allows ${sum(baseline)}; ` +
|
|
`${sum(markers)} line(s) marked ${DISABLE}, baseline allows ${sum(allowedMarkers)}`
|
|
);
|
|
return grown.length || marked.length || stale.length || staleMarkers.length ? 1 : 0;
|
|
}
|