@sister.software/oxlint-config 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@sister.software/oxlint-config",
3
+ "version": "9.0.0",
4
+ "description": "Sister Software's oxlint config",
5
+ "license": "AGPL-3.0",
6
+ "author": "teffen@sister.software",
7
+ "files": [
8
+ "out/**/*.js",
9
+ "out/**/*.d.ts",
10
+ "out/**/*.map",
11
+ "src"
12
+ ],
13
+ "type": "module",
14
+ "types": "./out/index.d.ts",
15
+ "exports": {
16
+ "./package.json": "./package.json",
17
+ ".": {
18
+ "import": "./out/index.js",
19
+ "types": "./out/index.d.ts"
20
+ },
21
+ "./headers-plugin": {
22
+ "import": "./out/headers-plugin.js",
23
+ "types": "./out/headers-plugin.d.ts"
24
+ }
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "scripts": {
30
+ "build": "tsc -p .",
31
+ "verify": "node scripts/verify-fixtures.mjs"
32
+ },
33
+ "devDependencies": {
34
+ "@sister.software/tsconfig": "workspace:*",
35
+ "@types/node": "^25.6.0",
36
+ "typescript": "^6.0.3"
37
+ },
38
+ "peerDependencies": {
39
+ "oxlint": "^1.71.0"
40
+ },
41
+ "engines": {
42
+ "node": ">=24.0"
43
+ }
44
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ * @file A JSDoc-aware license-header rule, authored as an oxlint JS plugin (ESLint v9-compatible
6
+ * API). It reports files missing the `@copyright`/`@license`/`@author` header and autofixes them
7
+ * while preserving any other JSDoc tags already present in the leading comment block.
8
+ */
9
+
10
+ //#region Minimal ESLint-compatible plugin types (kept local to avoid coupling to oxlint's alpha
11
+ // internal type surface, which is not subject to semver).
12
+
13
+ interface Comment {
14
+ type: string
15
+ value: string
16
+ range: [number, number]
17
+ }
18
+
19
+ interface SourceCode {
20
+ getText(): string
21
+ getAllComments(): Comment[]
22
+ }
23
+
24
+ interface Fixer {
25
+ insertTextBeforeRange(range: [number, number], text: string): unknown
26
+ replaceTextRange(range: [number, number], text: string): unknown
27
+ }
28
+
29
+ interface RuleContext {
30
+ options: unknown[]
31
+ sourceCode?: SourceCode
32
+ getSourceCode?(): SourceCode
33
+ report(descriptor: { node: unknown; message: string; fix?(fixer: Fixer): unknown }): void
34
+ }
35
+
36
+ interface Rule {
37
+ meta: { name: string; type: string; fixable: "code"; schema: unknown[] }
38
+ create(context: RuleContext): Record<string, (node: unknown) => void>
39
+ }
40
+
41
+ interface Plugin {
42
+ meta: { name: string }
43
+ rules: Record<string, Rule>
44
+ }
45
+
46
+ //#endregion
47
+
48
+ /** Options accepted by the header rule. */
49
+ export interface HeaderRuleOptions {
50
+ copyrightHolder?: string
51
+ spdxLicenseIdentifier?: string
52
+ author?: string
53
+ }
54
+
55
+ const HEADER_TAG_PATTERN = /^@(copyright|license|author)\b/
56
+
57
+ /**
58
+ * Splits a block comment's inner text into trimmed, non-empty content lines (one per JSDoc line), stripping the leading
59
+ * ` * ` decoration.
60
+ */
61
+ function parseInnerLines(comment: Comment): string[] {
62
+ return comment.value
63
+ .split("\n")
64
+ .map((line) => line.replace(/^\s*\*?\s?/, "").replace(/\s+$/, ""))
65
+ .filter((line) => line.length > 0)
66
+ }
67
+
68
+ const rule: Rule = {
69
+ meta: {
70
+ name: "require-file-header",
71
+ type: "layout",
72
+ fixable: "code",
73
+ // Permissive schema: oxlint's alpha JS-plugin layer mangles complex option schemas, so we
74
+ // accept any object and read options defensively below.
75
+ schema: [{ type: "object", additionalProperties: true }],
76
+ },
77
+ create(context) {
78
+ const options = (context.options[0] ?? {}) as HeaderRuleOptions
79
+ const copyrightHolder = options.copyrightHolder ?? "Sister Software"
80
+ const spdxLicenseIdentifier = options.spdxLicenseIdentifier ?? "UNLICENSED"
81
+ const author = options.author ?? "Teffen Ellis, et al."
82
+ const headerLines = [`@copyright ${copyrightHolder}`, `@license ${spdxLicenseIdentifier}`, `@author ${author}`]
83
+
84
+ const sourceCode = context.sourceCode ?? context.getSourceCode!()
85
+
86
+ function buildBlock(preservedLines: string[]): string {
87
+ return "/**\n" + [...headerLines, ...preservedLines].map((line) => ` * ${line}`).join("\n") + "\n */"
88
+ }
89
+
90
+ return {
91
+ Program(node) {
92
+ const text = sourceCode.getText()
93
+ // A leading shebang (`#!...`) must stay on line 1, so the header goes *after* it.
94
+ const shebang = /^#![^\n]*\n?/.exec(text)?.[0] ?? ""
95
+
96
+ const comments = sourceCode.getAllComments()
97
+ // The first *block* comment, skipping any shebang (which some ASTs surface as a leading
98
+ // comment). It counts as the header block only if nothing but the shebang precedes it.
99
+ const firstBlock = comments.find((comment) => comment.type === "Block")
100
+ const leading = firstBlock && text.slice(shebang.length, firstBlock.range[0]).trim() === "" ? firstBlock : null
101
+
102
+ if (leading) {
103
+ const existing = parseInnerLines(leading)
104
+ const missing = headerLines.filter((line) => !existing.includes(line))
105
+ if (missing.length === 0) return
106
+
107
+ const preserved = existing.filter((line) => !HEADER_TAG_PATTERN.test(line))
108
+ const block = buildBlock(preserved)
109
+
110
+ context.report({
111
+ node,
112
+ message: `File header missing or incorrect: ${missing.join(", ")}`,
113
+ fix(fixer) {
114
+ return fixer.replaceTextRange(leading.range, block)
115
+ },
116
+ })
117
+ } else {
118
+ const block = buildBlock([])
119
+ const insertAt = shebang.length
120
+
121
+ context.report({
122
+ node,
123
+ message: "File header missing (@copyright/@license/@author).",
124
+ fix(fixer) {
125
+ return fixer.insertTextBeforeRange([insertAt, insertAt], block + "\n\n")
126
+ },
127
+ })
128
+ }
129
+ },
130
+ }
131
+ },
132
+ }
133
+
134
+ /** The Sister Software oxlint header plugin. Registers the `sister-software/require-file-header` rule. */
135
+ const headerPlugin: Plugin = {
136
+ meta: { name: "sister-software" },
137
+ rules: { "require-file-header": rule },
138
+ }
139
+
140
+ export default headerPlugin
package/src/index.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ * @file oxlint configuration factory for Sister Software projects.
6
+ */
7
+
8
+ import { createRuntimeOverrides } from "./restrictions.js"
9
+
10
+ export * from "./restrictions.js"
11
+
12
+ /** An oxlint configuration object, as consumed by `oxlint.config.ts` / `.oxlintrc.json`. */
13
+ export type OxlintConfig = Record<string, unknown>
14
+
15
+ /** Options for {@link createOxlintConfig}. */
16
+ export interface OxlintConfigOptions {
17
+ /** The package namespace whose runtime boundaries are enforced, e.g. `@sister.software`. */
18
+ packageNamespace?: string
19
+ /** The copyright holder stamped into file headers. */
20
+ copyrightHolder?: string
21
+ /** The SPDX license identifier stamped into file headers. */
22
+ spdxLicenseIdentifier?: string
23
+ /** The author stamped into file headers. */
24
+ author?: string
25
+ /** Enable oxlint's React plugin (off by default). */
26
+ react?: boolean
27
+ /** Enforce file headers via the bundled JS plugin (on by default). */
28
+ headers?: boolean
29
+ /** Override the default ignore patterns. */
30
+ ignorePatterns?: string[]
31
+ /** Extra config deep-merged last; an escape hatch for per-repo tweaks. */
32
+ overrides?: OxlintConfig
33
+ }
34
+
35
+ /** Default ignore patterns for generated/build output. */
36
+ export const DefaultIgnorePatterns = [
37
+ "**/out",
38
+ "**/dist",
39
+ "**/.docusaurus/**",
40
+ "**/node_modules",
41
+ "**/coverage",
42
+ "**/storybook-static",
43
+ ]
44
+
45
+ /**
46
+ * Builds the complete oxlint configuration for a Sister Software package.
47
+ *
48
+ * Consumers use it directly from an `oxlint.config.ts`:
49
+ *
50
+ * ```ts
51
+ * import { createOxlintConfig } from "@sister.software/oxlint-config"
52
+ * export default createOxlintConfig({ spdxLicenseIdentifier: "AGPL-3.0" })
53
+ * ```
54
+ *
55
+ * @param options Configuration options.
56
+ * @returns A complete oxlint config object (no `extends` required).
57
+ */
58
+ export function createOxlintConfig(options: OxlintConfigOptions = {}): OxlintConfig {
59
+ const {
60
+ packageNamespace = "@sister.software",
61
+ copyrightHolder = "Sister Software",
62
+ spdxLicenseIdentifier = "UNLICENSED",
63
+ author = "Teffen Ellis, et al.",
64
+ react = false,
65
+ headers = true,
66
+ ignorePatterns = DefaultIgnorePatterns,
67
+ overrides = {},
68
+ } = options
69
+
70
+ const plugins = ["typescript", "unicorn", "oxc", ...(react ? ["react"] : [])]
71
+
72
+ const rules: Record<string, unknown> = {
73
+ // JavaScript
74
+ eqeqeq: ["error", "always", { null: "ignore" }],
75
+ "prefer-const": "warn",
76
+ "object-shorthand": ["warn", "always"],
77
+ "no-shadow": "off",
78
+ "no-undef": "off",
79
+ "no-unused-vars": [
80
+ "warn",
81
+ {
82
+ args: "all",
83
+ argsIgnorePattern: "^_",
84
+ caughtErrors: "all",
85
+ caughtErrorsIgnorePattern: "^_",
86
+ destructuredArrayIgnorePattern: "^_",
87
+ // Matches the prior ESLint config: unused vars are not reported (Prettier/oxfmt and TS
88
+ // already cover most cases); only unused args without a `_` prefix are flagged.
89
+ varsIgnorePattern: "^\\w",
90
+ ignoreRestSiblings: true,
91
+ },
92
+ ],
93
+
94
+ // TypeScript — intentionally permissive (matches the prior ESLint config).
95
+ "typescript/ban-ts-comment": ["warn", { "ts-ignore": "allow-with-description" }],
96
+ "typescript/ban-types": "off",
97
+ "typescript/no-empty-interface": "off",
98
+ "typescript/no-explicit-any": "off",
99
+ "typescript/no-misused-new": "off",
100
+ "typescript/no-non-null-assertion": "off",
101
+ "typescript/no-var-requires": "off",
102
+ "typescript/no-require-imports": "off",
103
+ }
104
+
105
+ if (headers) {
106
+ // Error severity: missing headers should fail `oxlint` (a real CI gate) and surface as an
107
+ // editor error. The rule is auto-fixable, so `oxlint --fix` keeps the friction low.
108
+ rules["sister-software/require-file-header"] = ["error", { copyrightHolder, spdxLicenseIdentifier, author }]
109
+ }
110
+
111
+ return {
112
+ plugins,
113
+ ...(headers ? { jsPlugins: ["@sister.software/oxlint-config/headers-plugin"] } : {}),
114
+ categories: { correctness: "error" },
115
+ ignorePatterns,
116
+ rules,
117
+ overrides: createRuntimeOverrides(packageNamespace),
118
+ ...overrides,
119
+ }
120
+ }
121
+
122
+ export default createOxlintConfig
@@ -0,0 +1,253 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ * @file Platform-layering import restrictions, ported to oxlint's `no-restricted-imports`.
6
+ */
7
+
8
+ import { builtinModules } from "node:module"
9
+
10
+ //#region Runtime data
11
+
12
+ /**
13
+ * Reserved package-name suffixes mapped to their target runtime.
14
+ *
15
+ * A package whose final name segment matches one of these signals that runtime, and imports across incompatible runtime
16
+ * boundaries become lint errors.
17
+ */
18
+ export const RuntimePackageNamesRecord = {
19
+ browser: ["client", "browser"],
20
+ node: ["server", "node", "sdk"],
21
+ agnostic: ["shared", "common"],
22
+ worker: ["worker"],
23
+ } as const
24
+
25
+ export type RuntimePackageNamesRecord = typeof RuntimePackageNamesRecord
26
+
27
+ /** Valid runtime names. */
28
+ export type RuntimeName = keyof RuntimePackageNamesRecord
29
+
30
+ /**
31
+ * Given a package name, produces the glob matcher for files belonging to that package.
32
+ *
33
+ * @param packageName The final package-name segment, e.g. `client`.
34
+ * @returns A single-element glob array, e.g. `["**\/client.{js,...}"]`.
35
+ */
36
+ export function createPackageFileMatcher(packageName: string): string[] {
37
+ return [`**/${packageName}.{js,mjs,cjs,ts,d.ts,mts,tsx}`]
38
+ }
39
+
40
+ /** Joins a namespace and package name into a specifier, e.g. `@sister.software/client`. */
41
+ function namespaced(packageNamespace: string, packageName: string): string {
42
+ return [packageNamespace, packageName].filter(Boolean).join("/")
43
+ }
44
+
45
+ //#endregion
46
+
47
+ //#region Node built-in helpers
48
+
49
+ const NODE_BUILTINS_NO_PREFIX = builtinModules.filter(
50
+ (moduleName) => !moduleName.startsWith("_") && !moduleName.startsWith("node:")
51
+ )
52
+
53
+ const NODE_BUILTINS_PREFIXED = NODE_BUILTINS_NO_PREFIX.map((moduleName) => `node:${moduleName}`)
54
+
55
+ /** A restricted-import entry: an exact module name plus the message shown when it is imported. */
56
+ export interface RestrictedPath {
57
+ name: string
58
+ message: string
59
+ }
60
+
61
+ /**
62
+ * Unprefixed Node built-ins (e.g. `fs`), each nudging toward the `node:` prefix. Used by browser- and node-runtime
63
+ * files where the bare form is ambiguous.
64
+ */
65
+ export function ambiguousNodeBuiltinPaths(): RestrictedPath[] {
66
+ return NODE_BUILTINS_NO_PREFIX.map((name) => ({
67
+ name,
68
+ message: `Ambiguous module: did you mean \`node:${name}\`?`,
69
+ }))
70
+ }
71
+
72
+ /**
73
+ * Every Node built-in (prefixed and unprefixed), each carrying the given message. Used by agnostic-runtime files, which
74
+ * must not assume a Node runtime at all.
75
+ */
76
+ export function allNodeBuiltinPaths(message: string): RestrictedPath[] {
77
+ return [...NODE_BUILTINS_NO_PREFIX, ...NODE_BUILTINS_PREFIXED].map((name) => ({ name, message }))
78
+ }
79
+
80
+ //#endregion
81
+
82
+ //#region Browser globals
83
+
84
+ /**
85
+ * Browser globals whose bare use is ambiguous (they collide with common identifiers). Browser- and node-runtime files
86
+ * warn on these, nudging toward an explicit `window.` access.
87
+ */
88
+ const BROWSER_GLOBALS = [
89
+ "addEventListener",
90
+ "blur",
91
+ "close",
92
+ "closed",
93
+ "confirm",
94
+ "defaultStatus",
95
+ "defaultstatus",
96
+ "event",
97
+ "external",
98
+ "find",
99
+ "focus",
100
+ "frameElement",
101
+ "frames",
102
+ "history",
103
+ "innerHeight",
104
+ "innerWidth",
105
+ "length",
106
+ "location",
107
+ "locationbar",
108
+ "menubar",
109
+ "moveBy",
110
+ "moveTo",
111
+ "name",
112
+ "onblur",
113
+ "onerror",
114
+ "onfocus",
115
+ "onload",
116
+ "onresize",
117
+ "onunload",
118
+ "open",
119
+ "opener",
120
+ "opera",
121
+ "outerHeight",
122
+ "outerWidth",
123
+ "pageXOffset",
124
+ "pageYOffset",
125
+ "parent",
126
+ "print",
127
+ "removeEventListener",
128
+ "resizeBy",
129
+ "resizeTo",
130
+ "screen",
131
+ "screenLeft",
132
+ "screenTop",
133
+ "screenX",
134
+ "screenY",
135
+ "scroll",
136
+ "scrollbars",
137
+ "scrollBy",
138
+ "scrollTo",
139
+ "scrollX",
140
+ "scrollY",
141
+ "self",
142
+ "status",
143
+ "statusbar",
144
+ "stop",
145
+ "toolbar",
146
+ "top",
147
+ ] as const
148
+
149
+ function restrictedBrowserGlobalsRule(): unknown {
150
+ return ["warn", ...BROWSER_GLOBALS.map((name) => ({ name, message: `Ambiguous: did you mean \`window.${name}\`?` }))]
151
+ }
152
+
153
+ //#endregion
154
+
155
+ //#region Override generation
156
+
157
+ /** A per-file-glob oxlint override: applies `rules` only to files matching `files`. */
158
+ export interface OxlintOverride {
159
+ files: string[]
160
+ rules: Record<string, unknown>
161
+ }
162
+
163
+ /** A restricted-import pattern: a set of gitignore-style globs plus a message. */
164
+ interface RestrictedPattern {
165
+ group: string[]
166
+ message: string
167
+ }
168
+
169
+ /**
170
+ * Builds the `patterns` entries forbidding a runtime from importing packages of incompatible runtimes, each with a
171
+ * tailored message.
172
+ */
173
+ function crossRuntimePatterns(
174
+ packageNamespace: string,
175
+ self: RuntimeName,
176
+ incompatible: readonly RuntimeName[]
177
+ ): RestrictedPattern[] {
178
+ return incompatible.flatMap((targetRuntime) =>
179
+ RuntimePackageNamesRecord[targetRuntime].map((packageName) => {
180
+ const specifier = namespaced(packageNamespace, packageName)
181
+
182
+ return {
183
+ group: [specifier, `${specifier}/**`],
184
+ message: `A "${self}"-runtime module must not import "${specifier}", which targets the "${targetRuntime}" runtime. Move shared code into a "shared"/"common" package and import that from both.`,
185
+ }
186
+ })
187
+ )
188
+ }
189
+
190
+ /**
191
+ * Generates the per-runtime overrides enforcing platform layering. One override is emitted for each browser-, node-,
192
+ * and agnostic-runtime package name (matching the original ESLint config — worker files are a restricted _target_ but
193
+ * have no override of their own).
194
+ *
195
+ * @param packageNamespace The namespace whose packages are subject to the rules, e.g. `@sister.software`.
196
+ * @returns An array of oxlint `overrides` entries.
197
+ */
198
+ export function createRuntimeOverrides(packageNamespace: string): OxlintOverride[] {
199
+ const overrides: OxlintOverride[] = []
200
+
201
+ for (const packageName of RuntimePackageNamesRecord.browser) {
202
+ overrides.push({
203
+ files: createPackageFileMatcher(packageName),
204
+ rules: {
205
+ "no-restricted-imports": [
206
+ "warn",
207
+ {
208
+ paths: ambiguousNodeBuiltinPaths(),
209
+ patterns: crossRuntimePatterns(packageNamespace, "browser", ["node", "worker"]),
210
+ },
211
+ ],
212
+ "no-restricted-globals": restrictedBrowserGlobalsRule(),
213
+ },
214
+ })
215
+ }
216
+
217
+ for (const packageName of RuntimePackageNamesRecord.node) {
218
+ overrides.push({
219
+ files: createPackageFileMatcher(packageName),
220
+ rules: {
221
+ "no-restricted-imports": [
222
+ "warn",
223
+ {
224
+ paths: ambiguousNodeBuiltinPaths(),
225
+ patterns: crossRuntimePatterns(packageNamespace, "node", ["browser", "worker"]),
226
+ },
227
+ ],
228
+ "no-restricted-globals": restrictedBrowserGlobalsRule(),
229
+ },
230
+ })
231
+ }
232
+
233
+ for (const packageName of RuntimePackageNamesRecord.agnostic) {
234
+ overrides.push({
235
+ files: createPackageFileMatcher(packageName),
236
+ rules: {
237
+ "no-restricted-imports": [
238
+ "warn",
239
+ {
240
+ paths: allNodeBuiltinPaths(
241
+ `A "shared"/"common" module assumes no runtime and must not import Node built-ins.`
242
+ ),
243
+ patterns: crossRuntimePatterns(packageNamespace, "agnostic", ["browser", "node", "worker"]),
244
+ },
245
+ ],
246
+ },
247
+ })
248
+ }
249
+
250
+ return overrides
251
+ }
252
+
253
+ //#endregion