@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 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
- # Temporary Holding Version
1
+ # @sando-ui/eslint-plugin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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).
@@ -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
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }