/* * The comparison behind lint:offline (scripts/offline-lint.mjs). */ import { join } from 'node:path' import { fileList, lowerList, readBaseline, writeBaseline } from './ratchet.mjs' import { sourceGraph } from './imports.mjs' /** The layers that render or drive a view, and so must not hold a network call. */ export const VIEW_LAYERS = ['components', 'mobile', 'pages', 'hooks'] /** * The view files that import from src/api/ for anything but types, each with * the imports that do it. A type-only import is erased from the bundle and * carries no request, so it does not count. */ export function bypasses(root) { const found = {} for (const file of sourceGraph(root)) { if (!VIEW_LAYERS.includes(file.layer)) continue const direct = file.imports.filter((imp) => imp.layer === 'api' && !imp.typeOnly) if (direct.length) found[file.key] = direct.map((imp) => `${imp.line}: '${imp.specifier}'`) } return found } /** * Runs the check against the list at baselinePath. A view file outside the * list may not import src/api/. A listed file that no longer does fails the * check as well until --update takes it out, so the list only shrinks and * the next file at that path does not inherit the exemption. Returns the exit * code. */ export function check({ root, baselinePath = join(root, 'scripts/offline-baseline.json'), update = false, list = false, log = console.log, error = console.error, }) { const found = bypasses(root) const offenders = Object.keys(found) let baseline = readBaseline(baselinePath, fileList) if (update) { baseline = lowerList(baseline, offenders) writeBaseline(baselinePath, baseline) } const allowed = new Set(baseline) const fresh = offenders.filter((key) => !allowed.has(key)) const stale = baseline.filter((key) => !found[key]) if (list) { for (const [key, direct] of Object.entries(found)) { log(`${allowed.has(key) ? 'listed' : 'new '} ${key}`) for (const line of direct) log(` ${line}`) } } for (const key of fresh) { error(`FAIL ${key} imports src/api/ directly:`) for (const line of found[key]) error(` ${line}`) } if (fresh.length) { error( 'A view reads through a repo (src/repo/) or a store and writes through the mutation queue, ' + 'so it keeps working offline. A domain that is online-only on purpose still gets its calls ' + 'in a store or a repo, not in the view. See client/CLAUDE.md, "Data flow".', ) } for (const key of stale) { error(`FAIL ${key} is listed in scripts/offline-baseline.json but no longer imports src/api/ (or is gone).`) } if (stale.length) error('Run npm run lint:offline -- --update to take it off the list.') log(`offline: ${offenders.length} view file(s) import src/api/ directly, ${baseline.length} listed in the baseline`) return fresh.length || stale.length ? 1 : 0 }