Files
TREK/client/scripts/lib/layers.mjs
T
Maurice 798553b13b ci(client): lint:layers holds the import direction between src/ trees
Components reach up into pages/ for models and helpers (28 imports in
25 files: the journey detail helpers, the collections, atlas and
dashboard models) and sideways into mobile/ for phone hooks and sheets
(36 in 30 files), and a few stores and a repo import from components/.
Nothing stopped the next one, and every such import ties two trees
together that should move and split independently.

lint:layers reads each source file's imports from its syntax tree
(multi-line imports, re-exports, import() and type imports included,
nothing inside comments or strings) and checks them against a small
rule table: a component imports neither pages/ nor mobile/, a shared
hook no view, nothing under the views (store, repo, sync, api, db,
utils and the other helpers) any view, a repo no store, and the Dexie
schema none of the layers above it. mobile/ and pages/ stay free, since
the phone screens reuse the page hooks by design.

The 80 imports that break a rule today are counted per file in
scripts/layers-baseline.json, and a file may hold no more than its
entry. Type imports count: a type the lower layer needs belongs below
it. Tests are not checked.
2026-10-08 13:04:39 +02:00

97 lines
3.4 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. Directories without an entry (mobile, pages, the app root) are
* free.
*/
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,
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
}