Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ node_modules/
.yarn-integrity
.DS_Store
.pnpm-store/
.test-fixtures/
20 changes: 20 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,29 @@ const plugin = {
'data-x-form-naming': require('./rules/data-x-form-naming'),

'todo-ticket-reference': require('./rules/todo-ticket-reference'),

'no-restricted-layer-import': require('./rules/no-restricted-layer-import'),
'no-nested-component': require('./rules/no-nested-component'),
'no-universal-in-universal': require('./rules/no-universal-in-universal'),
},

configs: {},
}

plugin.configs.architecture = {
plugins: {
'@kaliber': plugin,
},
rules: {
'@kaliber/no-restricted-layer-import': 'error',
'@kaliber/no-nested-component': ['warn', {
deny: [
{ parent: 'Container*', child: 'Container*' },
{ parent: 'Heading*', child: 'Heading*' },
]
}],
'@kaliber/no-universal-in-universal': 'error',
},
}

module.exports = plugin
8 changes: 7 additions & 1 deletion machinery/ast.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
module.exports = {
getPropertyName,
getFunctionName,
getJSXElementName, getParentJSXElement,
getJSXElementName, getParentJSXElement, getParentJSXElements,
isRootJSXElement, hasParentsJSXElementsWithClassName, isInJSXBranch, isInExport,
isComponentName,
}

/** A component name starts with an uppercase letter (PascalCase) */
function isComponentName(name) {
return Boolean(name) && name[0] === name[0].toUpperCase() && name[0] !== name[0].toLowerCase()
}

function getPropertyName(property) {
Expand Down
70 changes: 68 additions & 2 deletions machinery/filename.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
const path = require('node:path')

module.exports = {
isApp, isPage, isTemplate,
getBaseFilename,
isApp, isPage, isTemplate, isUniversal,
getBaseFilename, getLayer, findSrcRoot,
getLayerFromImportPath, isSublayerOf, matchesLayerPattern,
}

function isApp(context) {
Expand All @@ -29,3 +30,68 @@ function getBaseFilename(context) {
return name.slice(0, 1).toUpperCase() + name.slice(1)
} else return basename
}

function isUniversal(context) {
const filename = context.filename
return !!filename && filename.endsWith('.universal.js')
}

/** Get the architectural layer for a file path (relative to its src root) */
function getLayer(filePath, sublayerParents = ['features']) {
const srcRoot = findSrcRoot(filePath)
if (!srcRoot) return null

const relative = filePath.slice(srcRoot.length + 1)
const segments = relative.split(path.sep)

return layerFromSegments(segments, sublayerParents)
}

/** Get the architectural layer for an import path like '/features/hero/Hero' */
function getLayerFromImportPath(importPath, sublayerParents = ['features']) {
const segments = importPath.slice(1).split('/')

return layerFromSegments(segments, sublayerParents)
}

/** Check if a layer is a sublayer of a parent (e.g. 'features/hero' is a sublayer of 'features') */
function isSublayerOf(layer, parent) {
return layer.startsWith(parent + '/')
}

/**
* Check if a layer matches an allowed pattern, considering sublayer parents.
*
* - Exact match: 'machinery' matches 'machinery'
* - Parent match: 'features' matches 'features/hero' (when 'features' is a sublayer parent)
* - Wildcard match: 'features/*' matches 'features/hero'
*/
function matchesLayerPattern(targetLayer, pattern, sublayerParents = []) {
if (pattern === targetLayer) return true

for (const parent of sublayerParents) {
if (pattern === parent && isSublayerOf(targetLayer, parent)) return true
if (pattern === parent + '/*' && isSublayerOf(targetLayer, parent)) return true
}

return false
}

function findSrcRoot(filePath) {
const segments = filePath.split(path.sep)
const srcIndex = segments.lastIndexOf('src')
if (srcIndex === -1) return null
return segments.slice(0, srcIndex + 1).join(path.sep)
}

// ─── internal ───────────────────────────────────────────────────────────────

function layerFromSegments(segments, sublayerParents) {
if (!segments.length || !segments[0]) return null

if (sublayerParents.includes(segments[0]) && segments.length > 1) {
return segments[0] + '/' + segments[1]
}

return segments[0]
}
93 changes: 93 additions & 0 deletions machinery/importGraph.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
const { readFileSync, existsSync } = require('node:fs')
const { join } = require('node:path')

module.exports = { getImportGraph, _resetCache }

let cached = null
let cachedRoot = null

/**
* Read the import graph from @kaliber/build's esbuild metafile.
*
* The metafile is written to `.kaliber-build/server-metafile.json` during
* build and (with a small @kaliber/build change) during watch mode.
*
* Returns null if no metafile exists yet (build hasn't run).
*
* @param {string} projectRoot
* @returns {{ transitiveDeps: (filePath: string) => Set<string>, findChain: (source: string, target: string) => string[] | null } | null}
*/
function getImportGraph(projectRoot) {
if (cached && cachedRoot === projectRoot) return cached

const metafilePath = join(projectRoot, '.kaliber-build', 'server-metafile.json')
if (!existsSync(metafilePath)) return null

try {
const metafile = JSON.parse(readFileSync(metafilePath, 'utf-8'))
cached = parseMetafile(metafile)
cachedRoot = projectRoot
return cached
} catch {
return null
}
}
Comment on lines +20 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Module-level cache is never invalidated when the metafile changes.

The docstring states the metafile is rewritten during watch mode, but the cache is keyed only on projectRoot and persists for the lifetime of the process. In a long-lived ESLint session (editor/LSP or --watch), a rebuild that updates server-metafile.json will not be picked up, so transitive-nesting results in no-universal-in-universal can go stale until the process restarts.

Consider invalidating on file mtime.

♻️ Possible approach using mtime
-const { readFileSync, existsSync } = require('node:fs')
+const { readFileSync, existsSync, statSync } = require('node:fs')
 ...
 let cached = null
 let cachedRoot = null
+let cachedMtimeMs = null
 ...
 function getImportGraph(projectRoot) {
-  if (cached && cachedRoot === projectRoot) return cached
-
   const metafilePath = join(projectRoot, '.kaliber-build', 'server-metafile.json')
   if (!existsSync(metafilePath)) return null
+
+  const mtimeMs = statSync(metafilePath).mtimeMs
+  if (cached && cachedRoot === projectRoot && cachedMtimeMs === mtimeMs) return cached

   try {
     const metafile = JSON.parse(readFileSync(metafilePath, 'utf-8'))
     cached = parseMetafile(metafile)
     cachedRoot = projectRoot
+    cachedMtimeMs = mtimeMs
     return cached
   } catch {
     return null
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function getImportGraph(projectRoot) {
if (cached && cachedRoot === projectRoot) return cached
const metafilePath = join(projectRoot, '.kaliber-build', 'server-metafile.json')
if (!existsSync(metafilePath)) return null
try {
const metafile = JSON.parse(readFileSync(metafilePath, 'utf-8'))
cached = parseMetafile(metafile)
cachedRoot = projectRoot
return cached
} catch {
return null
}
}
const { readFileSync, existsSync, statSync } = require('node:fs')
let cached = null
let cachedRoot = null
let cachedMtimeMs = null
function getImportGraph(projectRoot) {
const metafilePath = join(projectRoot, '.kaliber-build', 'server-metafile.json')
if (!existsSync(metafilePath)) return null
const mtimeMs = statSync(metafilePath).mtimeMs
if (cached && cachedRoot === projectRoot && cachedMtimeMs === mtimeMs) return cached
try {
const metafile = JSON.parse(readFileSync(metafilePath, 'utf-8'))
cached = parseMetafile(metafile)
cachedRoot = projectRoot
cachedMtimeMs = mtimeMs
return cached
} catch {
return null
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@machinery/importGraph.js` around lines 20 - 34, The module-level cache in
getImportGraph is only keyed by projectRoot, so changes to server-metafile.json
are not picked up during watch/LSP sessions. Update getImportGraph to also track
the metafile’s mtime (or equivalent freshness signal) alongside
cachedRoot/cached, and only return the cached parse when the file timestamp
matches; otherwise reread and reparse the metafile before storing the new cache.


/** @param {object} metafile — esbuild metafile with `inputs` */
function parseMetafile(metafile) {
/** @type {Map<string, Set<string>>} filePath → direct dependencies */
const graph = new Map()

for (const [filePath, info] of Object.entries(metafile.inputs || {})) {
const deps = new Set()
for (const imp of info.imports || []) {
if (imp.path && !imp.external) deps.add(imp.path)
}
graph.set(filePath, deps)
}

return { transitiveDeps, findChain }

/** Get ALL transitive dependencies of a file (BFS) */
function transitiveDeps(filePath) {
const visited = new Set()
const queue = [filePath]

while (queue.length) {
const current = queue.pop()
if (visited.has(current)) continue
visited.add(current)
for (const dep of (graph.get(current) || [])) queue.push(dep)
}

visited.delete(filePath)
return visited
}

/** Find the shortest import chain from source to target (BFS) */
function findChain(source, target) {
const queue = [[source]]
const visited = new Set([source])

while (queue.length) {
const path = queue.shift()
const current = path[path.length - 1]

for (const dep of (graph.get(current) || [])) {
if (dep === target) return [...path, dep]
if (!visited.has(dep)) {
visited.add(dep)
queue.push([...path, dep])
}
}
}

return null
}
}

/** Reset the module-level cache — used in tests */
function _resetCache() {
cached = null
cachedRoot = null
}
Loading