Skip to content

Commit 852e2da

Browse files
authored
chore: extracts (npm and GH) constraint linter into separate package (#4888)
Extraction of our package.json and GH action constraints linter to a new separate `@kumahq` package. Note: I got an LLM to do this, reined it in, and then heavily amended it following my own review, please take this into account when reviewing to make sure the original functionality remains as-is. I'm also giving this a secondary review myself. I also tried to make the "nested workspace" functionality we have as an optional (not applied by default) feature (undocumented) seeing as this feature is probably very rarely required. --------- Signed-off-by: John Cowen <john.cowen@konghq.com>
1 parent 5bc3b6c commit 852e2da

13 files changed

Lines changed: 319 additions & 78 deletions

File tree

package-lock.json

Lines changed: 16 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ BrandiJS with token-based service registration for managing application
1212
dependencies.
1313
- **[@kumahq/data](./data)** - Vue components (`DataSource` and `DataLoader`) for
1414
handling external data fetching, loading states, and error handling.
15+
- **[@kumahq/eslint-package-constraints](./eslint-package-constraints)** - ESLint flat config for
16+
enforcing `package.json` and GitHub workflow version constraints across a monorepo, including
17+
dependency version range rules and SHA pinning for third-party actions.
1518
- **[@kumahq/fake-api](./fake-api)** - Utilities for loading and serving mock HTTP API
1619
responses with generated fake data via FakerJS for development and testing.
1720
- **[@kumahq/gherkin-web](./gherkin-web)** - Gherkin/Cucumber step definitions for testing web

packages/config/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,19 +43,18 @@
4343
"@cucumber/gherkin-utils": "^11.0.0",
4444
"@eslint/js": "^9.39.4",
4545
"@kumahq/gherkin-web": "*",
46+
"@kumahq/eslint-package-constraints": "*",
4647
"@playwright/test": "^1.59.1",
4748
"@stylistic/eslint-plugin": "^5.10.0",
4849
"@stylistic/stylelint-plugin": "^5.1.0",
4950
"@vue/eslint-config-typescript": "^14.7.0",
5051
"@vue/tsconfig": "^0.9.1",
5152
"deepmerge": "^4.3.1",
5253
"esbuild": "^0.28.0",
53-
"escape-string-regexp": "^4.0.0",
5454
"eslint": "^9.39.4",
5555
"eslint-import-resolver-alias": "^1.1.2",
5656
"eslint-import-resolver-typescript": "^4.4.4",
5757
"eslint-plugin-import": "^2.32.0",
58-
"eslint-plugin-json-schema-validator": "^6.2.0",
5958
"eslint-plugin-no-unsanitized": "^4.1.5",
6059
"eslint-plugin-vue": "^10.9.0",
6160
"globals": "^17.5.0",

packages/config/src/eslint.js

Lines changed: 12 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,11 @@
11
import eslint from '@eslint/js'
2+
import { defineConfig as packageConstraints, getNpmWorkspaceJSON } from '@kumahq/eslint-package-constraints'
23
import stylistic from '@stylistic/eslint-plugin'
34
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
4-
import escape from 'escape-string-regexp'
55
import importPlugin from 'eslint-plugin-import'
6-
import jsonSchemaValidatorPlugin from 'eslint-plugin-json-schema-validator'
76
import nounsanitized from 'eslint-plugin-no-unsanitized'
87
import vuePlugin from 'eslint-plugin-vue'
98
import globals from 'globals'
10-
import { execSync } from 'node:child_process'
11-
import { readFileSync as read } from 'node:fs'
12-
import { resolve, dirname } from 'node:path'
13-
import { fileURLToPath } from 'node:url'
14-
15-
const $config = dirname(fileURLToPath(import.meta.resolve('@kumahq/config')))
16-
17-
const workspace = JSON.parse(
18-
execSync('npm query :root').toString(),
19-
).at(0)
20-
21-
const packageSchema = JSON.parse(
22-
read(resolve(`${$config}/package.schema.json`), 'utf-8'),
23-
)
24-
25-
const workflowSchema = JSON.parse(
26-
read(resolve(`${$config}/workflow.schema.json`), 'utf-8'),
27-
)
289

2910
// Taken from https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/utils/inline-non-void-elements.json.
3011
const INLINE_NON_VOID_ELEMENTS = [
@@ -76,39 +57,16 @@ export function createEslintConfig(
7657
{
7758
tsConfigPath = 'tsconfig.json',
7859
componentIgnorePatterns = [],
79-
versionIgnorePatterns = {},
60+
dependencyIgnorePatterns = {},
8061
workspaceRoot = false,
8162
} = {
8263
tsConfigPath: 'tsconfig.json',
8364
componentIgnorePatterns: [],
84-
versionIgnorePatterns: {},
65+
dependencyIgnorePatterns: {},
8566
workspaceRoot: false,
8667
},
8768
) {
8869

89-
// amend our package.json schema depending on arguments
90-
((schema) => {
91-
// 1. include any ignored version patterns
92-
['dependencies', 'devDependencies', 'peerDependencies'].forEach((item) => {
93-
schema.properties[item].patternProperties = {
94-
...schema.properties[item].patternProperties,
95-
...versionIgnorePatterns[item] ?? {},
96-
}
97-
})
98-
99-
// 2. Take engines from the root of kumahq/kuma-gui and make sure
100-
// any other workspace root using this linter use the same engines
101-
Object.entries(workspace.engines).forEach(([key, value]) => {
102-
schema.definitions[key].pattern = `^${escape(value)}$`
103-
})
104-
105-
// 3. Any non-root package.json files shouldn't ever specify `engines`
106-
if (!workspaceRoot) {
107-
schema.properties.engines = {
108-
not: {},
109-
}
110-
}
111-
})(packageSchema)
11270

11371
const vueTsConfig = defineConfigWithVueTs(
11472
...vuePlugin.configs['flat/recommended'],
@@ -124,30 +82,6 @@ export function createEslintConfig(
12482
},
12583
]
12684

127-
const jsonSchemaValidatorConfig = [
128-
...jsonSchemaValidatorPlugin.configs['flat/recommended'],
129-
{
130-
rules: {
131-
'json-schema-validator/no-invalid': ['error', {
132-
useSchemastoreCatalog: false,
133-
mergeSchemas: true,
134-
schemas: [
135-
{
136-
fileMatch: ['package.json'],
137-
// our schema allows for ignoring individual dependencies if required
138-
// see ./package.schema.json patternProperties examples
139-
schema: packageSchema,
140-
},
141-
{
142-
fileMatch: ['.github/**/*.{yaml,yml}'],
143-
schema: workflowSchema,
144-
},
145-
],
146-
}],
147-
},
148-
},
149-
]
150-
15185
const stylisticConfig = {
15286
plugins: {
15387
'@stylistic': stylistic,
@@ -181,19 +115,24 @@ export function createEslintConfig(
181115
'@stylistic/semi': ['error', 'never'],
182116
},
183117
}
184-
118+
const workspaceJSON = getNpmWorkspaceJSON()
185119
return [
186120
// when linting workspaceRoots we want to ignore
187121
// sub-packages which are linted separately
188122
workspaceRoot ? {
189-
ignores: workspace.workspaces,
123+
ignores: workspaceJSON.workspaces,
190124
} : {},
191-
//
192125
eslint.configs.recommended,
193126
nounsanitized.configs.recommended,
194127
...vueTsConfig,
195128
...importConfig,
196-
...jsonSchemaValidatorConfig,
129+
//
130+
...packageConstraints({
131+
workspaceRoot,
132+
dependencyIgnorePatterns,
133+
workspaceJSON,
134+
}),
135+
//
197136
stylisticConfig,
198137
{
199138
languageOptions: {
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Please keep this file free of actual scripts
2+
# It should only be used for adding "non-dot" aliases and documentation
3+
4+
SHELL := /usr/bin/env bash -o pipefail
5+
6+
NPM_WORKSPACE_ROOT := $(shell npm prefix)
7+
KUMAHQ_CONFIG := $(NPM_WORKSPACE_ROOT)/$(shell cat $(NPM_WORKSPACE_ROOT)/package-lock.json | jq -r '.packages | to_entries[] | select(.value.name == "@kumahq/config") | .key')
8+
MK := $(KUMAHQ_CONFIG)/src/mk
9+
10+
## make help: if you're aren't sure use `make help`
11+
.DEFAULT_GOAL := help
12+
13+
include $(MK)/index.mk
14+
15+
.PHONY: help
16+
help: .help ## Display this help screen
17+
18+
.PHONY: clean
19+
clean: .clean ## Dev: Remove all `node_modules` recursively
20+
21+
.PHONY: install
22+
install: .install ## Dev: Install all dependencies
23+
24+
.PHONY: lint
25+
lint: .lint/script ## Dev: Run all lint script checks (js,ts)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# eslint-package-constraints
2+
3+
ESLint package that enforces `package.json` and GitHub workflow version
4+
constraints across a monorepo.
5+
6+
## Features
7+
8+
- Validates `package.json` files against a JSON Schema that enforces consistent
9+
dependency version ranges and engine requirements. Defaults to preferring carat
10+
versions but allows ignore rules
11+
- Validates `.github/**/*.{yaml,yml}` workflow files against a schema that
12+
enforces immutable SHA pinning for third-party actions
13+
- Prevents non-root packages from specifying their own `engines` field
14+
- Locks any "nested workspaces" to the same `node`/`npm` engine versions as the
15+
"root-most" workspace
16+
17+
## Usage
18+
19+
```js
20+
import { defineConfig } from '@kumahq/eslint-package-constraints'
21+
22+
export default [
23+
...defineConfig({
24+
workspaceRoot: false, // set true if linting at the monorepo root, otherwise `engines` properties are not allowed in the package.json
25+
dependencyIgnorePatterns: {
26+
dependencies: { // we are amending prod dependencies only (other options are `devDependencies` and `peerDependencies`)
27+
'@kong-ui-public/app-layout': {
28+
'$ref': '#/definitions/exactOnlyVersion', // reuse our definition to enforce only specifying an "exact version constraint"
29+
},
30+
},
31+
},
32+
}),
33+
]
34+
```
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/** @typedef {import('eslint').Linter.Config[]} Config */
2+
3+
import { eslint } from '@kumahq/config'
4+
5+
const /** @type Config */ config = [
6+
...eslint({}),
7+
]
8+
9+
export default config
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"name": "@kumahq/eslint-package-constraints",
3+
"version": "0.1.0",
4+
"private": true,
5+
"description": "ESLint flat config for enforcing package.json and workflow constraints",
6+
"author": "Kong",
7+
"type": "module",
8+
"main": "src/index.ts",
9+
"dependencies": {
10+
"escape-string-regexp": "^4.0.0",
11+
"eslint-plugin-json-schema-validator": "^6.2.0"
12+
},
13+
"devDependencies": {
14+
"@kumahq/config": "*"
15+
}
16+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import escape from 'escape-string-regexp'
2+
import jsonSchemaValidatorPlugin from 'eslint-plugin-json-schema-validator'
3+
import { execSync } from 'node:child_process'
4+
import { readFileSync as read } from 'node:fs'
5+
import { resolve, dirname } from 'node:path'
6+
import { fileURLToPath } from 'node:url'
7+
8+
import type { Linter } from 'eslint'
9+
10+
const $dir = dirname(fileURLToPath(import.meta.url))
11+
12+
type DependencyKey = 'dependencies' | 'devDependencies' | 'peerDependencies'
13+
14+
type Options = {
15+
workspaceRoot?: boolean
16+
dependencyIgnorePatterns?: Partial<
17+
Record<DependencyKey, Record<string, unknown>>
18+
>
19+
workspaceJSON?: Record<string, unknown>
20+
}
21+
export const getNpmWorkspaceJSON = () => JSON.parse(execSync('npm query :root').toString()).at(0)
22+
export const defineConfig = ({
23+
workspaceRoot = false,
24+
dependencyIgnorePatterns = {},
25+
workspaceJSON = {},
26+
}: Options = {}): Linter.Config[] => {
27+
const packageSchema = JSON.parse(read(resolve($dir, 'package.schema.json'), 'utf-8'))
28+
const workflowSchema = JSON.parse(read(resolve($dir, 'workflow.schema.json'), 'utf-8'))
29+
30+
// 1. include any ignored version patterns
31+
;(['dependencies', 'devDependencies', 'peerDependencies'] as DependencyKey[]).forEach((item) => {
32+
packageSchema.properties[item].patternProperties = {
33+
...packageSchema.properties[item].patternProperties,
34+
...dependencyIgnorePatterns[item] ?? {},
35+
}
36+
})
37+
38+
// 2. Take engines from the workspace root and enforce them across all packages
39+
if(workspaceJSON !== null && typeof workspaceJSON === 'object' && Object.keys(workspaceJSON).length > 0) {
40+
Object.entries(workspaceJSON?.engines ?? {}).forEach(([key, value]) => {
41+
packageSchema.definitions[key].pattern = `^${escape(value)}$`
42+
})
43+
}
44+
45+
// 3. Any non-root package.json files shouldn't ever specify `engines`
46+
if (!workspaceRoot) {
47+
packageSchema.properties.engines = { not: {} }
48+
}
49+
50+
return [
51+
...jsonSchemaValidatorPlugin.configs['flat/recommended'],
52+
{
53+
rules: {
54+
'json-schema-validator/no-invalid': ['error', {
55+
useSchemastoreCatalog: false,
56+
mergeSchemas: true,
57+
schemas: [
58+
{
59+
fileMatch: ['package.json'],
60+
// our schema allows for ignoring individual dependencies if required
61+
// see ./package.schema.json patternProperties examples
62+
schema: packageSchema,
63+
},
64+
{
65+
fileMatch: ['.github/**/*.{yaml,yml}'],
66+
schema: workflowSchema,
67+
},
68+
],
69+
}],
70+
},
71+
},
72+
]
73+
}

0 commit comments

Comments
 (0)