@sando-ui/eslint-plugin 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +64 -2
- package/lib/configs.mjs +176 -0
- package/lib/index.mjs +41 -0
- package/lib/resolve.mjs +65 -0
- package/lib/rules/css.mjs +167 -0
- package/lib/rules/screens.mjs +122 -0
- package/lib/rules/units.mjs +254 -0
- package/lib/token-names.mjs +58 -0
- package/lib/typescript-api.mjs +32 -0
- package/package.json +42 -4
- package/surface.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Song
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,65 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @sando-ui/eslint-plugin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
ESLint rules a service repository runs over its own screens and CSS beside installed Sando units.
|
|
4
|
+
Sando UI is a component design system (React Aria for behavior, Vanilla CSS and design tokens for
|
|
5
|
+
look) distributed as a copy-paste registry. These rules keep your own code on the system's tokens
|
|
6
|
+
and public classes, and keep your screens renderable from props alone.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
Install it with the CLI, both pinned to the same exact version (`<version>` is the Sando version you install, such as `0.1.0`):
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm install -D --save-exact @sando-ui/cli@<version> @sando-ui/eslint-plugin@<version>
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Requires Node.js 22.13 or later and ESLint 9.24 or later. `sando check` runs these rules for you.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
// eslint.config.js
|
|
22
|
+
import { defineConfig } from 'eslint/config'
|
|
23
|
+
import sando from '@sando-ui/eslint-plugin'
|
|
24
|
+
|
|
25
|
+
export default defineConfig([{ extends: [sando.configs.recommended] }])
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The `recommended` config reads `design/product.json` from the directory ESLint runs in.
|
|
29
|
+
|
|
30
|
+
## Rules
|
|
31
|
+
|
|
32
|
+
Screens and units (TSX):
|
|
33
|
+
|
|
34
|
+
| Rule | What it enforces |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `sando/screen-imports` | A screen imports no other screen, only units `product.json` lists, and nothing `denyImports` names. |
|
|
37
|
+
| `sando/story-args-only` | A screen story is data: the meta holds `component`, `title` and `args`; each story holds `args` and `name`. |
|
|
38
|
+
| `sando/installed-units` | An import of `src/{components,blocks,layouts}/*` lands on a file `design/sando.lock.json` records as installed. |
|
|
39
|
+
| `sando/shared-class-only` | An `sd-*` class in `className` is one the system makes public. |
|
|
40
|
+
| `sando/policy-props` | A unit's prop takes only the values `policy.props` allows. |
|
|
41
|
+
| `sando/policy-units` | A screen places only the units its screen type allows. |
|
|
42
|
+
| `sando/no-inline-scale-literal` | No spacing, border width or color literal in an inline `style` object. |
|
|
43
|
+
|
|
44
|
+
Stylesheets (CSS):
|
|
45
|
+
|
|
46
|
+
| Rule | What it enforces |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `sando/token-tier` | A `var(--sd-*)` names an existing system token, never a component or reference token. |
|
|
49
|
+
| `sando/no-scale-literal` | No spacing, border width or color literal: a system token carries them. |
|
|
50
|
+
| `sando/own-prefix` | Every class and custom property you define starts with your product prefix. |
|
|
51
|
+
| `sando/no-reach` | No selector reaches into a Sando unit. |
|
|
52
|
+
| `sando/type-whole` | No per-property type declarations: use `Text` or one `.sd-type-*` class. |
|
|
53
|
+
| `sando/theme-in-token` | No hand-built theme cascade: themes come from tokens. |
|
|
54
|
+
|
|
55
|
+
The recommended config also enables ESLint core rules that keep a screen free of network, storage,
|
|
56
|
+
clock and effect calls. Every rule id is stable, so `eslint --suppress-all` works as usual.
|
|
57
|
+
|
|
58
|
+
## Documentation
|
|
59
|
+
|
|
60
|
+
The registry and its documentation are at <https://sando-ui.github.io>. The full guide is installed
|
|
61
|
+
into `docs/sando/` by `sando install` from `@sando-ui/cli`.
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
MIT. See [LICENSE](LICENSE).
|
package/lib/configs.mjs
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// The shared config a service extends. It reads design/product.json at config-evaluation time,
|
|
2
|
+
// because S7's core-rule options and U4/U5's policy are static options, not runtime lookups.
|
|
3
|
+
//
|
|
4
|
+
// // eslint.config.js
|
|
5
|
+
// import { defineConfig } from 'eslint/config'
|
|
6
|
+
// import sando from '@sando-ui/eslint-plugin'
|
|
7
|
+
// export default defineConfig([{ extends: [sando.configs.recommended] }])
|
|
8
|
+
//
|
|
9
|
+
// Every rule id is stable (`sando/<name>` or an ESLint core rule), so `eslint --suppress-all`
|
|
10
|
+
// bulk suppression works as for any rule.
|
|
11
|
+
|
|
12
|
+
import './typescript-api.mjs' // first: it must run before the parser loads
|
|
13
|
+
import { readFileSync } from 'node:fs'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import css from '@eslint/css'
|
|
16
|
+
import tsParser from '@typescript-eslint/parser'
|
|
17
|
+
import { DEFAULT_DENY_IMPORTS } from './rules/screens.mjs'
|
|
18
|
+
|
|
19
|
+
const INSTALLED = ['src/components/**', 'src/blocks/**', 'src/layouts/**', 'src/styles/**']
|
|
20
|
+
const SCREENS = ['src/screens/**/*.{ts,tsx,js,jsx}']
|
|
21
|
+
const STORIES = ['src/screens/**/*.stories.{ts,tsx,js,jsx}']
|
|
22
|
+
|
|
23
|
+
// ── S7 screen purity: what breaks rendering every state without a backend (plan § 1.3) ───────
|
|
24
|
+
const NETWORK = ['fetch', 'XMLHttpRequest', 'WebSocket', 'EventSource']
|
|
25
|
+
const GLOBALS = {
|
|
26
|
+
network: NETWORK,
|
|
27
|
+
storage: ['localStorage', 'sessionStorage', 'indexedDB', 'caches'],
|
|
28
|
+
url: ['location', 'history', 'open', 'parent', 'top', 'opener', 'postMessage'],
|
|
29
|
+
time: ['setTimeout', 'setInterval', 'requestAnimationFrame'],
|
|
30
|
+
dom: ['document', 'addEventListener', 'removeEventListener', 'scrollTo', 'matchMedia'],
|
|
31
|
+
}
|
|
32
|
+
const PROPERTIES = [
|
|
33
|
+
['navigator', 'sendBeacon'],
|
|
34
|
+
['Date', 'now'],
|
|
35
|
+
['performance', 'now'],
|
|
36
|
+
['Math', 'random'],
|
|
37
|
+
['crypto', 'randomUUID'],
|
|
38
|
+
['crypto', 'getRandomValues'],
|
|
39
|
+
['React', 'useEffect'],
|
|
40
|
+
['React', 'useLayoutEffect'],
|
|
41
|
+
['React', 'useInsertionEffect'],
|
|
42
|
+
['React', 'useSyncExternalStore'],
|
|
43
|
+
]
|
|
44
|
+
const GLOBAL_OBJECTS = ['window', 'globalThis', 'self']
|
|
45
|
+
const EFFECTS = ['useEffect', 'useLayoutEffect', 'useInsertionEffect', 'useSyncExternalStore']
|
|
46
|
+
// The one exception (plan § 1.3): a viewport hook in src/screens/lib reads matchMedia through
|
|
47
|
+
// useSyncExternalStore. It is presentation, not data.
|
|
48
|
+
const LIB_EXCEPTION = ['matchMedia', 'useSyncExternalStore', 'React.useSyncExternalStore']
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The S7 core-rule set, with `allow` names removed: a global (`setTimeout`), a property
|
|
52
|
+
* (`Date.now`, `window.scrollTo`) or a React effect (`useEffect`).
|
|
53
|
+
*/
|
|
54
|
+
export function purityRules(allow = []) {
|
|
55
|
+
const allowed = new Set(allow)
|
|
56
|
+
const why = 'a screen renders from props alone; move this to the route layer (Sando screen purity)'
|
|
57
|
+
const windowAllowed = [...allowed].filter((n) => /^(window|globalThis|self)\./.test(n)).map((n) => n.split('.')[1])
|
|
58
|
+
return {
|
|
59
|
+
'no-restricted-globals': [
|
|
60
|
+
'error',
|
|
61
|
+
...Object.values(GLOBALS).flat().filter((n) => !allowed.has(n)).map((name) => ({ name, message: why })),
|
|
62
|
+
],
|
|
63
|
+
'no-restricted-properties': [
|
|
64
|
+
'error',
|
|
65
|
+
...PROPERTIES.filter(([o, p]) => !allowed.has(`${o}.${p}`) && !allowed.has(p)).map(([object, property]) => ({ object, property, message: why })),
|
|
66
|
+
...GLOBAL_OBJECTS.map((object) => ({
|
|
67
|
+
object,
|
|
68
|
+
message: why,
|
|
69
|
+
...(windowAllowed.length ? { allowProperties: windowAllowed } : {}),
|
|
70
|
+
})),
|
|
71
|
+
],
|
|
72
|
+
'no-restricted-syntax': [
|
|
73
|
+
'error',
|
|
74
|
+
...(allowed.has('new Date()')
|
|
75
|
+
? []
|
|
76
|
+
: [
|
|
77
|
+
{ selector: "NewExpression[callee.name='Date'][arguments.length=0]", message: `\`new Date()\` reads the clock; take a \`now\` prop. ${why}` },
|
|
78
|
+
{ selector: "CallExpression[callee.name='Date']", message: `\`Date()\` reads the clock; take a \`now\` prop. ${why}` },
|
|
79
|
+
]),
|
|
80
|
+
{ selector: "MemberExpression[object.type='MetaProperty'][property.name='env']", message: `\`import.meta.env\` is build configuration; ${why}` },
|
|
81
|
+
// Effects by named import from `react`. A namespace import (`import * as React`) is fine;
|
|
82
|
+
// its `React.useEffect` is caught by no-restricted-properties above.
|
|
83
|
+
...EFFECTS.filter((e) => !allowed.has(e)).map((e) => ({
|
|
84
|
+
selector: `ImportDeclaration[source.value='react'] > ImportSpecifier[imported.name='${e}']`,
|
|
85
|
+
message: `\`${e}\` runs after render; ${why}`,
|
|
86
|
+
})),
|
|
87
|
+
],
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const readJson = (path) => {
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
94
|
+
} catch {
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build the flat config for one service root.
|
|
101
|
+
* @param {{ root?: string, product?: object, tokenNames?: string, lock?: string }} [options]
|
|
102
|
+
* `product` overrides reading `<root>/design/product.json`; `tokenNames` and `lock` override the
|
|
103
|
+
* installed paths (a test fixture feeds both).
|
|
104
|
+
*/
|
|
105
|
+
export function createConfig(plugin, options = {}) {
|
|
106
|
+
const root = options.root ?? process.cwd()
|
|
107
|
+
const product = options.product ?? readJson(join(root, 'design/product.json')) ?? {}
|
|
108
|
+
const policy = product.policy ?? {}
|
|
109
|
+
const tokenNames = options.tokenNames ?? join(root, 'src/styles/token-names.json')
|
|
110
|
+
const lock = options.lock ?? join(root, 'design/sando.lock.json')
|
|
111
|
+
const allow = (policy.screen?.allowGlobals ?? []).map((g) => g.name)
|
|
112
|
+
const denyImports = [...DEFAULT_DENY_IMPORTS, ...(policy.screen?.denyImports ?? [])]
|
|
113
|
+
const tsx = {
|
|
114
|
+
files: ['src/**/*.{ts,tsx,js,jsx}'],
|
|
115
|
+
languageOptions: { parser: tsParser, parserOptions: { ecmaFeatures: { jsx: true } }, sourceType: 'module' },
|
|
116
|
+
}
|
|
117
|
+
return [
|
|
118
|
+
// Installed Sando files are not the service's code and may not be edited (U6 holds them), so
|
|
119
|
+
// ESLint never lints them: a directive they carry for a rule this config does not register
|
|
120
|
+
// (`@typescript-eslint/no-explicit-any` in icons.tsx) would otherwise be an unknown-rule error.
|
|
121
|
+
{ name: 'sando/installed', ignores: INSTALLED },
|
|
122
|
+
// The parser still reaches every TSX file, for a service config that scopes the ignore away.
|
|
123
|
+
{ name: 'sando/tsx-parser', ...tsx },
|
|
124
|
+
{
|
|
125
|
+
name: 'sando/tsx',
|
|
126
|
+
files: tsx.files,
|
|
127
|
+
ignores: INSTALLED,
|
|
128
|
+
plugins: { sando: plugin },
|
|
129
|
+
rules: {
|
|
130
|
+
'sando/installed-units': ['error', { root, lock }],
|
|
131
|
+
'sando/shared-class-only': ['error', { tokenNames }],
|
|
132
|
+
'sando/policy-props': ['error', { root, props: policy.props ?? [] }],
|
|
133
|
+
'sando/no-inline-scale-literal': 'error',
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'sando/screens',
|
|
138
|
+
files: SCREENS,
|
|
139
|
+
ignores: STORIES,
|
|
140
|
+
rules: {
|
|
141
|
+
...purityRules(allow),
|
|
142
|
+
'sando/screen-imports': ['error', { root, units: product.units ?? null, denyImports }],
|
|
143
|
+
'sando/policy-units': ['error', { root, screens: policy.screens ?? {}, screenTypes: policy.screenTypes ?? {} }],
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
name: 'sando/screens-lib',
|
|
148
|
+
files: ['src/screens/lib/**/*.{ts,tsx,js,jsx}'],
|
|
149
|
+
ignores: STORIES,
|
|
150
|
+
rules: { ...purityRules([...allow, ...LIB_EXCEPTION]), 'sando/policy-units': 'off' },
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: 'sando/screen-stories',
|
|
154
|
+
files: STORIES,
|
|
155
|
+
rules: {
|
|
156
|
+
'sando/story-args-only': 'error',
|
|
157
|
+
'no-restricted-globals': ['error', ...NETWORK.map((name) => ({ name, message: 'a story is data; it never fetches' }))],
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: 'sando/css',
|
|
162
|
+
files: ['src/**/*.css'],
|
|
163
|
+
ignores: INSTALLED,
|
|
164
|
+
plugins: { css, sando: plugin },
|
|
165
|
+
language: 'css/css',
|
|
166
|
+
rules: {
|
|
167
|
+
'sando/token-tier': ['error', { tokenNames }],
|
|
168
|
+
'sando/no-scale-literal': 'error',
|
|
169
|
+
'sando/own-prefix': ['error', { prefix: product.prefix ?? null }],
|
|
170
|
+
'sando/no-reach': 'error',
|
|
171
|
+
'sando/type-whole': 'error',
|
|
172
|
+
'sando/theme-in-token': 'error',
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
]
|
|
176
|
+
}
|
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// @sando-ui/eslint-plugin: the TSX and CSS rules a service repository runs over its own code
|
|
2
|
+
// beside installed Sando units. Rule ids and option schemas are listed in surface.json.
|
|
3
|
+
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
import { createConfig } from './configs.mjs'
|
|
6
|
+
import { noReach, noScaleLiteral, ownPrefix, themeInToken, tokenTier, typeWhole } from './rules/css.mjs'
|
|
7
|
+
import { screenImports, storyArgsOnly } from './rules/screens.mjs'
|
|
8
|
+
import { installedUnits, noInlineScaleLiteral, policyProps, policyUnits, sharedClassOnly } from './rules/units.mjs'
|
|
9
|
+
|
|
10
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
11
|
+
|
|
12
|
+
const plugin = {
|
|
13
|
+
meta: { name: pkg.name, version: pkg.version },
|
|
14
|
+
rules: {
|
|
15
|
+
'screen-imports': screenImports,
|
|
16
|
+
'story-args-only': storyArgsOnly,
|
|
17
|
+
'installed-units': installedUnits,
|
|
18
|
+
'shared-class-only': sharedClassOnly,
|
|
19
|
+
'policy-props': policyProps,
|
|
20
|
+
'policy-units': policyUnits,
|
|
21
|
+
'no-inline-scale-literal': noInlineScaleLiteral,
|
|
22
|
+
'token-tier': tokenTier,
|
|
23
|
+
'no-scale-literal': noScaleLiteral,
|
|
24
|
+
'own-prefix': ownPrefix,
|
|
25
|
+
'no-reach': noReach,
|
|
26
|
+
'type-whole': typeWhole,
|
|
27
|
+
'theme-in-token': themeInToken,
|
|
28
|
+
},
|
|
29
|
+
configs: {},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let recommended
|
|
33
|
+
Object.defineProperty(plugin.configs, 'recommended', {
|
|
34
|
+
enumerable: true,
|
|
35
|
+
// Read product.json from the directory ESLint runs in, once, when a config first asks.
|
|
36
|
+
get: () => (recommended ??= createConfig(plugin)),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
/** The shared config for an explicit service root (the CLI and tests pass one). */
|
|
40
|
+
export const configure = (options) => createConfig(plugin, options)
|
|
41
|
+
export default plugin
|
package/lib/resolve.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Where an import LANDS, as a repository path, and which Sando unit that path is.
|
|
2
|
+
//
|
|
3
|
+
// Policy is written in repo paths (`src/app/api/*`) and registry item names (`plan-card`), while
|
|
4
|
+
// code is written in specifiers (`../app/api/client`, `./PlanCard`). Matching the specifier text
|
|
5
|
+
// would let `../layouts/../screens/Auth` wear a legal prefix, so every rule here resolves first and
|
|
6
|
+
// tests where the import actually lands (the same reasoning as Sando's own screen check).
|
|
7
|
+
|
|
8
|
+
import { posix, relative, resolve, dirname, sep } from 'node:path'
|
|
9
|
+
|
|
10
|
+
const toPosix = (p) => p.split(sep).join('/')
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The repo path an import specifier lands on, or `null` for a package import.
|
|
14
|
+
* Relative specifiers resolve against the importing file; `@/x` and `src/x` read as `src/x`.
|
|
15
|
+
* ponytail: only the `@/` alias is known; other tsconfig/vite aliases read as packages. Read the
|
|
16
|
+
* service's alias map when a consumer uses another one.
|
|
17
|
+
*/
|
|
18
|
+
export function repoPath(spec, filename, root) {
|
|
19
|
+
if (spec.startsWith('.')) {
|
|
20
|
+
return posix.normalize(toPosix(relative(root, resolve(dirname(filename), spec))))
|
|
21
|
+
}
|
|
22
|
+
if (spec.startsWith('@/')) return posix.normalize(`src/${spec.slice(2)}`)
|
|
23
|
+
if (spec.startsWith('src/')) return posix.normalize(spec)
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const kebab = (name) =>
|
|
28
|
+
name
|
|
29
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
30
|
+
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
|
|
33
|
+
const UNIT_PATH = /^src\/(components|blocks|layouts)\/([A-Z][A-Za-z0-9]*)(?:\.(?:tsx|ts|jsx|js))?$/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The installed Sando unit a repo path names, or `null`.
|
|
37
|
+
* A unit is its MODULE FILE: `src/components/Card.tsx` is the registry item `card`, and every
|
|
38
|
+
* component that file exports (`Card`, `CardHeader`) belongs to `card`.
|
|
39
|
+
*/
|
|
40
|
+
export function unitOf(path) {
|
|
41
|
+
const m = path && UNIT_PATH.exec(path)
|
|
42
|
+
return m ? { file: `src/${m[1]}/${m[2]}.tsx`, unit: kebab(m[2]) } : null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Map every imported local binding in the file to the unit its module is, for JSX lookups. */
|
|
46
|
+
export function unitBindings(program, filename, root) {
|
|
47
|
+
const out = new Map()
|
|
48
|
+
for (const node of program.body) {
|
|
49
|
+
if (node.type !== 'ImportDeclaration') continue
|
|
50
|
+
const unit = unitOf(repoPath(node.source.value, filename, root))
|
|
51
|
+
if (!unit) continue
|
|
52
|
+
for (const s of node.specifiers) out.set(s.local.name, unit.unit)
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The local binding a JSX element name starts with: `Card` for `<Card>` and `<Card.Header>`. */
|
|
58
|
+
export function jsxRootName(name) {
|
|
59
|
+
let n = name
|
|
60
|
+
while (n.type === 'JSXMemberExpression') n = n.object
|
|
61
|
+
return n.type === 'JSXIdentifier' ? n.name : null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A repo-path glob test (`*` within a segment, `**` across), via node's own matcher. */
|
|
65
|
+
export const matchesGlob = (path, glob) => posix.matchesGlob(path, glob)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// C1 to C6: the stylesheet a service writes beside installed Sando units, read with @eslint/css.
|
|
2
|
+
// Source of every rule: docs/guide/styling-contract.md (shipped to the service as
|
|
3
|
+
// docs/sando/styling-contract.md). Installed Sando files are out of scope: the shared config
|
|
4
|
+
// ignores src/{components,blocks,layouts,styles}.
|
|
5
|
+
//
|
|
6
|
+
// NOT seen here: CSS-in-JS and `<style>` blocks (only `*.css` files reach the CSS language), the
|
|
7
|
+
// TIER of a `var()` inside an inline `style={{}}` (C2's inline half reads literals only), and
|
|
8
|
+
// state styling through `[data-*]` on your own element (C7, deferred).
|
|
9
|
+
|
|
10
|
+
import { classify, loadTokenNames } from '../token-names.mjs'
|
|
11
|
+
|
|
12
|
+
const SOURCE = 'docs/sando/styling-contract.md'
|
|
13
|
+
const rule = (id, section, description, create, schema = []) => ({
|
|
14
|
+
meta: {
|
|
15
|
+
type: 'problem',
|
|
16
|
+
docs: { description, url: `${SOURCE}#${section}`, sandoId: id },
|
|
17
|
+
schema,
|
|
18
|
+
messages: { violation: '{{message}} ({{source}})' },
|
|
19
|
+
},
|
|
20
|
+
create(context) {
|
|
21
|
+
const report = (node, message) =>
|
|
22
|
+
context.report({ node, messageId: 'violation', data: { message, source: `${SOURCE} § ${section}` } })
|
|
23
|
+
return create(context, report)
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// ── shared property families (Sando's own css-literal check uses the same three) ─────────────
|
|
28
|
+
const SIDES = '(-(top|right|bottom|left|block|inline)(-(start|end))?)?'
|
|
29
|
+
export const isSpaceProp = (p) => new RegExp(`^(gap|row-gap|column-gap|padding${SIDES}|margin${SIDES})$`).test(p)
|
|
30
|
+
export const isWidthProp = (p) =>
|
|
31
|
+
new RegExp(`^(border${SIDES}(-width)?|outline(-width)?|column-rule(-width)?)$`).test(p)
|
|
32
|
+
export const isColorProp = (p) =>
|
|
33
|
+
new RegExp(
|
|
34
|
+
`^(color|background|background-color|border${SIDES}(-color)?|outline(-color)?|fill|stroke|caret-color|accent-color|text-decoration-color|column-rule(-color)?)$`,
|
|
35
|
+
).test(p)
|
|
36
|
+
export const TYPE_PROPS = new Set(['font', 'font-size', 'line-height', 'font-weight', 'letter-spacing'])
|
|
37
|
+
const THEME_ATTRS = new Set(['data-theme', 'data-brand', 'data-contrast', 'data-context', 'data-ground'])
|
|
38
|
+
const THEME_FEATURES = new Set(['prefers-color-scheme', 'prefers-contrast'])
|
|
39
|
+
const COLOR_FN = /^(rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)$/i
|
|
40
|
+
|
|
41
|
+
/** Literal findings in one value tree: `space` / `width` lengths and colour literals. */
|
|
42
|
+
function literalsIn(node, out = []) {
|
|
43
|
+
if (!node || typeof node !== 'object') return out
|
|
44
|
+
if (node.type === 'Function' && node.name === 'var') return out
|
|
45
|
+
if (node.type === 'Dimension' && Number(node.value) !== 0) out.push({ kind: 'length', text: `${node.value}${node.unit}` })
|
|
46
|
+
if (node.type === 'Hash') out.push({ kind: 'color', text: `#${node.value}` })
|
|
47
|
+
if (node.type === 'Function' && COLOR_FN.test(node.name)) out.push({ kind: 'color', text: `${node.name}()` })
|
|
48
|
+
for (const c of node.children ?? []) literalsIn(c, out)
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const tokenTier = rule(
|
|
53
|
+
'C1',
|
|
54
|
+
'read-sys-tokens-never-cmp-or-ref',
|
|
55
|
+
'A `var(--sd-*)` must name an existing Sys token: never Cmp, never Ref.',
|
|
56
|
+
(context, report) => {
|
|
57
|
+
const [{ tokenNames }] = context.options
|
|
58
|
+
const names = loadTokenNames(tokenNames)
|
|
59
|
+
let missingReported = false
|
|
60
|
+
return {
|
|
61
|
+
"Function[name='var']"(node) {
|
|
62
|
+
const name = node.children?.[0]?.name
|
|
63
|
+
if (!name?.startsWith('--sd-')) return
|
|
64
|
+
if (!names) {
|
|
65
|
+
if (!missingReported) report(node, `cannot read ${tokenNames}; reinstall the \`styles\` item`)
|
|
66
|
+
missingReported = true
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
const tier = classify(name, names)
|
|
70
|
+
if (tier === 'sys') return
|
|
71
|
+
report(
|
|
72
|
+
node,
|
|
73
|
+
tier === 'unknown'
|
|
74
|
+
? `\`${name}\` is not a Sando token; the declaration would silently drop`
|
|
75
|
+
: `\`${name}\` is a ${tier === 'cmp' ? 'Cmp' : 'Ref'} token; read the Sys role instead`,
|
|
76
|
+
)
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
[{ type: 'object', properties: { tokenNames: { type: 'string' } }, required: ['tokenNames'], additionalProperties: false }],
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
export const noScaleLiteral = rule(
|
|
84
|
+
'C2',
|
|
85
|
+
'never-write-a-literal-for-a-value-a-scale-carries',
|
|
86
|
+
'No non-zero length in gap/padding/margin or a border width, and no colour literal: a Sys family carries them.',
|
|
87
|
+
(context, report) => ({
|
|
88
|
+
Declaration(node) {
|
|
89
|
+
const p = node.property.toLowerCase()
|
|
90
|
+
if (p.startsWith('--')) return
|
|
91
|
+
const lengths = isSpaceProp(p) || isWidthProp(p)
|
|
92
|
+
for (const f of literalsIn(node.value)) {
|
|
93
|
+
if (f.kind === 'length' && lengths) report(node, `\`${p}: ${f.text}\`; use a \`--sd-${isSpaceProp(p) ? 'space' : 'border'}-*\` token`)
|
|
94
|
+
if (f.kind === 'color' && isColorProp(p)) report(node, `colour literal \`${f.text}\` in \`${p}\`; use a \`--sd-color-*\` role`)
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
export const ownPrefix = rule(
|
|
101
|
+
'C3',
|
|
102
|
+
'your-own-class-prefix-never-sd-',
|
|
103
|
+
'Every class and custom property you define starts with the product prefix (`product.json` `prefix`).',
|
|
104
|
+
(context, report) => {
|
|
105
|
+
const raw = context.options[0]?.prefix
|
|
106
|
+
const prefix = raw ? raw.replace(/-$/, '') + '-' : null
|
|
107
|
+
const check = (node, name, what) => {
|
|
108
|
+
if (name.startsWith('sd-')) return what === 'class' ? undefined : report(node, `\`--${name}\` defines a property in Sando's \`sd-\` namespace`)
|
|
109
|
+
if (!prefix) return report(node, `${what} \`${name}\`: declare \`prefix\` in design/product.json`)
|
|
110
|
+
if (!name.startsWith(prefix)) report(node, `${what} \`${what === 'class' ? '.' : '--'}${name}\` does not start with \`${prefix}\``)
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
ClassSelector(node) {
|
|
114
|
+
check(node, node.name, 'class')
|
|
115
|
+
},
|
|
116
|
+
Declaration(node) {
|
|
117
|
+
if (node.property.startsWith('--')) check(node, node.property.slice(2), 'custom property')
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
[{ type: 'object', properties: { prefix: { type: ['string', 'null'] } }, additionalProperties: false }],
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
export const noReach = rule(
|
|
125
|
+
'C4',
|
|
126
|
+
'one-class-per-element-no-reaching-into-a-unit',
|
|
127
|
+
'No selector into a Sando unit: no `.sd-*` class and no `[class*="sd-"]` attribute match.',
|
|
128
|
+
(context, report) => ({
|
|
129
|
+
ClassSelector(node) {
|
|
130
|
+
if (node.name.startsWith('sd-')) report(node, `\`.${node.name}\` targets Sando's implementation; use the unit's props`)
|
|
131
|
+
},
|
|
132
|
+
AttributeSelector(node) {
|
|
133
|
+
const value = node.value?.value ?? node.value?.name ?? ''
|
|
134
|
+
if (node.name.name === 'class' && value.includes('sd-')) report(node, '`[class…sd-]` targets Sando classes; use the unit\'s props')
|
|
135
|
+
},
|
|
136
|
+
}),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
export const typeWhole = rule(
|
|
140
|
+
'C5',
|
|
141
|
+
'type-comes-whole-from-a-role',
|
|
142
|
+
'No per-property type (`font`, `font-size`, `line-height`, `font-weight`, `letter-spacing`): use `Text` or one `.sd-type-*` class.',
|
|
143
|
+
(context, report) => ({
|
|
144
|
+
Declaration(node) {
|
|
145
|
+
const p = node.property.toLowerCase()
|
|
146
|
+
if (TYPE_PROPS.has(p)) report(node, `\`${p}\` set by property; a type role sets all four together`)
|
|
147
|
+
},
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
export const themeInToken = rule(
|
|
152
|
+
'C6',
|
|
153
|
+
'the-theme-is-already-in-the-token',
|
|
154
|
+
'No hand-built theme cascade: no `prefers-color-scheme`/`prefers-contrast` query and no theme attribute selector.',
|
|
155
|
+
(context, report) => {
|
|
156
|
+
const feature = (node) => {
|
|
157
|
+
if (THEME_FEATURES.has(node.name)) report(node, `\`${node.name}\` query; a Sys role already follows it`)
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
Feature: feature,
|
|
161
|
+
MediaFeature: feature,
|
|
162
|
+
AttributeSelector(node) {
|
|
163
|
+
if (THEME_ATTRS.has(node.name.name)) report(node, `\`[${node.name.name}]\` selector; a Sys role already follows it`)
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// S7's import half and the screen-story rule.
|
|
2
|
+
//
|
|
3
|
+
// screen-imports resolves every relative or `@/` specifier to the repo path it LANDS on before
|
|
4
|
+
// judging it, because `no-restricted-imports` matches specifier text: `src/app/api/*` would never
|
|
5
|
+
// match `../app/api/client`, and `../layouts/../screens/Auth` would pass a prefix test.
|
|
6
|
+
//
|
|
7
|
+
// * a screen (or src/screens/lib) importing another screen, `src/screens/<Name>`
|
|
8
|
+
// * an installed unit (`src/{components,blocks,layouts}/<Name>`) whose item is not in
|
|
9
|
+
// product.json `units` (when `units` is given); U1 separately asks whether it is installed
|
|
10
|
+
// * any `denyImports` glob. A glob is matched against the landed REPO path for a relative or `@/`
|
|
11
|
+
// import and against the specifier itself for a package import. Defaults: `react-dom/client`
|
|
12
|
+
// and `src/styles/*.css` (global sheets), plus product.json `policy.screen.denyImports`.
|
|
13
|
+
//
|
|
14
|
+
// Everything else is allowed: the list is a denylist, so a service's i18n, context, form and
|
|
15
|
+
// logic modules pass without being named.
|
|
16
|
+
// NOT seen: dynamic `import(variable)`, `require()`, and aliases other than `@/` (resolve.mjs).
|
|
17
|
+
|
|
18
|
+
import { matchesGlob, repoPath, unitOf } from '../resolve.mjs'
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_DENY_IMPORTS = ['react-dom/client', 'src/styles/*.css']
|
|
21
|
+
const SCREEN_FILE = /^src\/screens\/(?!lib\/)[^/]+$/
|
|
22
|
+
const CODE_EXT = /\.(tsx?|jsx?)$/
|
|
23
|
+
|
|
24
|
+
const meta = (id, description, source, schema) => ({
|
|
25
|
+
type: 'problem',
|
|
26
|
+
docs: { description, url: source, sandoId: id },
|
|
27
|
+
schema,
|
|
28
|
+
messages: { violation: '{{message}} ({{source}})' },
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
export const screenImports = {
|
|
32
|
+
meta: meta(
|
|
33
|
+
'S7',
|
|
34
|
+
'A screen imports no other screen, only units listed in product.json `units`, and nothing `denyImports` names (matched on the resolved repo path).',
|
|
35
|
+
'docs/sando/design-language.md § Building a screen',
|
|
36
|
+
[
|
|
37
|
+
{
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
root: { type: 'string' },
|
|
41
|
+
units: { type: ['array', 'null'], items: { type: 'string' } },
|
|
42
|
+
denyImports: { type: 'array', items: { type: 'string' } },
|
|
43
|
+
},
|
|
44
|
+
additionalProperties: false,
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
),
|
|
48
|
+
create(context) {
|
|
49
|
+
const { root = context.cwd, units = null, denyImports = DEFAULT_DENY_IMPORTS } = context.options[0] ?? {}
|
|
50
|
+
const source = meta('S7', '', 'docs/sando/design-language.md § Building a screen').docs.url
|
|
51
|
+
const report = (node, message) => context.report({ node, messageId: 'violation', data: { message, source } })
|
|
52
|
+
const judge = (node) => {
|
|
53
|
+
if (node?.type !== 'Literal' || typeof node.value !== 'string') return
|
|
54
|
+
const spec = node.value
|
|
55
|
+
const landed = repoPath(spec, context.filename, root)
|
|
56
|
+
const subject = landed ?? spec
|
|
57
|
+
if (landed && SCREEN_FILE.test(landed) && (!/\.[a-z]+$/.test(landed) || CODE_EXT.test(landed))) {
|
|
58
|
+
return report(node, `imports the screen \`${landed}\`; a screen renders alone, so pass what it needs as props (or a \`background\` slot)`)
|
|
59
|
+
}
|
|
60
|
+
const unit = landed && unitOf(landed)
|
|
61
|
+
if (unit && units && !units.includes(unit.unit)) {
|
|
62
|
+
return report(node, `\`${unit.unit}\` is not in product.json \`units\`; add it there and run \`sando install\``)
|
|
63
|
+
}
|
|
64
|
+
const hit = denyImports.find((g) => matchesGlob(subject, g))
|
|
65
|
+
if (hit) report(node, `\`${subject}\` matches denyImports \`${hit}\`; a screen gets this through props`)
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
ImportDeclaration: (n) => judge(n.source),
|
|
69
|
+
ExportNamedDeclaration: (n) => judge(n.source),
|
|
70
|
+
ExportAllDeclaration: (n) => judge(n.source),
|
|
71
|
+
ImportExpression: (n) => judge(n.source),
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── screen stories: CSF3 data, nothing that runs ────────────────────────────────────────────
|
|
77
|
+
// A screen story is the state's props and nothing else, so the harness can render every state
|
|
78
|
+
// without Storybook: the meta holds `component`, `title`, `args`; each story holds `args`, `name`.
|
|
79
|
+
// `render`, `decorators`, `loaders`, `beforeEach`, `play` and `parameters` are reported.
|
|
80
|
+
const META_KEYS = new Set(['component', 'title', 'args'])
|
|
81
|
+
const STORY_KEYS = new Set(['args', 'name'])
|
|
82
|
+
const STORY_SOURCE = 'CSF3 screen stories: `component` and `args` only'
|
|
83
|
+
|
|
84
|
+
const unwrap = (n) => {
|
|
85
|
+
while (n && ['TSAsExpression', 'TSSatisfiesExpression', 'TSTypeAssertion', 'TSNonNullExpression'].includes(n.type)) n = n.expression
|
|
86
|
+
return n
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const storyArgsOnly = {
|
|
90
|
+
meta: meta('S3', 'A screen story is data: the meta holds `component`/`title`/`args`, each story `args`/`name`.', STORY_SOURCE, []),
|
|
91
|
+
create(context) {
|
|
92
|
+
const report = (node, message) => context.report({ node, messageId: 'violation', data: { message, source: STORY_SOURCE } })
|
|
93
|
+
const consts = new Map()
|
|
94
|
+
const objectOf = (n) => {
|
|
95
|
+
n = unwrap(n)
|
|
96
|
+
if (n?.type === 'Identifier') n = unwrap(consts.get(n.name))
|
|
97
|
+
return n
|
|
98
|
+
}
|
|
99
|
+
const keys = (obj, allowed, what) => {
|
|
100
|
+
if (obj?.type !== 'ObjectExpression') return report(obj ?? context.sourceCode.ast, `${what} is not an object literal`)
|
|
101
|
+
for (const p of obj.properties) {
|
|
102
|
+
const k = p.type === 'Property' && !p.computed ? (p.key.name ?? p.key.value) : null
|
|
103
|
+
if (!k || !allowed.has(k)) report(p, `${what} holds \`${k ?? '…'}\`; a screen story carries only ${[...allowed].map((a) => `\`${a}\``).join(', ')}`)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
Program(program) {
|
|
108
|
+
for (const s of program.body) {
|
|
109
|
+
const d = s.type === 'ExportNamedDeclaration' ? s.declaration : s
|
|
110
|
+
if (d?.type === 'VariableDeclaration') for (const v of d.declarations) if (v.id.type === 'Identifier') consts.set(v.id.name, v.init)
|
|
111
|
+
}
|
|
112
|
+
for (const s of program.body) {
|
|
113
|
+
if (s.type === 'ExportDefaultDeclaration') keys(objectOf(s.declaration), META_KEYS, 'the story meta')
|
|
114
|
+
if (s.type === 'ExportNamedDeclaration' && s.declaration?.type === 'VariableDeclaration') {
|
|
115
|
+
for (const v of s.declaration.declarations) keys(objectOf(v.init), STORY_KEYS, `story \`${v.id.name}\``)
|
|
116
|
+
}
|
|
117
|
+
if (s.type === 'ExportNamedDeclaration' && s.declaration?.type === 'FunctionDeclaration') report(s, `story \`${s.declaration.id.name}\` is a function; write \`{ args }\``)
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// U1, U3, U4, U5 and C2's inline half: how service TSX uses the installed Sando units.
|
|
2
|
+
//
|
|
3
|
+
// `policy` semantics (product.json, read by U4 and U5), stated once here and in surface.json:
|
|
4
|
+
// * A UNIT is a registry item name (`plan-card`). A JSX element belongs to unit X when its import
|
|
5
|
+
// lands on X's module file `src/{components,blocks,layouts}/<Pascal>.tsx`; every component that
|
|
6
|
+
// file exports counts as X (`Card` and `CardHeader` are both `card`).
|
|
7
|
+
// * `policy` absent, or a key of it absent: that rule has nothing to enforce and reports nothing.
|
|
8
|
+
// * `policy.screens` is OPTIONAL and partial. A screen with no entry has no type, so U5 sets no
|
|
9
|
+
// limit on it; a listed screen whose type has no `screenTypes` entry is reported.
|
|
10
|
+
// * `screenTypes.<t>.allowUnits`: absent, or containing "*", means every installed unit.
|
|
11
|
+
// Otherwise only the listed units. `denyUnits` always wins: a unit both allowed and denied is denied.
|
|
12
|
+
// * U5 also reads every src/screens/lib module a screen imports (and the lib modules those import):
|
|
13
|
+
// a unit such a module places in JSX counts as placed by the screen, reported on the import.
|
|
14
|
+
// * `props[].inside`: the element has a JSX ANCESTOR in the same file that belongs to that unit.
|
|
15
|
+
// An ancestor reached through another component file is not seen.
|
|
16
|
+
// * `props[].allow`: compared with a LITERAL attribute value (string, number, boolean; a bare
|
|
17
|
+
// attribute is `true`). An expression (`size={size}`) or an omitted prop is not judged.
|
|
18
|
+
//
|
|
19
|
+
// NOT seen: unit use through re-export barrels or aliases other than `@/` (resolve.mjs), and
|
|
20
|
+
// className values built at runtime (only string literals and template-literal text are read).
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
23
|
+
import { basename, join } from 'node:path'
|
|
24
|
+
import { loadTokenNames } from '../token-names.mjs'
|
|
25
|
+
import { jsxRootName, repoPath, unitBindings, unitOf } from '../resolve.mjs'
|
|
26
|
+
import { isColorProp, isSpaceProp, isWidthProp } from './css.mjs'
|
|
27
|
+
|
|
28
|
+
const rule = (id, source, description, schema, create) => ({
|
|
29
|
+
meta: {
|
|
30
|
+
type: 'problem',
|
|
31
|
+
docs: { description, url: source, sandoId: id },
|
|
32
|
+
schema,
|
|
33
|
+
messages: { violation: '{{message}} ({{source}})' },
|
|
34
|
+
},
|
|
35
|
+
create(context) {
|
|
36
|
+
const report = (node, message) => context.report({ node, messageId: 'violation', data: { message, source } })
|
|
37
|
+
return create(context, report, context.options[0] ?? {})
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const rootOf = (context, options) => options.root ?? context.cwd
|
|
42
|
+
|
|
43
|
+
const lockCache = new Map()
|
|
44
|
+
function lockFiles(path) {
|
|
45
|
+
if (!lockCache.has(path)) {
|
|
46
|
+
let files = null
|
|
47
|
+
try {
|
|
48
|
+
files = new Set(Object.keys(JSON.parse(readFileSync(path, 'utf8')).files ?? {}))
|
|
49
|
+
} catch {
|
|
50
|
+
files = null
|
|
51
|
+
}
|
|
52
|
+
lockCache.set(path, files)
|
|
53
|
+
}
|
|
54
|
+
return lockCache.get(path)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const ROOT_OPT = { root: { type: 'string' } }
|
|
58
|
+
|
|
59
|
+
export const installedUnits = rule(
|
|
60
|
+
'U1',
|
|
61
|
+
'docs/sando/design-language.md § Building a screen',
|
|
62
|
+
'An import of `src/{components,blocks,layouts}/*` must land on a file `design/sando.lock.json` records as installed.',
|
|
63
|
+
[{ type: 'object', properties: { ...ROOT_OPT, lock: { type: 'string' } }, required: ['lock'], additionalProperties: false }],
|
|
64
|
+
(context, report, options) => {
|
|
65
|
+
const files = lockFiles(options.lock)
|
|
66
|
+
const root = rootOf(context, options)
|
|
67
|
+
return {
|
|
68
|
+
ImportDeclaration(node) {
|
|
69
|
+
const unit = unitOf(repoPath(node.source.value, context.filename, root))
|
|
70
|
+
if (!unit) return
|
|
71
|
+
if (!files) return report(node.source, `cannot read ${options.lock}; run \`sando install\``)
|
|
72
|
+
if (!files.has(unit.file)) report(node.source, `\`${unit.file}\` is not an installed Sando file; add \`${unit.unit}\` to \`units\` and run \`sando install\`, or build it in src/local/`)
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const classTexts = (value) => {
|
|
79
|
+
if (!value) return []
|
|
80
|
+
if (value.type === 'Literal' && typeof value.value === 'string') return [[value, value.value]]
|
|
81
|
+
if (value.type === 'TemplateLiteral') return value.quasis.map((q) => [q, q.value.cooked ?? ''])
|
|
82
|
+
if (value.type === 'JSXExpressionContainer') return collectStrings(value.expression)
|
|
83
|
+
return []
|
|
84
|
+
}
|
|
85
|
+
// Every string literal inside a className expression: `cx('a', on && 'sd-x')`.
|
|
86
|
+
function collectStrings(node, out = []) {
|
|
87
|
+
if (!node || typeof node !== 'object') return out
|
|
88
|
+
if (node.type === 'Literal' && typeof node.value === 'string') out.push([node, node.value])
|
|
89
|
+
else if (node.type === 'TemplateLiteral') for (const q of node.quasis) out.push([q, q.value.cooked ?? ''])
|
|
90
|
+
else for (const [k, v] of Object.entries(node)) if (k !== 'parent' && v && typeof v === 'object') (Array.isArray(v) ? v : [v]).forEach((c) => collectStrings(c, out))
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const sharedClassOnly = rule(
|
|
95
|
+
'U3',
|
|
96
|
+
'docs/sando/shared-classes.md',
|
|
97
|
+
'A `sd-*` class in `className` must be one `shared-classes.md` makes public (read from token-names.json `classes`).',
|
|
98
|
+
[{ type: 'object', properties: { tokenNames: { type: 'string' } }, required: ['tokenNames'], additionalProperties: false }],
|
|
99
|
+
(context, report, options) => {
|
|
100
|
+
const classes = loadTokenNames(options.tokenNames)?.classes ?? null
|
|
101
|
+
return {
|
|
102
|
+
JSXAttribute(node) {
|
|
103
|
+
if (node.name.name !== 'className') return
|
|
104
|
+
for (const [at, text] of classTexts(node.value)) {
|
|
105
|
+
for (const cls of text.match(/(?<![\w-])sd-[a-z0-9_-]+/g) ?? []) {
|
|
106
|
+
if (!classes) report(at, `cannot read ${options.tokenNames}; reinstall the \`styles\` item`)
|
|
107
|
+
else if (!classes.has(cls)) report(at, `\`${cls}\` is not a public Sando class; style the unit through its props`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const literalValue = (attr) => {
|
|
116
|
+
if (attr.value === null) return { ok: true, value: true }
|
|
117
|
+
if (attr.value.type === 'Literal') return { ok: true, value: attr.value.value }
|
|
118
|
+
const e = attr.value.type === 'JSXExpressionContainer' ? attr.value.expression : null
|
|
119
|
+
if (e?.type === 'Literal') return { ok: true, value: e.value }
|
|
120
|
+
return { ok: false }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const policyProps = rule(
|
|
124
|
+
'U4',
|
|
125
|
+
'design/product.json § policy.props',
|
|
126
|
+
'A unit\'s prop takes only the values `policy.props` allows, optionally only inside another unit.',
|
|
127
|
+
[{ type: 'object', properties: { ...ROOT_OPT, props: { type: 'array' } }, additionalProperties: false }],
|
|
128
|
+
(context, report, options) => {
|
|
129
|
+
const entries = options.props ?? []
|
|
130
|
+
let units = new Map()
|
|
131
|
+
const unitOfElement = (el) => units.get(jsxRootName(el.openingElement.name))
|
|
132
|
+
return {
|
|
133
|
+
Program(node) {
|
|
134
|
+
units = unitBindings(node, context.filename, rootOf(context, options))
|
|
135
|
+
},
|
|
136
|
+
JSXOpeningElement(node) {
|
|
137
|
+
const unit = units.get(jsxRootName(node.name))
|
|
138
|
+
if (!unit) return
|
|
139
|
+
for (const entry of entries) {
|
|
140
|
+
if (entry.unit !== unit) continue
|
|
141
|
+
if (entry.inside) {
|
|
142
|
+
let inside = false
|
|
143
|
+
for (let p = node.parent.parent; p; p = p.parent) if (p.type === 'JSXElement' && unitOfElement(p) === entry.inside) inside = true
|
|
144
|
+
if (!inside) continue
|
|
145
|
+
}
|
|
146
|
+
const attr = node.attributes.find((a) => a.type === 'JSXAttribute' && a.name.name === entry.prop)
|
|
147
|
+
if (!attr) continue
|
|
148
|
+
const v = literalValue(attr)
|
|
149
|
+
if (v.ok && !entry.allow.includes(v.value)) {
|
|
150
|
+
report(attr, `\`${unit}\` ${entry.inside ? `inside \`${entry.inside}\` ` : ''}takes \`${entry.prop}\` ${entry.allow.map((a) => JSON.stringify(a)).join(' | ')}, not ${JSON.stringify(v.value)}${entry.because ? `: ${entry.because}` : ''}`)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
// The units a src/screens/lib module places in JSX, and those of the lib modules it imports, read
|
|
159
|
+
// with the parser this file is linted with. A unit the module places only on some props counts
|
|
160
|
+
// (static reading): a frame that differs by screen type is split per type, or passed in as props.
|
|
161
|
+
const LIB = /^src\/screens\/lib\//
|
|
162
|
+
const EXTS = ['', '.tsx', '.ts', '.jsx', '.js', '/index.tsx', '/index.ts', '/index.jsx', '/index.js']
|
|
163
|
+
const libCache = new Map()
|
|
164
|
+
function libUnits(path, root, parser, parserOptions, seen = new Set()) {
|
|
165
|
+
const file = EXTS.map((e) => join(root, path + e)).find((f) => existsSync(f) && statSync(f).isFile())
|
|
166
|
+
if (!file || seen.has(file)) return new Set()
|
|
167
|
+
seen.add(file)
|
|
168
|
+
const stamp = `${file}\u0000${statSync(file).mtimeMs}`
|
|
169
|
+
if (libCache.has(stamp)) return libCache.get(stamp)
|
|
170
|
+
const out = new Set()
|
|
171
|
+
try {
|
|
172
|
+
const code = readFileSync(file, 'utf8')
|
|
173
|
+
const options = { ...parserOptions, ecmaVersion: 'latest', sourceType: 'module', filePath: file, range: true, loc: true }
|
|
174
|
+
const program = parser.parseForESLint ? parser.parseForESLint(code, options).ast : parser.parse(code, options)
|
|
175
|
+
const units = unitBindings(program, file, root)
|
|
176
|
+
const walk = (n) => {
|
|
177
|
+
if (!n || typeof n !== 'object') return
|
|
178
|
+
if (n.type === 'JSXOpeningElement' && units.has(jsxRootName(n.name))) out.add(units.get(jsxRootName(n.name)))
|
|
179
|
+
for (const [k, v] of Object.entries(n)) if (k !== 'parent' && v && typeof v === 'object') (Array.isArray(v) ? v : [v]).forEach(walk)
|
|
180
|
+
}
|
|
181
|
+
walk(program)
|
|
182
|
+
for (const node of program.body) {
|
|
183
|
+
const to = node.type === 'ImportDeclaration' ? repoPath(node.source.value, file, root) : null
|
|
184
|
+
if (to && LIB.test(to)) for (const u of libUnits(to, root, parser, parserOptions, seen)) out.add(u)
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
// An unreadable or unparsable lib module is linted as its own file; nothing is inferred here.
|
|
188
|
+
}
|
|
189
|
+
libCache.set(stamp, out)
|
|
190
|
+
return out
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export const policyUnits = rule(
|
|
194
|
+
'U5',
|
|
195
|
+
'design/product.json § policy.screens, policy.screenTypes',
|
|
196
|
+
'A screen places only the units its screen type allows (`denyUnits` wins over `allowUnits`), in its own JSX or through a src/screens/lib module it imports.',
|
|
197
|
+
[{ type: 'object', properties: { ...ROOT_OPT, screens: { type: 'object' }, screenTypes: { type: 'object' } }, additionalProperties: false }],
|
|
198
|
+
(context, report, options) => {
|
|
199
|
+
const name = basename(context.filename).replace(/\.[tj]sx?$/, '')
|
|
200
|
+
const type = options.screens?.[name]?.type
|
|
201
|
+
if (!type) return {}
|
|
202
|
+
const limits = options.screenTypes?.[type]
|
|
203
|
+
const root = rootOf(context, options)
|
|
204
|
+
let units = new Map()
|
|
205
|
+
const judge = (node, unit, via = '') => {
|
|
206
|
+
const allow = limits.allowUnits
|
|
207
|
+
if (limits.denyUnits?.includes(unit)) report(node, `a \`${type}\` screen does not place \`${unit}\`${via} (denyUnits)`)
|
|
208
|
+
else if (allow && !allow.includes('*') && !allow.includes(unit)) report(node, `a \`${type}\` screen places only ${allow.map((u) => `\`${u}\``).join(', ')}; not \`${unit}\`${via}`)
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
Program(node) {
|
|
212
|
+
units = unitBindings(node, context.filename, root)
|
|
213
|
+
if (!limits) report(node, `screen \`${name}\` has type \`${type}\`, which \`policy.screenTypes\` does not define`)
|
|
214
|
+
},
|
|
215
|
+
ImportDeclaration(node) {
|
|
216
|
+
const to = limits && repoPath(node.source.value, context.filename, root)
|
|
217
|
+
if (!to || !LIB.test(to)) return
|
|
218
|
+
const { parser, parserOptions } = context.languageOptions
|
|
219
|
+
for (const unit of libUnits(to, root, parser, parserOptions)) judge(node, unit, ` through ${node.source.value}`)
|
|
220
|
+
},
|
|
221
|
+
JSXOpeningElement(node) {
|
|
222
|
+
const unit = units.get(jsxRootName(node.name))
|
|
223
|
+
if (unit && limits) judge(node, unit)
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
const REACT_UNITLESS = new Set(['flex', 'flexGrow', 'flexShrink', 'fontWeight', 'lineHeight', 'opacity', 'order', 'zIndex', 'zoom', 'scale'])
|
|
230
|
+
const LENGTH = /(?<![\w.#-])-?(\d*\.?\d+)(px|rem|em)\b/
|
|
231
|
+
const COLOR = /#[0-9a-fA-F]{3,8}\b|\b(rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/
|
|
232
|
+
const toKebab = (k) => k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
|
|
233
|
+
|
|
234
|
+
export const noInlineScaleLiteral = rule(
|
|
235
|
+
'C2',
|
|
236
|
+
'docs/sando/styling-contract.md § Never write a literal for a value a scale carries',
|
|
237
|
+
'C2 for an inline `style={{…}}` object literal: no non-zero length in gap/padding/margin or a border width, no colour literal.',
|
|
238
|
+
[],
|
|
239
|
+
(context, report) => ({
|
|
240
|
+
"JSXAttribute[name.name='style'] > JSXExpressionContainer > ObjectExpression > Property"(node) {
|
|
241
|
+
const key = node.key.type === 'Identifier' ? node.key.name : node.key.value
|
|
242
|
+
if (typeof key !== 'string' || key.startsWith('--')) return
|
|
243
|
+
const p = toKebab(key)
|
|
244
|
+
const v = node.value
|
|
245
|
+
let text = null
|
|
246
|
+
if (v.type === 'Literal' && typeof v.value === 'string') text = v.value.replace(/var\([^)]*\)/g, ' ')
|
|
247
|
+
else if (v.type === 'Literal' && typeof v.value === 'number') text = v.value === 0 || REACT_UNITLESS.has(key) ? null : `${v.value}px`
|
|
248
|
+
if (!text) return
|
|
249
|
+
const m = text.match(LENGTH)
|
|
250
|
+
if (m && Number(m[1]) !== 0 && (isSpaceProp(p) || isWidthProp(p))) report(node, `\`${p}: ${m[0]}\`; use a Sys token`)
|
|
251
|
+
if (COLOR.test(text) && isColorProp(p)) report(node, `colour literal in \`${p}\`; use a \`--sd-color-*\` role`)
|
|
252
|
+
},
|
|
253
|
+
}),
|
|
254
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The token-classification core: which tier a `--sd-*` name is, decided by WHERE it is declared,
|
|
2
|
+
// never by how it reads. `--sd-color-swatch-size-small` reads like a Sys colour and is a Cmp token
|
|
3
|
+
// (docs/guide/styling-contract.md § Read Sys tokens, never Cmp or Ref).
|
|
4
|
+
//
|
|
5
|
+
// Sys = declared in a brand sheet (src/styles/themes/<brand>.css) and not `--sd-ref-*`
|
|
6
|
+
// Ref = `--sd-ref-*`; its names differ per brand, so the prefix alone decides it
|
|
7
|
+
// Cmp = declared in src/styles/tokens.css
|
|
8
|
+
//
|
|
9
|
+
// Only DECLARATIONS count (`--sd-x:`). tokens.css also READS Sys names inside Cmp values
|
|
10
|
+
// (`var(--sd-duration-fast)`), so "appears in tokens.css" would misfile Sys as Cmp.
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from 'node:fs'
|
|
13
|
+
|
|
14
|
+
const DECL = /(--sd-[a-z0-9-]+)\s*:/g
|
|
15
|
+
const CLASS = /\.(sd-[a-z0-9_-]+)/g
|
|
16
|
+
const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
17
|
+
|
|
18
|
+
export const declaredNames = (css) => new Set([...stripComments(css).matchAll(DECL)].map((m) => m[1]))
|
|
19
|
+
export const classNames = (css) => new Set([...stripComments(css).matchAll(CLASS)].map((m) => m[1]))
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build the token-names document from the stylesheets `styles` and the brand items install.
|
|
23
|
+
* @param {{ tokensCss: string, themeCss: string[], resetCss?: string }} input
|
|
24
|
+
* @returns {{ sys: string[], cmp: string[], classes: string[] }}
|
|
25
|
+
*/
|
|
26
|
+
export function buildTokenNames({ tokensCss, themeCss, resetCss = '' }) {
|
|
27
|
+
const sys = new Set(themeCss.flatMap((css) => [...declaredNames(css)]).filter((n) => !n.startsWith('--sd-ref-')))
|
|
28
|
+
const cmp = declaredNames(tokensCss)
|
|
29
|
+
const both = [...cmp].filter((n) => sys.has(n))
|
|
30
|
+
if (both.length) throw new Error(`declared as both Sys and Cmp: ${both.join(', ')}`)
|
|
31
|
+
const classes = new Set([...classNames(tokensCss), ...classNames(resetCss)])
|
|
32
|
+
const sorted = (s) => [...s].sort()
|
|
33
|
+
return { sys: sorted(sys), cmp: sorted(cmp), classes: sorted(classes) }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @returns {'ref' | 'sys' | 'cmp' | 'unknown'} */
|
|
37
|
+
export function classify(name, names) {
|
|
38
|
+
if (name.startsWith('--sd-ref-')) return 'ref'
|
|
39
|
+
if (names.sys.has(name)) return 'sys'
|
|
40
|
+
if (names.cmp.has(name)) return 'cmp'
|
|
41
|
+
return 'unknown'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const cache = new Map()
|
|
45
|
+
/** Load a token-names.json as Sets, or `null` when it cannot be read. Cached per path. */
|
|
46
|
+
export function loadTokenNames(path) {
|
|
47
|
+
if (!cache.has(path)) {
|
|
48
|
+
let value = null
|
|
49
|
+
try {
|
|
50
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'))
|
|
51
|
+
value = { sys: new Set(raw.sys), cmp: new Set(raw.cmp), classes: new Set(raw.classes) }
|
|
52
|
+
} catch {
|
|
53
|
+
value = null
|
|
54
|
+
}
|
|
55
|
+
cache.set(path, value)
|
|
56
|
+
}
|
|
57
|
+
return cache.get(path)
|
|
58
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Keep the TSX parser on a TypeScript that has a JS API, whatever TypeScript the service runs.
|
|
2
|
+
//
|
|
3
|
+
// TypeScript 7 ships no JS API (`require('typescript')` is `{ version, versionMajorMinor }`).
|
|
4
|
+
// `@typescript-eslint/parser` accepts only TypeScript < 6.1, so npm nests a TypeScript 6 for it,
|
|
5
|
+
// but `ts-api-utils` (which typescript-estree loads) accepts any TypeScript >= 4.8.4, so npm hoists
|
|
6
|
+
// it to the service root, where `typescript` is the service's 7, and loading the parser throws
|
|
7
|
+
// (`Cannot read properties of undefined (reading 'Intrinsic')`). No dependency range in this
|
|
8
|
+
// package can stop that hoist (measured: a direct `ts-api-utils` dependency still lands at the
|
|
9
|
+
// root, and npm drops `bundleDependencies` when it packs a workspace).
|
|
10
|
+
//
|
|
11
|
+
// So before the parser loads, when the TypeScript ts-api-utils would load has no JS API, its
|
|
12
|
+
// module cache entry is pointed at the TypeScript typescript-estree itself resolves. This module
|
|
13
|
+
// is imported first in configs.mjs; static imports evaluate in order.
|
|
14
|
+
// ponytail: aliases a CJS require.cache entry for this process; swap for the parser's own fix
|
|
15
|
+
// once typescript-eslint and ts-api-utils resolve TypeScript 7 themselves.
|
|
16
|
+
|
|
17
|
+
import { createRequire } from 'node:module'
|
|
18
|
+
|
|
19
|
+
const from = (path) => createRequire(path)
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const estree = from(from(import.meta.url).resolve('@typescript-eslint/parser')).resolve('@typescript-eslint/typescript-estree')
|
|
23
|
+
const own = from(estree).resolve('typescript')
|
|
24
|
+
const seen = from(from(estree).resolve('ts-api-utils')).resolve('typescript')
|
|
25
|
+
const require = from(estree)
|
|
26
|
+
if (seen !== own && typeof require(seen).createSourceFile !== 'function') {
|
|
27
|
+
require(own)
|
|
28
|
+
require.cache[seen] = require.cache[own]
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// Nothing resolvable to repair: the parser import below reports the real error.
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sando-ui/eslint-plugin",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "ESLint rules a service repository runs over its own screens and CSS beside installed Sando units.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://sando-ui.github.io",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"sando-ui",
|
|
9
|
+
"eslint",
|
|
10
|
+
"eslintplugin",
|
|
11
|
+
"eslint-plugin",
|
|
12
|
+
"design-system",
|
|
13
|
+
"design-tokens",
|
|
14
|
+
"css"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./lib/index.mjs",
|
|
19
|
+
"./token-names": "./lib/token-names.mjs"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"lib",
|
|
23
|
+
"surface.json"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22.13"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "node --test",
|
|
30
|
+
"token-names:build": "node scripts/build-token-names.mjs",
|
|
31
|
+
"surface:build": "node scripts/build-surface.mjs",
|
|
32
|
+
"surface:check": "node scripts/build-surface.mjs --check"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"eslint": ">=9.24.0"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@eslint/css": "^2.0.1",
|
|
39
|
+
"@typescript-eslint/parser": "^8.72.1"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"eslint": "^9.39.5"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/surface.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"package": "@sando-ui/eslint-plugin",
|
|
3
|
+
"removalBreaks": {
|
|
4
|
+
"rules": [
|
|
5
|
+
"sando/installed-units",
|
|
6
|
+
"sando/no-inline-scale-literal",
|
|
7
|
+
"sando/no-reach",
|
|
8
|
+
"sando/no-scale-literal",
|
|
9
|
+
"sando/own-prefix",
|
|
10
|
+
"sando/policy-props",
|
|
11
|
+
"sando/policy-units",
|
|
12
|
+
"sando/screen-imports",
|
|
13
|
+
"sando/shared-class-only",
|
|
14
|
+
"sando/story-args-only",
|
|
15
|
+
"sando/theme-in-token",
|
|
16
|
+
"sando/token-tier",
|
|
17
|
+
"sando/type-whole"
|
|
18
|
+
],
|
|
19
|
+
"ruleOptions": [
|
|
20
|
+
"sando/installed-units.lock",
|
|
21
|
+
"sando/installed-units.root",
|
|
22
|
+
"sando/own-prefix.prefix",
|
|
23
|
+
"sando/policy-props.props",
|
|
24
|
+
"sando/policy-props.root",
|
|
25
|
+
"sando/policy-units.root",
|
|
26
|
+
"sando/policy-units.screenTypes",
|
|
27
|
+
"sando/policy-units.screens",
|
|
28
|
+
"sando/screen-imports.denyImports",
|
|
29
|
+
"sando/screen-imports.root",
|
|
30
|
+
"sando/screen-imports.units",
|
|
31
|
+
"sando/shared-class-only.tokenNames",
|
|
32
|
+
"sando/token-tier.tokenNames"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"additionBreaks": {
|
|
36
|
+
"errorRules": [
|
|
37
|
+
"no-restricted-globals",
|
|
38
|
+
"no-restricted-properties",
|
|
39
|
+
"no-restricted-syntax",
|
|
40
|
+
"sando/installed-units",
|
|
41
|
+
"sando/no-inline-scale-literal",
|
|
42
|
+
"sando/no-reach",
|
|
43
|
+
"sando/no-scale-literal",
|
|
44
|
+
"sando/own-prefix",
|
|
45
|
+
"sando/policy-props",
|
|
46
|
+
"sando/policy-units",
|
|
47
|
+
"sando/screen-imports",
|
|
48
|
+
"sando/shared-class-only",
|
|
49
|
+
"sando/story-args-only",
|
|
50
|
+
"sando/theme-in-token",
|
|
51
|
+
"sando/token-tier",
|
|
52
|
+
"sando/type-whole"
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
}
|