@diia-inhouse/oxc-config 3.2.0 → 4.0.1

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.
@@ -1,84 +1,620 @@
1
- import { readFileSync } from 'node:fs'
2
- import { resolve } from 'node:path'
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join, resolve } from 'node:path'
3
3
 
4
- let pkg = null
5
- let loaded = false
4
+ const FIX_FLAGS = new Set(['--fix', '--fix-suggestions', '--fix-dangerously'])
5
+ const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
6
+ const PUBLISH_FIELDS = ['license', 'files', 'publishConfig', 'repository']
7
+ const LEGACY_CONFIG_FIELDS = ['prettier', 'eslintConfig', 'eslintIgnore']
8
+ const LEGACY_PACKAGES = [
9
+ 'eslint',
10
+ 'typescript-eslint',
11
+ 'jsonc-eslint-parser',
12
+ 'eslint-config-prettier',
13
+ 'eslint-plugin-prettier',
14
+ '@diia-inhouse/eslint-config',
15
+ 'prettier',
16
+ 'prettier-plugin-sort-imports',
17
+ '@ianvs/prettier-plugin-sort-imports',
18
+ 'tsc-watch',
19
+ 'ts-node',
20
+ 'ts-node-dev',
21
+ 'nodemon',
22
+ ]
23
+ const LEGACY_SCRIPT_NAMES = new Set(['eslint', 'prettier'])
24
+ const LEGACY_TOOL_PATTERN = /\b(eslint|prettier)\b/
25
+ const SERVICE_NAME_PATTERN = /^service-|-service$/
26
+ const VERSION_RANGE_PATTERN = /^[\^~]/
27
+ const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
28
+ const INSTALLED_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies']
29
+ const EMPTY_FIELDS = [
30
+ 'keywords',
31
+ 'description',
32
+ 'author',
33
+ 'scripts',
34
+ 'dependencies',
35
+ 'devDependencies',
36
+ 'peerDependencies',
37
+ 'optionalDependencies',
38
+ 'overrides',
39
+ ]
40
+ const DEV_TOOL_PATTERNS = [
41
+ /^@types\//,
42
+ /^vitest(-|$)/,
43
+ /^@vitest\//,
44
+ /^jest(-|$)/,
45
+ /^@jest\//,
46
+ /^oxlint(-|$)/,
47
+ /^oxfmt$/,
48
+ /^eslint(-|$)/,
49
+ /^@eslint\//,
50
+ /^prettier(-|$)/,
51
+ /^typescript$/,
52
+ /^madge$/,
53
+ /^lockfile-lint$/,
54
+ /^semantic-release(-|$)/,
55
+ /^@semantic-release\//,
56
+ /^rimraf$/,
57
+ /^tsc-alias$/,
58
+ /^tsc-watch$/,
59
+ /^@diia-inhouse\/(test|oxc-config|configs|scaffold|eslint-config|eslint-plugin)$/,
60
+ ]
61
+ const DEFAULT_NODE_MAJOR = 24
62
+ const DEFAULT_AUTHOR = 'diia-team'
63
+ const DEV_SCRIPT = 'diia-dev'
64
+ const COMMITLINT_CONFIG = '@diia-inhouse/configs/dist/commitlint'
65
+ const MADGE_TS_CONFIG = './tsconfig.json'
66
+
67
+ const isFixRun = () => process.argv.some((arg) => FIX_FLAGS.has(arg))
68
+
69
+ const manifests = new Map()
70
+ const manifestByDir = new Map()
71
+
72
+ function findManifest(file) {
73
+ const seen = []
74
+ let dir = dirname(resolve(file))
75
+ let found = null
76
+
77
+ for (;;) {
78
+ const known = manifestByDir.get(dir)
79
+
80
+ if (known !== undefined) {
81
+ found = known
82
+ break
83
+ }
84
+
85
+ seen.push(dir)
86
+
87
+ const candidate = join(dir, 'package.json')
88
+ const parent = dirname(dir)
89
+
90
+ if (existsSync(candidate)) {
91
+ found = candidate
92
+ break
93
+ }
94
+
95
+ if (parent === dir) {
96
+ break
97
+ }
98
+
99
+ dir = parent
100
+ }
6
101
 
7
- function loadPackageJson() {
8
- if (loaded) {
9
- return pkg
102
+ for (const d of seen) {
103
+ manifestByDir.set(d, found)
10
104
  }
11
105
 
12
- loaded = true
106
+ return found
107
+ }
108
+
109
+ function loadManifest(path) {
110
+ let text
13
111
 
14
112
  try {
15
- pkg = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
113
+ text = readFileSync(path, 'utf8')
16
114
  } catch {
17
- // package.json not found or not parseable
115
+ return null
116
+ }
117
+
118
+ const cached = manifests.get(path)
119
+
120
+ if (cached?.text === text) {
121
+ return cached
18
122
  }
19
123
 
20
- return pkg
124
+ try {
125
+ const entry = { path, text, data: JSON.parse(text), reported: new Set() }
126
+
127
+ manifests.set(path, entry)
128
+
129
+ return entry
130
+ } catch {
131
+ return null
132
+ }
21
133
  }
22
134
 
23
- const VERSION_RANGE_PATTERN = /^[\^~]/
135
+ function writeManifest(entry, data) {
136
+ const indent = /^\{\r?\n([ \t]+)/.exec(entry.text)?.[1] ?? ' '
137
+ const text = `${JSON.stringify(data, null, indent)}${entry.text.endsWith('\n') ? '\n' : ''}`
24
138
 
25
- const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
139
+ writeFileSync(entry.path, text)
140
+ entry.text = text
141
+ entry.data = data
142
+ }
26
143
 
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
- },
144
+ function loadLock(entry) {
145
+ if (entry.lock === undefined) {
146
+ try {
147
+ entry.lock = JSON.parse(readFileSync(join(dirname(entry.path), 'package-lock.json'), 'utf8'))
148
+ } catch {
149
+ entry.lock = null
150
+ }
151
+ }
152
+
153
+ return entry.lock
154
+ }
155
+
156
+ const installedVersion = (entry, name) => loadLock(entry)?.packages?.[`node_modules/${name}`]?.version
157
+
158
+ const parseVersion = (text) => {
159
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(text)
160
+
161
+ return match ? match.slice(1, 4).map(Number) : null
162
+ }
163
+
164
+ const compareVersions = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]
165
+
166
+ const lowestOf = (comparator) => {
167
+ const bare = comparator.replace(/^[\^~]|^>=|^=/, '')
168
+
169
+ if (comparator === '' || comparator === '*' || /^[<x]/i.test(comparator)) {
170
+ return [0, 0, 0]
171
+ }
172
+
173
+ const match = /^(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?(?:[-+][0-9A-Za-z.+-]*)?$/.exec(bare)
174
+
175
+ return match
176
+ ? [match[1], match[2], match[3]].map((part) => (part === undefined || part === 'x' || part === '*' ? 0 : Number(part)))
177
+ : null
178
+ }
179
+
180
+ const minVersion = (range) => {
181
+ const alternatives = String(range)
182
+ .split('||')
183
+ .map((alternative) =>
184
+ alternative
185
+ .trim()
186
+ .replace(/\s+-\s+.*$/, '')
187
+ .split(/\s+/)
188
+ .filter(Boolean),
189
+ )
190
+ const mins = alternatives.map((comparators) => {
191
+ const lows = comparators.map(lowestOf)
192
+
193
+ return lows.some((low) => low === null)
194
+ ? null
195
+ : lows.reduce((highest, low) => (compareVersions(low, highest) > 0 ? low : highest), [0, 0, 0])
196
+ })
197
+
198
+ return mins.some((min) => min === null) ? null : mins.reduce((lowest, min) => (compareVersions(min, lowest) < 0 ? min : lowest))
199
+ }
200
+
201
+ const formatVersion = (version) => version.join('.')
202
+
203
+ const allDependencies = (data) => Object.assign({}, ...DEPENDENCY_FIELDS.map((field) => data[field] ?? {}))
204
+ const dependsOn = (data, name) => name in allDependencies(data)
205
+ const isUnscoped = ({ name }) => typeof name === 'string' && !name.startsWith('@')
206
+ const isPrivate = (data) => data.private === true || isUnscoped(data)
207
+ const isDevTool = (name) => DEV_TOOL_PATTERNS.some((pattern) => pattern.test(name))
208
+ const isEmpty = (value) =>
209
+ value === '' ||
210
+ (Array.isArray(value) && value.length === 0) ||
211
+ (typeof value === 'object' && value !== null && !Array.isArray(value) && Object.keys(value).length === 0)
212
+
213
+ export const manifestRules = {
214
+ 'no-service-in-package-name': {
215
+ messages: {
216
+ suffix: 'package.json "name" is "{{ name }}": the repository is called <name>-service, the package is called <name>. Not auto-fixed, because the name is the service identity at runtime.',
217
+ },
218
+ check: ({ name }) => (typeof name === 'string' && SERVICE_NAME_PATTERN.test(name) ? [{ messageId: 'suffix', data: { name } }] : []),
219
+ },
220
+
221
+ 'pinned-dependencies': {
222
+ messages: {
223
+ unpinned: 'Dependency "{{ name }}" has unpinned version "{{ version }}". Pin to an exact version (remove ^ or ~).',
224
+ inexact:
225
+ 'Dependency "{{ name }}" is "{{ version }}", not an exact version from the registry. Not auto-fixed: run `npm i {{ name }}@<version>`.',
226
+ },
227
+ check: (data) => {
228
+ const violations = []
229
+
230
+ for (const field of DEPENDENCY_FIELDS) {
231
+ for (const [name, version] of Object.entries(data[field] ?? {})) {
232
+ if (typeof version !== 'string') {
233
+ continue
234
+ }
235
+
236
+ if (VERSION_RANGE_PATTERN.test(version)) {
237
+ violations.push({ messageId: 'unpinned', data: { name, version } })
238
+ } else if (INSTALLED_FIELDS.includes(field) && !EXACT_VERSION_PATTERN.test(version)) {
239
+ violations.push({ messageId: 'inexact', data: { name, version } })
240
+ }
46
241
  }
47
- },
242
+ }
243
+
244
+ return violations
48
245
  },
246
+ fix: (data, _options, entry) => {
247
+ for (const field of DEPENDENCY_FIELDS) {
248
+ for (const [name, version] of Object.entries(data[field] ?? {})) {
249
+ const installed = typeof version === 'string' && VERSION_RANGE_PATTERN.test(version) && installedVersion(entry, name)
49
250
 
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
- }
251
+ if (installed) {
252
+ data[field][name] = installed
253
+ }
254
+ }
255
+ }
65
256
 
66
- for (const field of DEP_FIELDS) {
67
- const deps = data[field]
257
+ return data
258
+ },
259
+ },
260
+
261
+ 'unique-dependencies': {
262
+ messages: {
263
+ duplicate:
264
+ 'Dependency "{{ name }}" is listed in both {{ first }} and {{ second }}. Not auto-fixed, because package-lock.json must change with it: run `npm uninstall -D {{ name }}` if it is needed at runtime, `npm uninstall {{ name }}` if not.',
265
+ },
266
+ check: (data) => {
267
+ const violations = []
268
+ const seen = new Map()
269
+
270
+ for (const field of INSTALLED_FIELDS) {
271
+ for (const name of Object.keys(data[field] ?? {})) {
272
+ if (seen.has(name)) {
273
+ violations.push({ messageId: 'duplicate', data: { name, first: seen.get(name), second: field } })
274
+ } else {
275
+ seen.set(name, field)
276
+ }
277
+ }
278
+ }
68
279
 
69
- if (!deps) {
70
- continue
71
- }
280
+ return violations
281
+ },
282
+ },
283
+
284
+ 'no-dev-tooling-in-dependencies': {
285
+ messages: {
286
+ tool: 'Dependency "{{ name }}" is a development tool listed as a runtime dependency. Not auto-fixed, because package-lock.json must change with it: run `npm uninstall {{ name }} && npm i -D {{ name }}`.',
287
+ },
288
+ check: (data) =>
289
+ isPrivate(data)
290
+ ? Object.keys(data.dependencies ?? {})
291
+ .filter(isDevTool)
292
+ .map((name) => ({ messageId: 'tool', data: { name } }))
293
+ : [],
294
+ },
295
+
296
+ 'private-package': {
297
+ messages: {
298
+ missing: 'An unscoped package ("{{ name }}") can never be published to the registry; say so with "private": true.',
299
+ },
300
+ check: (data) => (isUnscoped(data) && data.private !== true ? [{ messageId: 'missing', data: { name: data.name } }] : []),
301
+ fix: (data) => ({ ...data, private: true }),
302
+ },
303
+
304
+ 'no-publish-fields': {
305
+ messages: {
306
+ field: 'package.json "{{ field }}" describes publishing, and this package is private. Remove it.',
307
+ },
308
+ check: (data) =>
309
+ isPrivate(data)
310
+ ? PUBLISH_FIELDS.filter((field) => field in data).map((field) => ({ messageId: 'field', data: { field } }))
311
+ : [],
312
+ fix: (data) => {
313
+ for (const field of PUBLISH_FIELDS) {
314
+ delete data[field]
315
+ }
316
+
317
+ return data
318
+ },
319
+ },
320
+
321
+ 'no-empty-fields': {
322
+ messages: {
323
+ empty: 'package.json "{{ field }}" is empty. Remove it.',
324
+ },
325
+ check: (data) =>
326
+ EMPTY_FIELDS.filter((field) => field in data && isEmpty(data[field])).map((field) => ({ messageId: 'empty', data: { field } })),
327
+ fix: (data) => {
328
+ for (const field of EMPTY_FIELDS) {
329
+ if (field in data && isEmpty(data[field])) {
330
+ delete data[field]
331
+ }
332
+ }
333
+
334
+ return data
335
+ },
336
+ },
337
+
338
+ 'engines-node': {
339
+ schema: [{ type: 'object', properties: { nodeMajor: { type: 'integer' } }, additionalProperties: false }],
340
+ messages: {
341
+ engines: 'package.json "engines.node" must be "{{ expected }}", the Node.js major CI builds on (found {{ found }}).',
342
+ },
343
+ check: (data, { nodeMajor = DEFAULT_NODE_MAJOR }) => {
344
+ const expected = `>=${nodeMajor}`
72
345
 
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
- }
346
+ return data.engines?.node === expected
347
+ ? []
348
+ : [{ messageId: 'engines', data: { expected, found: data.engines?.node ?? 'nothing' } }]
349
+ },
350
+ fix: (data, { nodeMajor = DEFAULT_NODE_MAJOR }) => ({ ...data, engines: { ...data.engines, node: `>=${nodeMajor}` } }),
351
+ },
352
+
353
+ author: {
354
+ schema: [{ type: 'object', properties: { author: { type: 'string' } }, additionalProperties: false }],
355
+ messages: {
356
+ author: 'package.json "author" must be "{{ expected }}" (found {{ found }}).',
357
+ },
358
+ check: (data, { author = DEFAULT_AUTHOR }) =>
359
+ data.author === author ? [] : [{ messageId: 'author', data: { expected: author, found: data.author ?? 'nothing' } }],
360
+ fix: (data, { author = DEFAULT_AUTHOR }) => ({ ...data, author }),
361
+ },
362
+
363
+ description: {
364
+ messages: {
365
+ missing: 'package.json "description" is empty. Say in one line what this package does.',
366
+ },
367
+ check: ({ description }) => (typeof description === 'string' && description.trim() !== '' ? [] : [{ messageId: 'missing' }]),
368
+ },
369
+
370
+ 'no-legacy-tooling': {
371
+ messages: {
372
+ installed:
373
+ 'The oxc toolchain replaced {{ names }}. Not auto-fixed, because package-lock.json must change with it: run `npm uninstall {{ command }}`.',
374
+ },
375
+ check: (data) => {
376
+ const installed = LEGACY_PACKAGES.filter((name) => dependsOn(data, name))
377
+
378
+ return installed.length > 0
379
+ ? [{ messageId: 'installed', data: { names: installed.join(', '), command: installed.join(' ') } }]
380
+ : []
381
+ },
382
+ },
383
+
384
+ 'no-legacy-config-fields': {
385
+ messages: {
386
+ field: 'package.json "{{ field }}" configures a tool the oxc toolchain replaced. Remove it.',
387
+ },
388
+ check: (data) => LEGACY_CONFIG_FIELDS.filter((field) => field in data).map((field) => ({ messageId: 'field', data: { field } })),
389
+ fix: (data) => {
390
+ for (const field of LEGACY_CONFIG_FIELDS) {
391
+ delete data[field]
392
+ }
393
+
394
+ return data
395
+ },
396
+ },
397
+
398
+ 'no-legacy-scripts': {
399
+ messages: {
400
+ script: 'Script "{{ name }}" runs {{ tool }}, which the oxc toolchain replaced.',
401
+ },
402
+ check: ({ scripts = {} }) => {
403
+ const violations = []
404
+
405
+ for (const [name, body] of Object.entries(scripts)) {
406
+ const tool = LEGACY_SCRIPT_NAMES.has(name) ? name : LEGACY_TOOL_PATTERN.exec(String(body))?.[1]
407
+
408
+ if (tool) {
409
+ violations.push({ messageId: 'script', data: { name, tool } })
410
+ }
411
+ }
412
+
413
+ return violations
414
+ },
415
+ fix: (data) => {
416
+ for (const [name, body] of Object.entries(data.scripts ?? {})) {
417
+ if (LEGACY_SCRIPT_NAMES.has(name)) {
418
+ delete data.scripts[name]
419
+ } else {
420
+ data.scripts[name] = String(body)
421
+ .replace(/\bprettier --write\b/g, 'oxfmt')
422
+ .replace(/\bprettier --check\b/g, 'oxfmt --check')
423
+ }
424
+ }
425
+
426
+ return data
427
+ },
428
+ },
429
+
430
+ 'dev-script': {
431
+ messages: {
432
+ dev: 'Script "dev" must run "{{ expected }}" (found "{{ found }}"); it watches the entry file with the tsx bundled in @diia-inhouse/oxc-config.',
433
+ },
434
+ check: (data) => {
435
+ const dev = data.scripts?.dev
436
+
437
+ return dependsOn(data, '@diia-inhouse/diia-app') && typeof dev === 'string' && !/^diia-dev(\s|$)/.test(dev)
438
+ ? [{ messageId: 'dev', data: { expected: DEV_SCRIPT, found: dev } }]
439
+ : []
440
+ },
441
+ fix: (data) => ({ ...data, scripts: { ...data.scripts, dev: DEV_SCRIPT } }),
442
+ },
443
+
444
+ 'peers-installed-locally': {
445
+ messages: {
446
+ missing:
447
+ 'Peer dependency "{{ name }}" is not installed for this package\'s own tests. Not auto-fixed, because package-lock.json must change with it: run `npm i -D {{ name }}`.',
448
+ },
449
+ check: (data) =>
450
+ isPrivate(data)
451
+ ? []
452
+ : Object.keys(data.peerDependencies ?? {})
453
+ .filter((name) => data.peerDependenciesMeta?.[name]?.optional !== true)
454
+ .filter((name) => !(name in (data.devDependencies ?? {})) && !(name in (data.dependencies ?? {})))
455
+ .map((name) => ({ messageId: 'missing', data: { name } })),
456
+ },
457
+
458
+ 'exports-package-json': {
459
+ messages: {
460
+ missing:
461
+ 'package.json "exports" does not expose "./package.json", so nothing can read this package\'s manifest through the package.',
462
+ },
463
+ check: (data) => {
464
+ const { exports } = data
465
+
466
+ if (
467
+ isPrivate(data) ||
468
+ exports === undefined ||
469
+ (typeof exports === 'object' && exports !== null && './package.json' in exports)
470
+ ) {
471
+ return []
472
+ }
473
+
474
+ return [{ messageId: 'missing' }]
475
+ },
476
+ fix: (data) => {
477
+ const { exports } = data
478
+ const isSubpathMap =
479
+ typeof exports === 'object' &&
480
+ exports !== null &&
481
+ !Array.isArray(exports) &&
482
+ Object.keys(exports).every((key) => key.startsWith('.'))
483
+
484
+ return isSubpathMap ? { ...data, exports: { ...exports, './package.json': './package.json' } } : data
485
+ },
486
+ },
487
+
488
+ 'stale-overrides': {
489
+ messages: {
490
+ dead: 'Override "{{ name }}" pins a package nothing in the dependency tree uses. Remove it: `npm pkg delete overrides.{{ name }} && npm install`.',
491
+ redundant:
492
+ 'Override "{{ name }}" = {{ pin }} raises nothing: every dependent already asks for {{ pin }} or newer. Remove it: `npm pkg delete overrides.{{ name }} && npm install`.',
493
+ holdingBack:
494
+ 'Override "{{ name }}" = {{ pin }} holds {{ dependents }} below the {{ wanted }} they ask for. Raise it: `npm pkg set overrides.{{ name }}={{ wanted }} && npm install`.',
495
+ },
496
+ check: (data, _options, entry) => {
497
+ const packages = loadLock(entry)?.packages
498
+
499
+ if (!packages) {
500
+ return []
501
+ }
502
+
503
+ const violations = []
504
+
505
+ for (const [name, pin] of Object.entries(data.overrides ?? {})) {
506
+ if (typeof pin !== 'string' || !EXACT_VERSION_PATTERN.test(pin)) {
507
+ continue
508
+ }
509
+
510
+ const pinned = parseVersion(pin)
511
+ const dependents = []
512
+
513
+ for (const [location, manifest] of Object.entries(packages)) {
514
+ for (const field of DEPENDENCY_FIELDS) {
515
+ const range = manifest[field]?.[name]
516
+
517
+ if (range !== undefined) {
518
+ dependents.push({
519
+ from: location.replace(/^node_modules\//, '') || 'the package itself',
520
+ min: minVersion(range),
521
+ })
78
522
  }
79
- },
523
+ }
80
524
  }
81
- },
525
+
526
+ if (dependents.length === 0) {
527
+ violations.push({ messageId: 'dead', data: { name } })
528
+ continue
529
+ }
530
+
531
+ const known = dependents.filter((dependent) => dependent.min !== null)
532
+ const above = known.filter((dependent) => compareVersions(dependent.min, pinned) > 0)
533
+
534
+ if (above.length > 0) {
535
+ const wanted = above.reduce(
536
+ (highest, dependent) => (compareVersions(dependent.min, highest) > 0 ? dependent.min : highest),
537
+ pinned,
538
+ )
539
+
540
+ violations.push({
541
+ messageId: 'holdingBack',
542
+ data: { name, pin, wanted: formatVersion(wanted), dependents: above.map((dependent) => dependent.from).join(', ') },
543
+ })
544
+ } else if (known.length === dependents.length && known.every((dependent) => compareVersions(dependent.min, pinned) >= 0)) {
545
+ violations.push({ messageId: 'redundant', data: { name, pin } })
546
+ }
547
+ }
548
+
549
+ return violations
550
+ },
551
+ },
552
+
553
+ 'shared-configs': {
554
+ messages: {
555
+ commitlint: 'package.json "commitlint.extends" must be "{{ expected }}" so commit messages are checked by the shared rules.',
556
+ madge: 'package.json "madge.tsConfig" must be "{{ expected }}" so find-circulars resolves the same paths as the build.',
557
+ },
558
+ check: (data) => {
559
+ const violations = []
560
+
561
+ if (dependsOn(data, '@diia-inhouse/configs') && data.commitlint?.extends !== COMMITLINT_CONFIG) {
562
+ violations.push({ messageId: 'commitlint', data: { expected: COMMITLINT_CONFIG } })
563
+ }
564
+
565
+ if (dependsOn(data, 'madge') && data.madge?.tsConfig !== MADGE_TS_CONFIG) {
566
+ violations.push({ messageId: 'madge', data: { expected: MADGE_TS_CONFIG } })
567
+ }
568
+
569
+ return violations
570
+ },
571
+ fix: (data) => {
572
+ if (dependsOn(data, '@diia-inhouse/configs')) {
573
+ data.commitlint = { ...data.commitlint, extends: COMMITLINT_CONFIG }
574
+ }
575
+
576
+ if (dependsOn(data, 'madge')) {
577
+ data.madge = { ...data.madge, tsConfig: MADGE_TS_CONFIG }
578
+ }
579
+
580
+ return data
82
581
  },
83
582
  },
84
583
  }
584
+
585
+ function defineRule(name, { messages, schema = [], check, fix }) {
586
+ return {
587
+ meta: { type: 'problem', messages, schema },
588
+ create(context) {
589
+ return {
590
+ Program(node) {
591
+ const path = findManifest(context.filename)
592
+ const entry = path && loadManifest(path)
593
+
594
+ if (!entry || entry.reported.has(name)) {
595
+ return
596
+ }
597
+
598
+ entry.reported.add(name)
599
+
600
+ const options = context.options?.[0] ?? {}
601
+ let violations = check(entry.data, options, entry)
602
+
603
+ if (violations.length > 0 && fix && isFixRun()) {
604
+ writeManifest(entry, fix(structuredClone(entry.data), options, entry))
605
+ violations = check(entry.data, options, entry)
606
+ }
607
+
608
+ for (const { messageId, data } of violations) {
609
+ context.report({ node, messageId, data })
610
+ }
611
+ },
612
+ }
613
+ },
614
+ }
615
+ }
616
+
617
+ export default {
618
+ meta: { name: '@diia-inhouse/oxlint-plugin-package' },
619
+ rules: Object.fromEntries(Object.entries(manifestRules).map(([name, rule]) => [name, defineRule(name, rule)])),
620
+ }