Files
TREK/client/tests/unit/scripts/layer-lint.test.ts
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

118 lines
5.1 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { importsOf, layerOf } from '../../../scripts/lib/imports.mjs';
import { check, RULES, violations } from '../../../scripts/lib/layers.mjs';
import { RatchetError } from '../../../scripts/lib/ratchet.mjs';
import { ratchetTree, type RatchetTree } from '../../helpers/ratchetFixture';
let tree: RatchetTree;
afterEach(() => tree?.remove());
const BASELINE = 'scripts/layers-baseline.json';
function run(baseline: Record<string, number>, files: Record<string, string>, update = false) {
tree = ratchetTree({ [BASELINE]: JSON.stringify(baseline), ...files });
return check({ root: tree.root, baselinePath: tree.path(BASELINE), update, ...tree.out });
}
describe('scripts/lib/imports', () => {
it('IMPORTS-001: finds static, multi-line, re-exported, dynamic and type imports, and nothing in comments or strings', () => {
const source = [
"import a from './a'",
'import {',
' b,',
"} from './b'",
"import type { C } from './c'",
"import { type D } from './d'",
"import E, { type F } from './e'",
"export { g } from './g'",
"export type { H } from './h'",
"const i = () => import('./i')",
"let j: typeof import('./j')",
"// import k from './k'",
'const l = "import m from \'./m\'"',
"import './n.css'",
].join('\n');
expect(importsOf(source, 'x.tsx')).toEqual([
{ specifier: './a', line: 1, typeOnly: false },
{ specifier: './b', line: 2, typeOnly: false },
{ specifier: './c', line: 5, typeOnly: true },
{ specifier: './d', line: 6, typeOnly: true },
{ specifier: './e', line: 7, typeOnly: false },
{ specifier: './g', line: 8, typeOnly: false },
{ specifier: './h', line: 9, typeOnly: true },
{ specifier: './i', line: 10, typeOnly: false },
{ specifier: './j', line: 11, typeOnly: true },
{ specifier: './n.css', line: 14, typeOnly: false },
]);
});
it('IMPORTS-002: resolves a relative import to the top directory under src/', () => {
const src = join('/r', 'src');
const from = join(src, 'components', 'Map', 'MapView.tsx');
expect(layerOf(src, from, '../../pages/atlas/atlasModel')).toBe('pages');
expect(layerOf(src, from, './MapMarkers')).toBe('components');
expect(layerOf(src, from, '../../types')).toBe('types');
expect(layerOf(src, from, 'react')).toBeNull();
expect(layerOf(src, from, '../../../../shared/src')).toBeNull();
});
});
describe('lint:layers', () => {
it('LAYERS-001: components may not import pages/ or mobile/, and the core no view at all', () => {
expect(RULES.components).toEqual(expect.arrayContaining(['pages', 'mobile']));
for (const core of ['store', 'repo', 'sync', 'api', 'db', 'utils']) {
expect(RULES[core as keyof typeof RULES]).toEqual(
expect.arrayContaining(['components', 'mobile', 'pages', 'hooks'])
);
}
expect(RULES.repo).toContain('store');
});
it('LAYERS-002: reports each import against the layering with its line', () => {
tree = ratchetTree({
'src/components/A.tsx':
"import { m } from '../pages/x/model'\nimport { useIsPhone } from '../mobile/useIsPhone'\nimport { s } from '../store/s'\n",
'src/store/s.ts': "import type { P } from '../components/P'\n",
'src/pages/P.tsx': "import A from '../components/A'\nimport M from '../mobile/M'\n",
'src/components/A.test.tsx': "import P from '../pages/P'\n",
});
expect(violations(tree.root)).toEqual({
'src/components/A.tsx': [
"1: '../pages/x/model' (components into pages)",
"2: '../mobile/useIsPhone' (components into mobile)",
],
'src/store/s.ts': ["1: '../components/P' (store into components)"],
});
});
it('LAYERS-003: fails a file that gains an import against the layering', () => {
expect(run({}, { 'src/components/A.tsx': "import { m } from '../pages/x/model'\n" })).toBe(1);
expect(tree.error.join('\n')).toMatch(/src\/components\/A\.tsx: 1 import\(s\) against the layering, baseline 0/);
expect(tree.error.join('\n')).toMatch(/1: '\.\.\/pages\/x\/model' \(components into pages\)/);
});
it('LAYERS-004: holds a baselined file at its entry', () => {
const files = { 'src/components/A.tsx': "import { m } from '../pages/x/model'\n" };
expect(run({ 'src/components/A.tsx': 1 }, files)).toBe(0);
});
it('LAYERS-005: --update lowers and drops entries and never raises one', () => {
run(
{ 'src/components/A.tsx': 3, 'src/components/B.tsx': 1 },
{
'src/components/A.tsx': "import { m } from '../pages/x/model'\n",
'src/components/B.tsx': "import { m } from '../utils/m'\n",
},
true
);
expect(JSON.parse(readFileSync(tree.path(BASELINE), 'utf8'))).toEqual({ 'src/components/A.tsx': 1 });
});
it('LAYERS-006: a missing baseline stops the check', () => {
tree = ratchetTree({ 'src/a.ts': '' });
expect(() => check({ root: tree.root, baselinePath: tree.path(BASELINE), ...tree.out })).toThrow(RatchetError);
});
});