Files
TREK/client/scripts/lib/layers.mjs
T
Maurice 17b72ff778 ci(client): lint:layers rules help/ and vacay/ as helper layers
help/ holds the help center's screen registry and vacay/ a few date
helpers; both are plain data and code like utils/, yet without an entry in
RULES they could import any view unseen. Neither does today, so ruling
them costs nothing in the baseline. managed/ stays free and the comment now
says why: it is the slot an operator fills with whole screens at build time.
2026-10-08 13:04:40 +02:00

102 lines
3.6 KiB
JavaScript

/*
* The rules and the comparison behind lint:layers (scripts/layer-lint.mjs).
*/
import { join } from 'node:path'
import { countMap, lowerCounts, readBaseline, writeBaseline } from './ratchet.mjs'
import { sourceGraph } from './imports.mjs'
const VIEW = ['components', 'mobile', 'pages', 'hooks']
/**
* For each directory under src/, the directories its files may not import
* from. The view layers sit on top: a page composes components, a component
* may use a shared hook, and the phone screens reuse both. Nothing below the
* views (stores, repos, sync, api, the Dexie schema and the helpers) knows a
* view exists, a repo sits under the stores, and the Dexie schema under all
* of them. help/ (the help center's screen registry, plain data) and vacay/
* (date helpers) are helpers like utils/. Directories without an entry are
* free on purpose: mobile and pages sit at the top, the app root wires
* everything, and managed/ is the slot an operator fills with whole screens
* at build time.
*/
export const RULES = {
components: ['pages', 'mobile'],
hooks: ['pages', 'mobile', 'components'],
store: VIEW,
sync: VIEW,
api: VIEW,
utils: VIEW,
i18n: VIEW,
theme: VIEW,
constants: VIEW,
services: VIEW,
push: VIEW,
types: VIEW,
help: VIEW,
vacay: VIEW,
repo: [...VIEW, 'store'],
db: [...VIEW, 'store', 'repo', 'sync', 'api'],
}
/**
* Every import that crosses a boundary the wrong way, per file. A type-only
* import counts too: a type the lower layer needs belongs below it.
*/
export function violations(root, rules = RULES) {
const found = {}
for (const file of sourceGraph(root)) {
const banned = rules[file.layer]
if (!banned) continue
const wrong = file.imports.filter((imp) => imp.layer && banned.includes(imp.layer))
if (wrong.length) found[file.key] = wrong.map((imp) => `${imp.line}: '${imp.specifier}' (${file.layer} into ${imp.layer})`)
}
return found
}
/** Runs the check against the baseline at baselinePath and returns the exit code. */
export function check({
root,
baselinePath = join(root, 'scripts/layers-baseline.json'),
rules = RULES,
update = false,
list = false,
log = console.log,
error = console.error,
}) {
const found = violations(root, rules)
const counts = Object.fromEntries(Object.entries(found).map(([key, list]) => [key, list.length]))
let baseline = readBaseline(baselinePath, countMap)
if (list) {
for (const [key, wrong] of Object.entries(found)) {
log(`${key} (${wrong.length}, baseline ${baseline[key] ?? 0})`)
for (const line of wrong) log(` ${line}`)
}
}
if (update) {
baseline = lowerCounts(baseline, counts)
writeBaseline(baselinePath, baseline)
}
const grown = Object.entries(counts).filter(([key, n]) => n > (baseline[key] ?? 0))
const lowerable = Object.entries(baseline).filter(([key, n]) => (counts[key] ?? 0) < n)
for (const [key, n] of grown) {
error(`FAIL ${key}: ${n} import(s) against the layering, baseline ${baseline[key] ?? 0}:`)
for (const line of found[key]) error(` ${line}`)
}
if (grown.length) {
error(
'Move what both sides need down into a layer they may share (a model or helper under utils/, ' +
'a hook under hooks/, a type under types/) instead of importing upwards.',
)
}
if (lowerable.length && !update) {
log(`${lowerable.length} file(s) hold fewer of these imports than the baseline: run with --update to lower it.`)
}
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0)
log(`layers: ${sum(counts)} import(s) against the layering in ${Object.keys(counts).length} file(s), baseline allows ${sum(baseline)}`)
return grown.length ? 1 : 0
}