@diia-inhouse/oxc-config 1.9.2

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.
@@ -0,0 +1,183 @@
1
+ function isSchemaConstructor(callee) {
2
+ return (
3
+ (callee.type === 'Identifier' && callee.name === 'Schema') ||
4
+ (callee.type === 'MemberExpression' &&
5
+ callee.object.type === 'Identifier' &&
6
+ callee.object.name === 'mongoose' &&
7
+ callee.property.type === 'Identifier' &&
8
+ callee.property.name === 'Schema')
9
+ )
10
+ }
11
+
12
+ function getOptionValue(optionsNode, key) {
13
+ if (!optionsNode || optionsNode.type !== 'ObjectExpression') {
14
+ return undefined
15
+ }
16
+
17
+ const prop = optionsNode.properties.find((p) => p.type === 'Property' && p.key.type === 'Identifier' && p.key.name === key)
18
+
19
+ if (!prop) {
20
+ return undefined
21
+ }
22
+
23
+ return prop.value.type === 'Literal' ? prop.value.value : undefined
24
+ }
25
+
26
+ function isModelFile(filename) {
27
+ return filename.includes('/models/')
28
+ }
29
+
30
+ export default {
31
+ meta: { name: '@diia-inhouse/oxlint-plugin-mongoose' },
32
+ rules: {
33
+ 'schema-timestamps': {
34
+ meta: {
35
+ type: 'problem',
36
+ messages: {
37
+ missing: 'Mongoose schema is missing { timestamps: true } in options.',
38
+ },
39
+ },
40
+ create(context) {
41
+ if (!isModelFile(context.filename)) {
42
+ return {}
43
+ }
44
+
45
+ const subSchemaVars = new Set()
46
+
47
+ return {
48
+ 'VariableDeclarator[init.type="NewExpression"]'(node) {
49
+ if (!isSchemaConstructor(node.init.callee)) {
50
+ return
51
+ }
52
+
53
+ const options = node.init.arguments[1]
54
+
55
+ if (getOptionValue(options, '_id') === false) {
56
+ subSchemaVars.add(node.id.name)
57
+ }
58
+ },
59
+ 'ExportDefaultDeclaration, ExportNamedDeclaration'() {
60
+ // Reset tracking per export boundary — not needed
61
+ },
62
+ 'Program:exit'(programNode) {
63
+ for (const stmt of programNode.body) {
64
+ if (stmt.type !== 'VariableDeclaration') {
65
+ continue
66
+ }
67
+
68
+ for (const decl of stmt.declarations) {
69
+ if (!decl.init || decl.init.type !== 'NewExpression' || !isSchemaConstructor(decl.init.callee)) {
70
+ continue
71
+ }
72
+
73
+ if (subSchemaVars.has(decl.id.name)) {
74
+ continue
75
+ }
76
+
77
+ const options = decl.init.arguments[1]
78
+
79
+ if (getOptionValue(options, 'timestamps') !== true) {
80
+ context.report({ node: decl, messageId: 'missing' })
81
+ }
82
+ }
83
+ }
84
+ },
85
+ }
86
+ },
87
+ },
88
+
89
+ 'sub-schema-id-false': {
90
+ meta: {
91
+ type: 'problem',
92
+ messages: {
93
+ missing: 'Sub-schema is missing { _id: false } in options. Embedded documents should not generate _id.',
94
+ },
95
+ },
96
+ create(context) {
97
+ if (!isModelFile(context.filename)) {
98
+ return {}
99
+ }
100
+
101
+ const schemaVars = new Map()
102
+
103
+ return {
104
+ VariableDeclarator(node) {
105
+ if (!node.init || node.init.type !== 'NewExpression' || !isSchemaConstructor(node.init.callee)) {
106
+ return
107
+ }
108
+
109
+ schemaVars.set(node.id.name, {
110
+ node: node.init,
111
+ hasIdFalse: getOptionValue(node.init.arguments[1], '_id') === false,
112
+ hasTimestamps: getOptionValue(node.init.arguments[1], 'timestamps') === true,
113
+ usedAsSubSchema: false,
114
+ })
115
+ },
116
+ Property(node) {
117
+ if (node.key.type !== 'Identifier' || node.key.name !== 'type' || node.value.type !== 'ArrayExpression') {
118
+ return
119
+ }
120
+
121
+ for (const el of node.value.elements) {
122
+ if (el && el.type === 'Identifier' && schemaVars.has(el.name)) {
123
+ schemaVars.get(el.name).usedAsSubSchema = true
124
+ }
125
+ }
126
+ },
127
+ 'Program:exit'() {
128
+ for (const [, info] of schemaVars) {
129
+ if (info.usedAsSubSchema && !info.hasIdFalse) {
130
+ context.report({ node: info.node, messageId: 'missing' })
131
+ }
132
+ }
133
+ },
134
+ }
135
+ },
136
+ },
137
+
138
+ 'status-requires-history': {
139
+ meta: {
140
+ type: 'problem',
141
+ messages: {
142
+ missing:
143
+ 'Schema has "status" field but no "statusHistory". Every model with status MUST have statusHistory — missing it is a data corruption bug.',
144
+ },
145
+ },
146
+ create(context) {
147
+ if (!isModelFile(context.filename)) {
148
+ return {}
149
+ }
150
+
151
+ return {
152
+ NewExpression(node) {
153
+ if (!isSchemaConstructor(node.callee)) {
154
+ return
155
+ }
156
+
157
+ const options = node.arguments[1]
158
+
159
+ if (getOptionValue(options, '_id') === false) {
160
+ return
161
+ }
162
+
163
+ const fields = node.arguments[0]
164
+
165
+ if (!fields || fields.type !== 'ObjectExpression') {
166
+ return
167
+ }
168
+
169
+ const fieldNames = new Set(
170
+ fields.properties.filter((p) => p.type === 'Property' && p.key.type === 'Identifier').map((p) => p.key.name),
171
+ )
172
+
173
+ const hasHistory = [...fieldNames].some((name) => name.toLowerCase().includes('statushistor'))
174
+
175
+ if (fieldNames.has('status') && !hasHistory) {
176
+ context.report({ node, messageId: 'missing' })
177
+ }
178
+ },
179
+ }
180
+ },
181
+ },
182
+ },
183
+ }
@@ -0,0 +1,43 @@
1
+ const CYRILLIC_PATTERN = /[\u0400-\u04FF\u0500-\u052F]/
2
+
3
+ const EXCLUDED_PATHS = ['/locales/', '.spec.ts', '.test.ts', '/tests/']
4
+
5
+ function isExcludedFile(filename) {
6
+ return EXCLUDED_PATHS.some((path) => filename.includes(path))
7
+ }
8
+
9
+ function checkStringNode(context, node) {
10
+ if (isExcludedFile(context.filename)) {
11
+ return
12
+ }
13
+
14
+ const value = node.type === 'TemplateLiteral' ? node.quasis.map((q) => q.value.raw).join('') : node.value
15
+
16
+ if (typeof value === 'string' && CYRILLIC_PATTERN.test(value)) {
17
+ context.report({ node, messageId: 'forbidden', data: { text: value.length > 50 ? value.slice(0, 50) + '...' : value } })
18
+ }
19
+ }
20
+
21
+ export default {
22
+ meta: { name: '@diia-inhouse/oxlint-plugin-locale' },
23
+ rules: {
24
+ 'no-hardcoded-cyrillic': {
25
+ meta: {
26
+ type: 'problem',
27
+ messages: {
28
+ forbidden: 'Hardcoded Cyrillic string "{{ text }}" found. Move user-facing text to locale files and use i18n service.',
29
+ },
30
+ },
31
+ create(context) {
32
+ return {
33
+ Literal(node) {
34
+ checkStringNode(context, node)
35
+ },
36
+ TemplateLiteral(node) {
37
+ checkStringNode(context, node)
38
+ },
39
+ }
40
+ },
41
+ },
42
+ },
43
+ }
@@ -0,0 +1,84 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+
4
+ let pkg = null
5
+ let loaded = false
6
+
7
+ function loadPackageJson() {
8
+ if (loaded) {
9
+ return pkg
10
+ }
11
+
12
+ loaded = true
13
+
14
+ try {
15
+ pkg = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
16
+ } catch {
17
+ // package.json not found or not parseable
18
+ }
19
+
20
+ return pkg
21
+ }
22
+
23
+ const VERSION_RANGE_PATTERN = /^[\^~]/
24
+
25
+ const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
26
+
27
+ export default {
28
+ meta: { name: '@diia-inhouse/oxlint-plugin-package' },
29
+ rules: {
30
+ 'no-service-in-package-name': {
31
+ meta: {
32
+ type: 'problem',
33
+ messages: {
34
+ forbidden: 'package.json "name" field must not contain the word "service" (found: "{{ name }}")',
35
+ },
36
+ },
37
+ create(context) {
38
+ return {
39
+ Program(node) {
40
+ const data = loadPackageJson()
41
+
42
+ if (data?.name?.includes('service')) {
43
+ context.report({ node, messageId: 'forbidden', data: { name: data.name } })
44
+ }
45
+ },
46
+ }
47
+ },
48
+ },
49
+
50
+ 'pinned-dependencies': {
51
+ meta: {
52
+ type: 'problem',
53
+ messages: {
54
+ unpinned: 'Dependency "{{ name }}" has unpinned version "{{ version }}". Pin to an exact version (remove ^ or ~).',
55
+ },
56
+ },
57
+ create(context) {
58
+ return {
59
+ Program(node) {
60
+ const data = loadPackageJson()
61
+
62
+ if (!data) {
63
+ return
64
+ }
65
+
66
+ for (const field of DEP_FIELDS) {
67
+ const deps = data[field]
68
+
69
+ if (!deps) {
70
+ continue
71
+ }
72
+
73
+ for (const [name, version] of Object.entries(deps)) {
74
+ if (typeof version === 'string' && VERSION_RANGE_PATTERN.test(version)) {
75
+ context.report({ node, messageId: 'unpinned', data: { name, version } })
76
+ }
77
+ }
78
+ }
79
+ },
80
+ }
81
+ },
82
+ },
83
+ },
84
+ }
@@ -0,0 +1,169 @@
1
+ function isTestFile(filename) {
2
+ return filename.includes('/tests/') || filename.includes('/test/')
3
+ }
4
+
5
+ function isWorkflowFile(filename) {
6
+ return !isTestFile(filename) && filename.includes('/worker/workflows/') && !filename.includes('.types.')
7
+ }
8
+
9
+ function isActivityFile(filename) {
10
+ return !isTestFile(filename) && filename.includes('/worker/activities/')
11
+ }
12
+
13
+ const FORBIDDEN_NODE_MODULES = new Set([
14
+ 'node:path',
15
+ 'path',
16
+ 'node:fs',
17
+ 'fs',
18
+ 'node:fs/promises',
19
+ 'fs/promises',
20
+ 'node:crypto',
21
+ 'crypto',
22
+ 'node:child_process',
23
+ 'child_process',
24
+ 'node:os',
25
+ 'os',
26
+ 'node:net',
27
+ 'net',
28
+ 'node:dns',
29
+ 'dns',
30
+ 'node:http',
31
+ 'http',
32
+ 'node:https',
33
+ 'https',
34
+ ])
35
+
36
+ export default {
37
+ meta: { name: '@diia-inhouse/oxlint-plugin-temporal' },
38
+ rules: {
39
+ 'workflow-single-param': {
40
+ meta: {
41
+ type: 'problem',
42
+ messages: {
43
+ tooMany:
44
+ 'Workflow function "{{ name }}" has {{ count }} parameters. Workflows must accept a single object parameter for serialization compatibility.',
45
+ },
46
+ },
47
+ create(context) {
48
+ if (!isWorkflowFile(context.filename)) {
49
+ return {}
50
+ }
51
+
52
+ function checkFunction(node, name) {
53
+ if (node.params.length > 1) {
54
+ context.report({
55
+ node,
56
+ messageId: 'tooMany',
57
+ data: { name: name || '<anonymous>', count: String(node.params.length) },
58
+ })
59
+ }
60
+ }
61
+
62
+ return {
63
+ 'ExportNamedDeclaration > FunctionDeclaration'(node) {
64
+ checkFunction(node, node.id?.name)
65
+ },
66
+ 'ExportNamedDeclaration > VariableDeclaration > VariableDeclarator'(node) {
67
+ if (node.init && (node.init.type === 'ArrowFunctionExpression' || node.init.type === 'FunctionExpression')) {
68
+ checkFunction(node.init, node.id?.name)
69
+ }
70
+ },
71
+ }
72
+ },
73
+ },
74
+
75
+ 'async-activity': {
76
+ meta: {
77
+ type: 'problem',
78
+ messages: {
79
+ notAsync: 'Activity method "{{ name }}" must be async. All Temporal activities must be async functions.',
80
+ },
81
+ },
82
+ create(context) {
83
+ if (!isActivityFile(context.filename)) {
84
+ return {}
85
+ }
86
+
87
+ return {
88
+ MethodDefinition(node) {
89
+ if (node.kind === 'constructor' || node.key.type !== 'Identifier' || node.accessibility === 'private') {
90
+ return
91
+ }
92
+
93
+ const fn = node.value
94
+
95
+ if (fn && !fn.async) {
96
+ context.report({
97
+ node: node.key,
98
+ messageId: 'notAsync',
99
+ data: { name: node.key.name },
100
+ })
101
+ }
102
+ },
103
+ }
104
+ },
105
+ },
106
+
107
+ 'no-node-imports': {
108
+ meta: {
109
+ type: 'problem',
110
+ messages: {
111
+ forbidden:
112
+ 'Importing "{{ source }}" in a workflow file will break the Temporal bundle. Workflows run in a sandboxed VM — Node.js built-in modules are not available.',
113
+ },
114
+ },
115
+ create(context) {
116
+ if (!isWorkflowFile(context.filename)) {
117
+ return {}
118
+ }
119
+
120
+ return {
121
+ ImportDeclaration(node) {
122
+ const source = node.source.value
123
+
124
+ if (FORBIDDEN_NODE_MODULES.has(source)) {
125
+ context.report({
126
+ node: node.source,
127
+ messageId: 'forbidden',
128
+ data: { source },
129
+ })
130
+ }
131
+ },
132
+ }
133
+ },
134
+ },
135
+
136
+ 'no-path-alias-imports': {
137
+ meta: {
138
+ type: 'problem',
139
+ messages: {
140
+ forbidden:
141
+ 'Path alias "{{ source }}" cannot be used in workflow files. The Temporal bundler does not resolve tsconfig path aliases. Use relative imports or import only from type-only workflow packages.',
142
+ },
143
+ },
144
+ create(context) {
145
+ if (!isWorkflowFile(context.filename)) {
146
+ return {}
147
+ }
148
+
149
+ return {
150
+ ImportDeclaration(node) {
151
+ if (node.importKind === 'type') {
152
+ return
153
+ }
154
+
155
+ const source = node.source.value
156
+
157
+ if (source.startsWith('@') && !source.startsWith('@diia-inhouse/') && !source.startsWith('@temporalio/')) {
158
+ context.report({
159
+ node: node.source,
160
+ messageId: 'forbidden',
161
+ data: { source },
162
+ })
163
+ }
164
+ },
165
+ }
166
+ },
167
+ },
168
+ },
169
+ }
@@ -0,0 +1,67 @@
1
+ const PERSISTENT_MOCK_METHODS = new Set(['mockResolvedValue', 'mockReturnValue', 'mockRejectedValue', 'mockImplementation'])
2
+
3
+ function getOnceVariant(name) {
4
+ return `${name}Once`
5
+ }
6
+
7
+ export default {
8
+ meta: { name: '@diia-inhouse/oxlint-plugin-test' },
9
+ rules: {
10
+ 'no-persistent-mock': {
11
+ meta: {
12
+ type: 'problem',
13
+ messages: {
14
+ forbidden:
15
+ 'Use {{ once }}() instead of {{ method }}(). Persistent mocks hide test isolation issues and mask unexpected call counts.',
16
+ },
17
+ },
18
+ create(context) {
19
+ return {
20
+ CallExpression(node) {
21
+ if (
22
+ node.callee.type === 'MemberExpression' &&
23
+ node.callee.property.type === 'Identifier' &&
24
+ PERSISTENT_MOCK_METHODS.has(node.callee.property.name)
25
+ ) {
26
+ const method = node.callee.property.name
27
+
28
+ context.report({
29
+ node: node.callee.property,
30
+ messageId: 'forbidden',
31
+ data: { method, once: getOnceVariant(method) },
32
+ })
33
+ }
34
+ },
35
+ }
36
+ },
37
+ },
38
+
39
+ 'no-vi-mock-in-workflows': {
40
+ meta: {
41
+ type: 'problem',
42
+ messages: {
43
+ forbidden: 'vi.mock() is forbidden in workflow tests. Use mockActivities() from Temporal test utilities instead.',
44
+ },
45
+ },
46
+ create(context) {
47
+ if (!context.filename.includes('/worker/')) {
48
+ return {}
49
+ }
50
+
51
+ return {
52
+ CallExpression(node) {
53
+ if (
54
+ node.callee.type === 'MemberExpression' &&
55
+ node.callee.object.type === 'Identifier' &&
56
+ node.callee.object.name === 'vi' &&
57
+ node.callee.property.type === 'Identifier' &&
58
+ node.callee.property.name === 'mock'
59
+ ) {
60
+ context.report({ node, messageId: 'forbidden' })
61
+ }
62
+ },
63
+ }
64
+ },
65
+ },
66
+ },
67
+ }
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@diia-inhouse/oxc-config",
3
+ "version": "1.9.2",
4
+ "description": "OXC toolchain configs for Diia services: oxlint, oxfmt, oxc transformer",
5
+ "author": "Diia",
6
+ "license": "SEE LICENSE IN LICENSE.md",
7
+ "type": "module",
8
+ "bin": {
9
+ "diia-dev": "./bin/dev.mjs"
10
+ },
11
+ "exports": {
12
+ "./oxlint": "./dist/oxlint/config.js",
13
+ "./oxfmt": "./dist/oxfmt.config.js"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "oxlint/plugins",
18
+ "bin"
19
+ ],
20
+ "engines": {
21
+ "node": ">=22"
22
+ },
23
+ "scripts": {
24
+ "test": "vitest run",
25
+ "build": "tsc -p tsconfig.build.json && cp -r oxlint/plugins dist/oxlint/plugins",
26
+ "prepare": "npm run build",
27
+ "lint": "oxlint && oxfmt --check",
28
+ "lint-fix": "oxlint --fix && oxfmt",
29
+ "find-circulars": "madge --circular --extensions ts ./",
30
+ "lint:lockfile": "lockfile-lint --path package-lock.json --allowed-hosts registry.npmjs.org --validate-https",
31
+ "semantic-release": "semantic-release"
32
+ },
33
+ "dependencies": {
34
+ "@boundaries/eslint-plugin": "6.0.2",
35
+ "@diia-inhouse/eslint-plugin": "1.9.6",
36
+ "@stylistic/eslint-plugin": "5.10.0",
37
+ "eslint-import-resolver-oxc": "0.15.0",
38
+ "eslint-plugin-regexp": "3.1.0",
39
+ "eslint-plugin-security": "4.0.0",
40
+ "tsx": "4.21.0"
41
+ },
42
+ "peerDependencies": {
43
+ "oxfmt": ">= 0.43.0",
44
+ "oxlint": ">= 1.58.0",
45
+ "oxlint-tsgolint": ">= 0.20.0"
46
+ },
47
+ "devDependencies": {
48
+ "@diia-inhouse/configs": "6.1.1",
49
+ "@typescript-eslint/parser": "8.59.1",
50
+ "@typescript-eslint/rule-tester": "8.59.1",
51
+ "@vitest/coverage-v8": "4.1.5",
52
+ "@vitest/ui": "4.1.5",
53
+ "lockfile-lint": "5.0.0",
54
+ "madge": "8.0.0",
55
+ "oxfmt": "0.47.0",
56
+ "oxlint": "1.62.0",
57
+ "oxlint-tsgolint": "0.22.1",
58
+ "semantic-release": "24.2.7",
59
+ "vitest": "4.1.5"
60
+ },
61
+ "commitlint": {
62
+ "extends": "@diia-inhouse/configs/dist/commitlint"
63
+ },
64
+ "release": {
65
+ "extends": "@diia-inhouse/configs/dist/semantic-release/package"
66
+ },
67
+ "repository": "https://github.com/diia-open-source/be-oxc-config"
68
+ }