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.
This commit is contained in:
Maurice
2026-10-08 11:43:21 +02:00
parent 37d386178b
commit 798553b13b
7 changed files with 376 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env node
/*
* lint:layers: imports follow the layering, downwards only.
*
* Components reached up into pages/ for models and helpers, and sideways into
* mobile/ for phone hooks and sheets, so nothing could be moved or split
* without touching three trees. scripts/lib/layers.mjs holds the rules: a
* component imports neither pages/ nor mobile/, a shared hook no view, and
* nothing under the views (stores, repos, sync, api, db, helpers) a view at
* all. The imports that already broke a rule are counted per file in
* scripts/layers-baseline.json, and a file may hold no more of them than its
* entry (a file without an entry holds none). Type-only imports count: a type
* the lower layer needs belongs below it.
*
* npm run lint:layers check against the baseline (CI)
* npm run lint:layers -- --list print every import against the layering
* npm run lint:layers -- --update lower the baseline to what the files hold now;
* it never raises an entry
*/
import { fileURLToPath } from 'node:url'
import { check } from './lib/layers.mjs'
import { runCli } from './lib/ratchet.mjs'
const root = fileURLToPath(new URL('..', import.meta.url))
await runCli('lint:layers', (args) => check({ root, update: args.includes('--update'), list: args.includes('--list') }))
+65
View File
@@ -0,0 +1,65 @@
{
"src/api/client.ts": 1,
"src/components/Atlas/AtlasCountryPlaces.tsx": 1,
"src/components/Budget/ReceiptScanModal.tsx": 1,
"src/components/Collab/CollabLinks.tsx": 1,
"src/components/Collab/CollabNotes.tsx": 1,
"src/components/Collections/AddPlaceToCollectionModal.tsx": 1,
"src/components/Collections/CollectionFilterBar.tsx": 2,
"src/components/Collections/CollectionLabelFilter.tsx": 1,
"src/components/Collections/CollectionMap.tsx": 1,
"src/components/Collections/CollectionPicker.tsx": 1,
"src/components/Collections/CollectionPlaceDetail.tsx": 1,
"src/components/Collections/ListEditorModal.tsx": 1,
"src/components/Collections/MSaveToCollectionSheet.tsx": 3,
"src/components/Collections/StatusBadge.tsx": 1,
"src/components/Dawarich/DawarichAcceptDialog.tsx": 3,
"src/components/Dawarich/DawarichAtlasDialog.tsx": 3,
"src/components/Dawarich/DawarichSuggestionsPanel.tsx": 1,
"src/components/Journey/ContributorInviteDialog.tsx": 1,
"src/components/Journey/JourneyDetailPageAddTripDialog.tsx": 1,
"src/components/Journey/JourneyDetailPageChips.tsx": 1,
"src/components/Journey/JourneyDetailPageEntryCard.tsx": 2,
"src/components/Journey/JourneyDetailPageEntryEditor.tsx": 3,
"src/components/Journey/JourneyDetailPageGalleryView.tsx": 1,
"src/components/Journey/JourneyDetailPageMapView.tsx": 1,
"src/components/Journey/JourneyDetailPagePhotoGrid.tsx": 1,
"src/components/Journey/JourneyDetailPageProviderPicker.tsx": 2,
"src/components/Journey/JourneyDetailPageSettingsDialog.tsx": 1,
"src/components/Journey/JourneyEntryCover.tsx": 2,
"src/components/Journey/MobileEntryView.tsx": 1,
"src/components/Map/MapView.tsx": 1,
"src/components/Map/MapViewGL.tsx": 1,
"src/components/Packing/packingPrintDocument.ts": 1,
"src/components/Packing/usePackingExport.ts": 1,
"src/components/Planner/IcsSubscribeModal.tsx": 1,
"src/components/Planner/TransitJourneyModal.tsx": 1,
"src/components/Planner/TransitSearchPanel.tsx": 1,
"src/components/Planner/TripWarningsBanner.tsx": 1,
"src/components/Planner/bookings/bookingsModel.ts": 2,
"src/components/Planner/transitDisplay.tsx": 1,
"src/components/Plugins/PluginFrame.tsx": 1,
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
"src/components/Studio/StudioShell.tsx": 1,
"src/components/Studio/StudioSidebar.tsx": 1,
"src/components/Studio/TravelElements.tsx": 1,
"src/components/Todo/useTodoList.ts": 1,
"src/components/Trips/TripMembersModal.tsx": 1,
"src/components/shared/ConfirmDialog.tsx": 1,
"src/components/shared/CountryFlag.tsx": 1,
"src/components/shared/EmptyState.tsx": 1,
"src/components/shared/TripLoadingSplash.tsx": 1,
"src/components/shared/dialogFocus.ts": 1,
"src/hooks/useDawarichConnection.ts": 1,
"src/hooks/useDawarichJournalTrail.ts": 1,
"src/hooks/useDawarichSuggestions.ts": 1,
"src/hooks/useDayNotes.ts": 1,
"src/hooks/useRouteCalculation.ts": 4,
"src/hooks/useTransportRoutes.ts": 1,
"src/hooks/useWebPush.ts": 1,
"src/repo/pluginPoiRepo.ts": 1,
"src/store/slices/budgetSlice.ts": 1,
"src/store/studioStore.ts": 2,
"src/store/vacayStore.ts": 1
}
+70
View File
@@ -0,0 +1,70 @@
/*
* The imports of a source file, read from its syntax tree rather than with a
* regular expression, so an import split over lines, a dynamic import() and a
* re-export count, and an import inside a comment or a string does not.
*/
import { dirname, join, relative, resolve } from 'node:path'
import ts from 'typescript'
import { listFiles, readText, TEST_FILE, toKey } from './ratchet.mjs'
const bindingsAreTypes = (elements) => elements.length > 0 && elements.every((el) => el.isTypeOnly)
/**
* Every module the file names, with the line it is named on and whether the
* import only brings in types (erased from the bundle, so no runtime path).
*/
export function importsOf(source, file) {
const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind)
const found = []
const add = (node, literal, typeOnly) => {
if (!literal || !ts.isStringLiteralLike(literal)) return
found.push({ specifier: literal.text, line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1, typeOnly })
}
const visit = (node) => {
if (ts.isImportDeclaration(node)) {
const clause = node.importClause
const named = clause?.namedBindings && ts.isNamedImports(clause.namedBindings) ? clause.namedBindings.elements : null
const typeOnly = Boolean(clause && (clause.isTypeOnly || (!clause.name && named && bindingsAreTypes(named))))
add(node, node.moduleSpecifier, typeOnly)
} else if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
const named = node.exportClause && ts.isNamedExports(node.exportClause) ? node.exportClause.elements : null
add(node, node.moduleSpecifier, node.isTypeOnly || Boolean(named && bindingsAreTypes(named)))
} else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
add(node, node.moduleReference.expression, node.isTypeOnly)
} else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
add(node, node.argument.literal, true)
} else if (
ts.isCallExpression(node) &&
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
(ts.isIdentifier(node.expression) && node.expression.text === 'require'))
) {
add(node, node.arguments[0], false)
}
ts.forEachChild(node, visit)
}
visit(sf)
return found
}
/**
* The top directory under src/ a relative import lands in ("components",
* "api"; a file directly in src/ gives its own name), or null for a package
* or a path outside src/.
*/
export function layerOf(srcRoot, fromFile, specifier) {
if (!specifier.startsWith('.')) return null
const target = relative(srcRoot, resolve(dirname(fromFile), specifier)).split('\\').join('/')
if (!target || target.startsWith('..')) return null
return target.split('/')[0]
}
/** Every source file under root/src outside the tests, with its key, its layer and its imports resolved to layers. */
export function sourceGraph(root) {
const src = join(root, 'src')
return listFiles(root, ['src'], (key) => /\.tsx?$/.test(key) && !TEST_FILE.test(key)).map((path) => ({
key: toKey(root, path),
layer: toKey(src, path).split('/')[0],
imports: importsOf(readText(path), path).map((imp) => ({ ...imp, layer: layerOf(src, path, imp.specifier) })),
}))
}
+96
View File
@@ -0,0 +1,96 @@
/*
* 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
}