Skip to content

Commit 787e577

Browse files
committed
feat(sfcc): add valid-require-path rule for SFCC module imports
add new sfcc rule to validate require path patterns allow SFCC-specific forms: dw/, cartridgeName/, ./, ../, */, ~/ keep optional cartridge existence check via rule options register and enable rule in recommended config add tests for allowed, invalid, dynamic, and existence-check scenarios document rule behavior and configuration in README
1 parent 3de5e5a commit 787e577

5 files changed

Lines changed: 309 additions & 1 deletion

File tree

README.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,34 @@ The new `sfcc` plugin contains the general Rhino/SFCC runtime rules:
8383
| `sfcc/prefer-const` | Requires `const` for `let` declarations that are never reassigned, excluding Rhino-sensitive nested/loop contexts. | `error` |
8484
| `sfcc/rhino-const-compat` | Enforces `let` instead of `const` in Rhino loop-critical contexts (loop headers and declarations inside loop bodies) and supports auto-fix. | `error` |
8585
| `sfcc/rhino-const-conflict` | Detects same-name `const` declarations in nested blocks within the same function (Rhino treats them as function-scoped) and supports auto-fix to `let`. | `error` |
86+
| `sfcc/valid-require-path` | Validates SFCC-compatible `require()` paths (`dw/*`, `cartridgeName/*`, `./*`, `../*`, `*/*`, `~/*`) and supports optional cartridge-existence checks. | `error` |
8687

87-
The recommended config intentionally combines these three `sfcc/*` rules so `--fix` does not bounce between conflicting suggestions: Rhino-unsafe `const` becomes `let`, while genuinely safe top-level function bindings still become `const`.
88+
The recommended config intentionally combines these `sfcc/*` rules so `--fix` does not bounce between conflicting suggestions: Rhino-unsafe `const` becomes `let`, while genuinely safe top-level function bindings still become `const`.
89+
90+
### `sfcc/valid-require-path` options
91+
92+
By default, the rule validates path patterns only and allows bare `server` requires.
93+
94+
```js
95+
import { defineConfig } from "eslint/config"
96+
import sfcc from "@jenssimon/eslint-config-sfcc"
97+
98+
export default defineConfig(sfcc.configs.recommended, {
99+
rules: {
100+
"sfcc/valid-require-path": [
101+
"error",
102+
{
103+
// Optional: allow additional bare module ids
104+
allowBareModules: ["server", "proxyquire"],
105+
// Optional: verify cartridgeName/* against filesystem
106+
checkCartridgeExists: true,
107+
// Optional: absolute or relative path to cartridges root
108+
cartridgesDir: "cartridges",
109+
},
110+
],
111+
},
112+
})
113+
```
88114

89115
### Rhino const strategy example
90116

src/plugins/sfcc/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import preferConst from "./prefer-const.js"
22
import rhinoConstCompat from "./rhino-const-compat.js"
33
import rhinoConstConflict from "./rhino-const-conflict.js"
4+
import validRequirePath from "./valid-require-path.js"
45

56
const sfcc = {
67
rules: {
78
"prefer-const": preferConst,
89
"rhino-const-compat": rhinoConstCompat,
910
"rhino-const-conflict": rhinoConstConflict,
11+
"valid-require-path": validRequirePath,
1012
},
1113
}
1214

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import type { Rule } from "eslint"
2+
3+
import fs from "node:fs"
4+
import path from "node:path"
5+
6+
type RuleOptions = {
7+
allowBareModules?: string[]
8+
checkCartridgeExists?: boolean
9+
cartridgesDir?: string
10+
}
11+
12+
function getStringArgument(node: Rule.Node): string | undefined {
13+
if (node.type === "Literal" && typeof node.value === "string") {
14+
return node.value
15+
}
16+
17+
if (node.type === "TemplateLiteral" && node.expressions.length === 0) {
18+
return node.quasis[0]?.value.cooked ?? undefined
19+
}
20+
21+
return undefined
22+
}
23+
24+
function isAllowedPrefix(requirePath: string): boolean {
25+
return (
26+
requirePath.startsWith("dw/") ||
27+
requirePath.startsWith("./") ||
28+
requirePath.startsWith("../") ||
29+
requirePath.startsWith("*/") ||
30+
requirePath.startsWith("~/")
31+
)
32+
}
33+
34+
function isCartridgeStylePath(requirePath: string): boolean {
35+
return /^[A-Za-z0-9_-]+\/.+/u.test(requirePath)
36+
}
37+
38+
function getFirstSegment(requirePath: string): string {
39+
const slashIndex = requirePath.indexOf("/")
40+
return slashIndex === -1 ? requirePath : requirePath.slice(0, slashIndex)
41+
}
42+
43+
function resolveCartridgesDir(cartridgesDir: string, cwd: string): string {
44+
return path.isAbsolute(cartridgesDir) ? cartridgesDir : path.resolve(cwd, cartridgesDir)
45+
}
46+
47+
function cartridgeExists(cartridgeName: string, cartridgesDir: string, cwd: string): boolean {
48+
const baseDir = resolveCartridgesDir(cartridgesDir, cwd)
49+
const cartridgeRoot = path.join(baseDir, cartridgeName)
50+
51+
try {
52+
return fs.statSync(cartridgeRoot).isDirectory()
53+
} catch {
54+
return false
55+
}
56+
}
57+
58+
const validRequirePath: Rule.RuleModule = {
59+
meta: {
60+
type: "problem",
61+
docs: {
62+
description:
63+
"Enforce SFCC-compatible require paths (dw/, relative, cartridge-name/, */, ~/).",
64+
recommended: true,
65+
},
66+
schema: [
67+
{
68+
type: "object",
69+
properties: {
70+
allowBareModules: {
71+
type: "array",
72+
items: { type: "string" },
73+
uniqueItems: true,
74+
},
75+
checkCartridgeExists: { type: "boolean" },
76+
cartridgesDir: { type: "string" },
77+
},
78+
additionalProperties: false,
79+
},
80+
],
81+
messages: {
82+
invalidPath:
83+
'Invalid require path "{{requirePath}}". Allowed: dw/*, cartridgeName/*, ./*, ../*, */*, ~/* or configured bare modules.',
84+
unknownCartridge:
85+
'Unknown cartridge "{{cartridgeName}}" in require path "{{requirePath}}" (checked in "{{cartridgesDir}}/").',
86+
},
87+
},
88+
create: (context) => {
89+
const options = (context.options[0] ?? {}) as RuleOptions
90+
const allowBareModules = new Set(options.allowBareModules ?? ["server"])
91+
const checkCartridgeExists = options.checkCartridgeExists === true
92+
const cartridgesDir = options.cartridgesDir ?? "cartridges"
93+
const cwd =
94+
(context as Rule.RuleContext & { cwd?: string }).cwd ??
95+
(context as Rule.RuleContext & { getCwd?: () => string }).getCwd?.() ??
96+
process.cwd()
97+
98+
return {
99+
CallExpression(node) {
100+
const callNode = node as Rule.Node & {
101+
callee?: { type?: string; name?: string }
102+
arguments?: Rule.Node[]
103+
}
104+
105+
if (callNode.callee?.type !== "Identifier" || callNode.callee.name !== "require") {
106+
return
107+
}
108+
109+
const firstArgument = callNode.arguments?.[0]
110+
if (!firstArgument) {
111+
return
112+
}
113+
114+
const requirePath = getStringArgument(firstArgument)
115+
if (!requirePath) {
116+
return
117+
}
118+
119+
if (isAllowedPrefix(requirePath)) {
120+
return
121+
}
122+
123+
if (!requirePath.includes("/")) {
124+
if (!allowBareModules.has(requirePath)) {
125+
context.report({
126+
node: firstArgument,
127+
messageId: "invalidPath",
128+
data: { requirePath },
129+
})
130+
}
131+
return
132+
}
133+
134+
if (!isCartridgeStylePath(requirePath)) {
135+
context.report({
136+
node: firstArgument,
137+
messageId: "invalidPath",
138+
data: { requirePath },
139+
})
140+
return
141+
}
142+
143+
if (!checkCartridgeExists) {
144+
return
145+
}
146+
147+
const cartridgeName = getFirstSegment(requirePath)
148+
if (!cartridgeExists(cartridgeName, cartridgesDir, cwd)) {
149+
context.report({
150+
node: firstArgument,
151+
messageId: "unknownCartridge",
152+
data: { cartridgeName, requirePath, cartridgesDir },
153+
})
154+
}
155+
},
156+
}
157+
},
158+
}
159+
160+
export default validRequirePath

src/rules/sfcc.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const sfcc: Linter.RulesRecord = {
44
"sfcc/prefer-const": "error",
55
"sfcc/rhino-const-compat": "error",
66
"sfcc/rhino-const-conflict": "error",
7+
"sfcc/valid-require-path": "error",
78
}
89

910
export default sfcc
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { Linter } from "eslint"
2+
import fs from "node:fs"
3+
import os from "node:os"
4+
import path from "node:path"
5+
import { expect, test } from "vite-plus/test"
6+
7+
import { recommended } from "../src/index.js"
8+
9+
function lint(code: string, filename = "cartridges/app_sfra/cartridge/controllers/Home.js") {
10+
const linter = new Linter()
11+
return linter.verify(code, recommended, { filename })
12+
}
13+
14+
test("allows dw requires", () => {
15+
const messages = lint(`
16+
const OrderMgr = require("dw/order/OrderMgr")
17+
module.exports = OrderMgr
18+
`)
19+
20+
expect(messages.some((m) => m.ruleId === "sfcc/valid-require-path")).toBe(false)
21+
})
22+
23+
test("allows cartridge-style requires", () => {
24+
const messages = lint(`
25+
const helper = require("app_storefront/cartridge/scripts/helper")
26+
module.exports = helper
27+
`)
28+
29+
expect(messages.some((m) => m.ruleId === "sfcc/valid-require-path")).toBe(false)
30+
})
31+
32+
test("allows relative requires", () => {
33+
const messages = lint(`
34+
const one = require("./test")
35+
const two = require("../test")
36+
module.exports = { one, two }
37+
`)
38+
39+
expect(messages.some((m) => m.ruleId === "sfcc/valid-require-path")).toBe(false)
40+
})
41+
42+
test("allows SFCC star and tilde requires", () => {
43+
const messages = lint(`
44+
const one = require("*/cartridge/scripts/middleware/csrf")
45+
const two = require("~/cartridge/scripts/middleware/auth")
46+
module.exports = { one, two }
47+
`)
48+
49+
expect(messages.some((m) => m.ruleId === "sfcc/valid-require-path")).toBe(false)
50+
})
51+
52+
test("allows configured default bare module server", () => {
53+
const messages = lint(`
54+
const server = require("server")
55+
module.exports = server
56+
`)
57+
58+
expect(messages.some((m) => m.ruleId === "sfcc/valid-require-path")).toBe(false)
59+
})
60+
61+
test("reports invalid bare module requires", () => {
62+
const messages = lint(`
63+
const lodash = require("lodash")
64+
module.exports = lodash
65+
`)
66+
67+
expect(messages.some((m) => m.ruleId === "sfcc/valid-require-path")).toBe(true)
68+
})
69+
70+
test("ignores dynamic requires", () => {
71+
const messages = lint(`
72+
const moduleName = "dw/order/OrderMgr"
73+
const dynamic = require(moduleName)
74+
module.exports = dynamic
75+
`)
76+
77+
expect(messages.some((m) => m.ruleId === "sfcc/valid-require-path")).toBe(false)
78+
})
79+
80+
test("supports checkCartridgeExists option", () => {
81+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "eslint-config-sfcc-"))
82+
const cartridgesDir = path.join(tempRoot, "cartridges")
83+
const existingCartridge = path.join(cartridgesDir, "app_storefront")
84+
85+
fs.mkdirSync(existingCartridge, { recursive: true })
86+
87+
const linter = new Linter()
88+
const config: Linter.Config[] = [
89+
{
90+
...recommended[0],
91+
rules: {
92+
...recommended[0]?.rules,
93+
"sfcc/valid-require-path": [
94+
"error",
95+
{
96+
checkCartridgeExists: true,
97+
cartridgesDir,
98+
},
99+
],
100+
},
101+
},
102+
]
103+
104+
const messages = linter.verify(
105+
`
106+
const ok = require("app_storefront/cartridge/scripts/ok")
107+
const bad = require("missing_cartridge/cartridge/scripts/bad")
108+
module.exports = { ok, bad }
109+
`,
110+
config,
111+
{ filename: "cartridges/app_sfra/cartridge/controllers/Home.js" },
112+
)
113+
114+
fs.rmSync(tempRoot, { recursive: true, force: true })
115+
116+
const hits = messages.filter((m) => m.ruleId === "sfcc/valid-require-path")
117+
expect(hits).toHaveLength(1)
118+
expect(hits[0]?.message.includes("missing_cartridge")).toBe(true)
119+
})

0 commit comments

Comments
 (0)