@codyswann/lisa 3.45.9 → 3.46.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/all/copy-overwrite/scripts/lisa-floor-collisions.mjs +17 -1
- package/all/copy-overwrite/scripts/lisa-gates.mjs +2 -2
- package/all/copy-overwrite/scripts/lisa-work-item.mjs +54 -12
- package/dist/configs/eslint/expo.d.ts.map +1 -1
- package/dist/configs/eslint/expo.js +5 -0
- package/dist/configs/eslint/expo.js.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +15 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +70 -17
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/eslint-plugin-ui-standards/README.md +90 -0
- package/eslint-plugin-ui-standards/index.js +4 -0
- package/eslint-plugin-ui-standards/rules/no-unbound-design-value.js +375 -0
- package/expo/copy-overwrite/eslint.expo.ts +5 -0
- package/expo/copy-overwrite/scripts/bdd/discover.mjs +20 -4
- package/expo/copy-overwrite/scripts/bdd/markdown-cell.mjs +7 -1
- package/expo/copy-overwrite/scripts/bdd/parse.mjs +8 -2
- package/expo/copy-overwrite/scripts/check-e2e-coverage.mjs +8 -2
- package/expo/copy-overwrite/scripts/classify-maestro-failures.mjs +91 -10
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-design-intake/SKILL.md +169 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-design-intake/agents/openai.yaml +4 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-implement/SKILL.md +2 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-quality-review/SKILL.md +2 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-review-local/SKILL.md +9 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-tdd-implementation/SKILL.md +1 -0
- package/plugins/lisa/commands/design/intake.md +7 -0
- package/plugins/lisa/rules/eager/design-value-binding.md +80 -0
- package/plugins/lisa/rules/reference/design-value-binding.md +204 -0
- package/plugins/lisa/scripts/design-bindings-probe.mjs +713 -0
- package/plugins/lisa/scripts/design-intake-gate.mjs +470 -0
- package/plugins/lisa/scripts/design-variable-ids.mjs +565 -0
- package/plugins/lisa/skills/lisa-design-intake/SKILL.md +169 -0
- package/plugins/lisa/skills/lisa-design-intake/agents/openai.yaml +4 -0
- package/plugins/lisa/skills/lisa-implement/SKILL.md +2 -0
- package/plugins/lisa/skills/lisa-quality-review/SKILL.md +2 -0
- package/plugins/lisa/skills/lisa-review-local/SKILL.md +9 -0
- package/plugins/lisa/skills/lisa-tdd-implementation/SKILL.md +1 -0
- package/plugins/lisa-agy/commands/lisa/design/intake.md +7 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/scripts/design-bindings-probe.mjs +713 -0
- package/plugins/lisa-agy/scripts/design-intake-gate.mjs +470 -0
- package/plugins/lisa-agy/scripts/design-variable-ids.mjs +565 -0
- package/plugins/lisa-agy/skills/lisa-design-intake/SKILL.md +169 -0
- package/plugins/lisa-agy/skills/lisa-implement/SKILL.md +2 -0
- package/plugins/lisa-agy/skills/lisa-quality-review/SKILL.md +2 -0
- package/plugins/lisa-agy/skills/lisa-review-local/SKILL.md +9 -0
- package/plugins/lisa-agy/skills/lisa-tdd-implementation/SKILL.md +1 -0
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/commands/lisa/design/intake.md +7 -0
- package/plugins/lisa-copilot/rules/eager/design-value-binding.md +80 -0
- package/plugins/lisa-copilot/rules/reference/design-value-binding.md +204 -0
- package/plugins/lisa-copilot/scripts/design-bindings-probe.mjs +713 -0
- package/plugins/lisa-copilot/scripts/design-intake-gate.mjs +470 -0
- package/plugins/lisa-copilot/scripts/design-variable-ids.mjs +565 -0
- package/plugins/lisa-copilot/skills/lisa-design-intake/SKILL.md +169 -0
- package/plugins/lisa-copilot/skills/lisa-implement/SKILL.md +2 -0
- package/plugins/lisa-copilot/skills/lisa-quality-review/SKILL.md +2 -0
- package/plugins/lisa-copilot/skills/lisa-review-local/SKILL.md +9 -0
- package/plugins/lisa-copilot/skills/lisa-tdd-implementation/SKILL.md +1 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/commands/lisa/design/intake.md +7 -0
- package/plugins/lisa-cursor/rules/design-value-binding-reference.mdc +209 -0
- package/plugins/lisa-cursor/rules/design-value-binding.mdc +85 -0
- package/plugins/lisa-cursor/scripts/design-bindings-probe.mjs +713 -0
- package/plugins/lisa-cursor/scripts/design-intake-gate.mjs +470 -0
- package/plugins/lisa-cursor/scripts/design-variable-ids.mjs +565 -0
- package/plugins/lisa-cursor/skills/lisa-design-intake/SKILL.md +169 -0
- package/plugins/lisa-cursor/skills/lisa-implement/SKILL.md +2 -0
- package/plugins/lisa-cursor/skills/lisa-quality-review/SKILL.md +2 -0
- package/plugins/lisa-cursor/skills/lisa-review-local/SKILL.md +9 -0
- package/plugins/lisa-cursor/skills/lisa-tdd-implementation/SKILL.md +1 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/commands/design/intake.md +7 -0
- package/plugins/src/base/rules/eager/design-value-binding.md +80 -0
- package/plugins/src/base/rules/reference/design-value-binding.md +204 -0
- package/plugins/src/base/scripts/design-bindings-probe.mjs +713 -0
- package/plugins/src/base/scripts/design-intake-gate.mjs +470 -0
- package/plugins/src/base/scripts/design-variable-ids.mjs +565 -0
- package/plugins/src/base/skills/lisa-design-intake/SKILL.md +169 -0
- package/plugins/src/base/skills/lisa-implement/SKILL.md +2 -0
- package/plugins/src/base/skills/lisa-quality-review/SKILL.md +2 -0
- package/plugins/src/base/skills/lisa-review-local/SKILL.md +9 -0
- package/plugins/src/base/skills/lisa-tdd-implementation/SKILL.md +1 -0
- package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +22 -6
- package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +10 -1
|
@@ -146,6 +146,95 @@ const MyComponent = () => (
|
|
|
146
146
|
|
|
147
147
|
---
|
|
148
148
|
|
|
149
|
+
### no-unbound-design-value
|
|
150
|
+
|
|
151
|
+
Flags a hardcoded style value in an axis that has a published design-variable
|
|
152
|
+
collection behind it.
|
|
153
|
+
|
|
154
|
+
#### Rule Details
|
|
155
|
+
|
|
156
|
+
Design handoff has one rule: **values come from design variables where a variable
|
|
157
|
+
system exists.** Visual measurement is supplemental there, and legitimate as the
|
|
158
|
+
primary source where no variable system exists.
|
|
159
|
+
|
|
160
|
+
The regime is resolved **per axis, not per project**. A library with a mature
|
|
161
|
+
colour system and no spacing scale is the common case, and it has to work:
|
|
162
|
+
colour is bound and a literal is a defect, while spacing is measured and a
|
|
163
|
+
literal is correct — in the same file, on the same day.
|
|
164
|
+
|
|
165
|
+
**Regime-awareness is by declaration, not by live query.** ESLint cannot ask a
|
|
166
|
+
design tool which collections are published, so the typed axes arrive as the
|
|
167
|
+
`typedAxes` option, mirroring `design.tokens.axes` in `.lisa.config.json`. The
|
|
168
|
+
default is the empty list, so **an unconfigured project sees nothing**. That
|
|
169
|
+
silence is deliberate: a rule that fires on every project is indistinguishable
|
|
170
|
+
from no rule at all, because the first thing anyone does is switch it off.
|
|
171
|
+
|
|
172
|
+
The rule answers only the objective question — is a value in a typed axis
|
|
173
|
+
written as a literal? It never judges whether a design is ambiguous or ugly.
|
|
174
|
+
That is an opinion, and an agent asked for one blocks on everything or nothing.
|
|
175
|
+
|
|
176
|
+
**Axes:** `color`, `spacing`, `typography`, `radius`, `elevation`, `motion`.
|
|
177
|
+
|
|
178
|
+
**Surfaces covered:** style-object properties (`StyleSheet.create`, inline style
|
|
179
|
+
objects, theme objects), JSX attributes, and `styled`/`css` tagged-template
|
|
180
|
+
bodies — whose declarations live inside a string and are invisible to a
|
|
181
|
+
property-name visitor.
|
|
182
|
+
|
|
183
|
+
**Not covered:** stylesheet files (`.css`, `.scss`) — ESLint does not parse them.
|
|
184
|
+
|
|
185
|
+
#### Examples
|
|
186
|
+
|
|
187
|
+
Given `typedAxes: ["color", "radius"]` — colour and radius are published, spacing
|
|
188
|
+
is not:
|
|
189
|
+
|
|
190
|
+
**Incorrect** (a literal in a typed axis):
|
|
191
|
+
|
|
192
|
+
```tsx
|
|
193
|
+
const styles = StyleSheet.create({
|
|
194
|
+
card: { backgroundColor: "#3A7BD5", borderRadius: 12 },
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
**Correct** (bound in the typed axes, measured in the untyped one):
|
|
199
|
+
|
|
200
|
+
```tsx
|
|
201
|
+
const styles = StyleSheet.create({
|
|
202
|
+
card: {
|
|
203
|
+
backgroundColor: tokens.surface.raised,
|
|
204
|
+
borderRadius: tokens.radius.card,
|
|
205
|
+
padding: 12, // spacing has no variable collection — measuring is the source
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
#### Configuration
|
|
211
|
+
|
|
212
|
+
```javascript
|
|
213
|
+
// eslint.config.local.ts
|
|
214
|
+
{
|
|
215
|
+
rules: {
|
|
216
|
+
"ui-standards/no-unbound-design-value": ["error", {
|
|
217
|
+
typedAxes: ["color", "radius"],
|
|
218
|
+
}],
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**Options:**
|
|
224
|
+
|
|
225
|
+
| Option | Type | Default | Description |
|
|
226
|
+
|--------|------|---------|-------------|
|
|
227
|
+
| `typedAxes` | `string[]` | `[]` | Axes with a published variable collection. Empty means the rule reports nothing. |
|
|
228
|
+
| `ignoreValues` | `(string \| number)[]` | `[0, 1]` | Values never worth a token — a zero is the absence of a value, a one is a hairline. |
|
|
229
|
+
| `tokenSourcePaths` | `string[]` | `["/theme/", "/themes/", "/tokens/", "/design-system/", "/design-tokens/"]` | Path fragments where literals belong, because those files mirror the published variables. |
|
|
230
|
+
|
|
231
|
+
This rule is the executable rung of the design-handoff policy. The judgment rung
|
|
232
|
+
— regime detection against the live variable collections, the five block
|
|
233
|
+
conditions, and escalation of a blocked item — lives in the `lisa-design-intake`
|
|
234
|
+
skill (`/lisa:design:intake`).
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
149
238
|
## Installation
|
|
150
239
|
|
|
151
240
|
This plugin is installed locally as a file dependency:
|
|
@@ -172,6 +261,7 @@ export default [
|
|
|
172
261
|
rules: {
|
|
173
262
|
'ui-standards/no-classname-outside-ui': 'error',
|
|
174
263
|
'ui-standards/no-direct-rn-imports': 'error',
|
|
264
|
+
'ui-standards/no-unbound-design-value': ['error', { typedAxes: ['color'] }],
|
|
175
265
|
},
|
|
176
266
|
},
|
|
177
267
|
];
|
|
@@ -12,10 +12,13 @@
|
|
|
12
12
|
* Rules:
|
|
13
13
|
* - no-classname-outside-ui: Disallows className prop outside UI components
|
|
14
14
|
* - no-direct-rn-imports: Disallows direct React Native imports
|
|
15
|
+
* - no-unbound-design-value: Disallows a hardcoded style value in an axis that
|
|
16
|
+
* has a published design-variable collection (regime-aware; opt-in per axis)
|
|
15
17
|
* @module eslint-plugin-ui-standards
|
|
16
18
|
*/
|
|
17
19
|
const noClassnameOutsideUi = require("./rules/no-classname-outside-ui");
|
|
18
20
|
const noDirectRnImports = require("./rules/no-direct-rn-imports");
|
|
21
|
+
const noUnboundDesignValue = require("./rules/no-unbound-design-value");
|
|
19
22
|
|
|
20
23
|
const plugin = {
|
|
21
24
|
meta: {
|
|
@@ -25,6 +28,7 @@ const plugin = {
|
|
|
25
28
|
rules: {
|
|
26
29
|
"no-classname-outside-ui": noClassnameOutsideUi,
|
|
27
30
|
"no-direct-rn-imports": noDirectRnImports,
|
|
31
|
+
"no-unbound-design-value": noUnboundDesignValue,
|
|
28
32
|
},
|
|
29
33
|
};
|
|
30
34
|
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is managed by Lisa.
|
|
3
|
+
* Do not edit directly — changes will be overwritten on the next `lisa` run.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Flags a hardcoded style value in an axis the project has declared *typed* —
|
|
8
|
+
* an axis that has a published design-variable collection behind it.
|
|
9
|
+
*
|
|
10
|
+
* The policy this enforces is per-axis, not per-project: colour may have a
|
|
11
|
+
* mature variable system while spacing has none, in the same library, on the
|
|
12
|
+
* same day. Where a variable system exists the variable is the source of truth
|
|
13
|
+
* and a literal is a copy of a number that will change without warning. Where
|
|
14
|
+
* no variable system exists, measuring the design *is* the legitimate source
|
|
15
|
+
* and this rule must stay silent — a control that fires on every project is
|
|
16
|
+
* indistinguishable from no control at all, because it gets turned off.
|
|
17
|
+
*
|
|
18
|
+
* **Regime-awareness is by declaration, not by live query.** ESLint cannot ask
|
|
19
|
+
* a design tool which collections are published, so the typed axes arrive as a
|
|
20
|
+
* rule option (`typedAxes`), sourced from `design.tokens.axes` in
|
|
21
|
+
* `.lisa.config.json`. The default is the empty list, which means the rule
|
|
22
|
+
* reports nothing until a project states which axes it has a variable system
|
|
23
|
+
* for. Silence-by-default is the deliberate choice: the failure mode of an
|
|
24
|
+
* over-firing rule is a disabled rule.
|
|
25
|
+
*
|
|
26
|
+
* The judgment this rule deliberately does NOT make is "is this design
|
|
27
|
+
* ambiguous" — that is an opinion, not a fact, and an agent asked for it blocks
|
|
28
|
+
* on everything or nothing. This rule only answers the objective question: is
|
|
29
|
+
* a value in a typed axis written as a literal?
|
|
30
|
+
* @module eslint-plugin-ui-standards/rules/no-unbound-design-value
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Every axis the policy recognises. Fixed vocabulary — projects pick a subset. */
|
|
34
|
+
const AXES = [
|
|
35
|
+
"color",
|
|
36
|
+
"spacing",
|
|
37
|
+
"typography",
|
|
38
|
+
"radius",
|
|
39
|
+
"elevation",
|
|
40
|
+
"motion",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Object-property names, in the JS style-object dialects (React Native
|
|
45
|
+
* `StyleSheet`, inline `style` objects, theme objects), mapped to their axis.
|
|
46
|
+
* Compared after camelCase/kebab-case normalization, so the same table serves
|
|
47
|
+
* `borderRadius`, `border-radius`, and `BORDER_RADIUS`.
|
|
48
|
+
*/
|
|
49
|
+
const PROPERTY_AXES = new Map(
|
|
50
|
+
Object.entries({
|
|
51
|
+
// color
|
|
52
|
+
color: "color",
|
|
53
|
+
backgroundcolor: "color",
|
|
54
|
+
background: "color",
|
|
55
|
+
bordercolor: "color",
|
|
56
|
+
bordertopcolor: "color",
|
|
57
|
+
borderbottomcolor: "color",
|
|
58
|
+
borderleftcolor: "color",
|
|
59
|
+
borderrightcolor: "color",
|
|
60
|
+
tintcolor: "color",
|
|
61
|
+
shadowcolor: "color",
|
|
62
|
+
placeholdertextcolor: "color",
|
|
63
|
+
fill: "color",
|
|
64
|
+
stroke: "color",
|
|
65
|
+
// spacing
|
|
66
|
+
padding: "spacing",
|
|
67
|
+
paddingtop: "spacing",
|
|
68
|
+
paddingbottom: "spacing",
|
|
69
|
+
paddingleft: "spacing",
|
|
70
|
+
paddingright: "spacing",
|
|
71
|
+
paddinghorizontal: "spacing",
|
|
72
|
+
paddingvertical: "spacing",
|
|
73
|
+
margin: "spacing",
|
|
74
|
+
margintop: "spacing",
|
|
75
|
+
marginbottom: "spacing",
|
|
76
|
+
marginleft: "spacing",
|
|
77
|
+
marginright: "spacing",
|
|
78
|
+
marginhorizontal: "spacing",
|
|
79
|
+
marginvertical: "spacing",
|
|
80
|
+
gap: "spacing",
|
|
81
|
+
rowgap: "spacing",
|
|
82
|
+
columngap: "spacing",
|
|
83
|
+
// typography
|
|
84
|
+
fontsize: "typography",
|
|
85
|
+
fontweight: "typography",
|
|
86
|
+
lineheight: "typography",
|
|
87
|
+
letterspacing: "typography",
|
|
88
|
+
fontfamily: "typography",
|
|
89
|
+
// radius
|
|
90
|
+
borderradius: "radius",
|
|
91
|
+
bordertopleftradius: "radius",
|
|
92
|
+
bordertoprightradius: "radius",
|
|
93
|
+
borderbottomleftradius: "radius",
|
|
94
|
+
borderbottomrightradius: "radius",
|
|
95
|
+
// elevation
|
|
96
|
+
elevation: "elevation",
|
|
97
|
+
shadowopacity: "elevation",
|
|
98
|
+
shadowradius: "elevation",
|
|
99
|
+
boxshadow: "elevation",
|
|
100
|
+
// motion
|
|
101
|
+
animationduration: "motion",
|
|
102
|
+
transitionduration: "motion",
|
|
103
|
+
transitiondelay: "motion",
|
|
104
|
+
})
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Literals that are unmistakably colours, wherever they appear. Colour is the
|
|
109
|
+
* one axis whose values are self-identifying, so it is caught by shape as well
|
|
110
|
+
* as by property name — a `#3A7BD5` handed to a prop this table never heard of
|
|
111
|
+
* is still a colour copied out of a design file.
|
|
112
|
+
*
|
|
113
|
+
* Kept as two simple patterns rather than one alternation: the combined form
|
|
114
|
+
* tripped the regex-complexity budget, and two named shapes read better anyway.
|
|
115
|
+
*/
|
|
116
|
+
const HEX_COLOR = /^#[0-9a-f]{3,8}$/iu;
|
|
117
|
+
|
|
118
|
+
/** The functional colour notations. */
|
|
119
|
+
const FUNCTIONAL_COLOR = /^(?:rgba?|hsla?)\([^)]*\)$/iu;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* One CSS declaration, already isolated to a single `property: value` slice.
|
|
123
|
+
* Splitting first and matching an anchored pattern second keeps the matcher
|
|
124
|
+
* linear — a single scanning regex over the whole body backtracks.
|
|
125
|
+
*/
|
|
126
|
+
const CSS_DECLARATION = /^([a-z][a-z-]*):(.+)$/iu;
|
|
127
|
+
|
|
128
|
+
/** A CSS value that is a variable reference rather than a bare literal. */
|
|
129
|
+
const CSS_VARIABLE_REFERENCE = /var\(|\$\{|\$[a-z_]/iu;
|
|
130
|
+
|
|
131
|
+
/** Tag callees that mean "the template body is CSS". */
|
|
132
|
+
const CSS_TAGS = new Set(["css", "styled", "createGlobalStyle", "keyframes"]);
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Normalize a property name so `borderRadius`, `border-radius`, and
|
|
136
|
+
* `BORDER_RADIUS` all reach the same table entry.
|
|
137
|
+
* @param {string} name - Raw property name.
|
|
138
|
+
* @returns {string} Lowercased name with separators removed.
|
|
139
|
+
*/
|
|
140
|
+
function normalizeProperty(name) {
|
|
141
|
+
return String(name).replaceAll(/[-_]/gu, "").toLowerCase();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Resolve the static name of an object-property or JSX-attribute key.
|
|
146
|
+
* @param {object} node - Property or JSXAttribute node.
|
|
147
|
+
* @returns {string | null} The key's name, or null when it is computed.
|
|
148
|
+
*/
|
|
149
|
+
function keyNameOf(node) {
|
|
150
|
+
const key = node?.key ?? node?.name;
|
|
151
|
+
if (!key) return null;
|
|
152
|
+
if (node?.computed === true) return null;
|
|
153
|
+
if (key.type === "Identifier" || key.type === "JSXIdentifier")
|
|
154
|
+
return key.name;
|
|
155
|
+
if (key.type === "Literal" && typeof key.value === "string") return key.value;
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Read a literal's value when the node is a plain literal (or a negated
|
|
161
|
+
* numeric one, which is a `UnaryExpression` in the AST rather than a Literal).
|
|
162
|
+
* @param {object} node - Value node.
|
|
163
|
+
* @returns {{ value: string | number } | null} The literal value, or null.
|
|
164
|
+
*/
|
|
165
|
+
function literalValueOf(node) {
|
|
166
|
+
if (node?.type === "Literal") {
|
|
167
|
+
const { value } = node;
|
|
168
|
+
return typeof value === "string" || typeof value === "number"
|
|
169
|
+
? { value }
|
|
170
|
+
: null;
|
|
171
|
+
}
|
|
172
|
+
if (
|
|
173
|
+
node?.type === "UnaryExpression" &&
|
|
174
|
+
node.operator === "-" &&
|
|
175
|
+
node.argument?.type === "Literal" &&
|
|
176
|
+
typeof node.argument.value === "number"
|
|
177
|
+
) {
|
|
178
|
+
return { value: -node.argument.value };
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Decide the axis a literal belongs to, or null when it belongs to none.
|
|
185
|
+
* @param {string | null} property - Normalized property name, if any.
|
|
186
|
+
* @param {string | number} value - The literal value.
|
|
187
|
+
* @returns {string | null} Axis name, or null.
|
|
188
|
+
*/
|
|
189
|
+
function axisFor(property, value) {
|
|
190
|
+
if (typeof value === "string") {
|
|
191
|
+
const trimmed = value.trim();
|
|
192
|
+
if (HEX_COLOR.test(trimmed) || FUNCTIONAL_COLOR.test(trimmed)) {
|
|
193
|
+
return "color";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return property === null ? null : (PROPERTY_AXES.get(property) ?? null);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* True when the file is a place design values are *defined* rather than
|
|
201
|
+
* consumed — a theme or token module is exactly where the literals belong,
|
|
202
|
+
* because that is the file the variables are mirrored into.
|
|
203
|
+
* @param {string} filename - Absolute or relative file path.
|
|
204
|
+
* @param {readonly string[]} tokenSourcePaths - Path fragments to exempt.
|
|
205
|
+
* @returns {boolean} Whether the file defines tokens.
|
|
206
|
+
*/
|
|
207
|
+
function isTokenSource(filename, tokenSourcePaths) {
|
|
208
|
+
const normalized = String(filename).replaceAll("\\", "/").toLowerCase();
|
|
209
|
+
return tokenSourcePaths.some(fragment =>
|
|
210
|
+
normalized.includes(String(fragment).toLowerCase())
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Extract every axis-bearing CSS declaration from a tagged template body.
|
|
216
|
+
*
|
|
217
|
+
* Styled-component and `css` template bodies are opaque to a property-name
|
|
218
|
+
* visitor — the declarations live inside a string, not in the AST — so they are
|
|
219
|
+
* parsed here rather than left uncovered.
|
|
220
|
+
* @param {string} text - The concatenated template body.
|
|
221
|
+
* @returns {{ property: string, value: string, axis: string }[]} Findings.
|
|
222
|
+
*/
|
|
223
|
+
function cssDeclarations(text) {
|
|
224
|
+
const found = [];
|
|
225
|
+
for (const slice of text.split(/[;{}\n]/u)) {
|
|
226
|
+
const match = CSS_DECLARATION.exec(slice.trim());
|
|
227
|
+
if (match === null) continue;
|
|
228
|
+
const property = normalizeProperty(match[1] ?? "");
|
|
229
|
+
const value = (match[2] ?? "").trim();
|
|
230
|
+
if (value.length === 0 || CSS_VARIABLE_REFERENCE.test(value)) continue;
|
|
231
|
+
const axis = axisFor(property, value);
|
|
232
|
+
if (axis !== null) found.push({ property, value, axis });
|
|
233
|
+
}
|
|
234
|
+
return found;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* True when the tagged template is CSS (`styled.div`, `styled(X)`, `css`).
|
|
239
|
+
* @param {object} node - TaggedTemplateExpression node.
|
|
240
|
+
* @returns {boolean} Whether the body should be read as CSS.
|
|
241
|
+
*/
|
|
242
|
+
function isCssTag(node) {
|
|
243
|
+
const tag = node?.tag;
|
|
244
|
+
if (tag?.type === "Identifier") return CSS_TAGS.has(tag.name);
|
|
245
|
+
if (tag?.type === "MemberExpression" && tag.object?.type === "Identifier") {
|
|
246
|
+
return CSS_TAGS.has(tag.object.name);
|
|
247
|
+
}
|
|
248
|
+
if (tag?.type === "CallExpression" && tag.callee?.type === "Identifier") {
|
|
249
|
+
return CSS_TAGS.has(tag.callee.name);
|
|
250
|
+
}
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
module.exports = {
|
|
255
|
+
meta: {
|
|
256
|
+
type: "problem",
|
|
257
|
+
docs: {
|
|
258
|
+
description:
|
|
259
|
+
"Disallow hardcoded style values in an axis that has a published design-variable collection",
|
|
260
|
+
category: "Best Practices",
|
|
261
|
+
recommended: false,
|
|
262
|
+
},
|
|
263
|
+
fixable: null,
|
|
264
|
+
schema: [
|
|
265
|
+
{
|
|
266
|
+
type: "object",
|
|
267
|
+
properties: {
|
|
268
|
+
typedAxes: {
|
|
269
|
+
type: "array",
|
|
270
|
+
items: { type: "string", enum: AXES },
|
|
271
|
+
uniqueItems: true,
|
|
272
|
+
},
|
|
273
|
+
ignoreValues: {
|
|
274
|
+
type: "array",
|
|
275
|
+
items: { type: ["string", "number"] },
|
|
276
|
+
},
|
|
277
|
+
tokenSourcePaths: {
|
|
278
|
+
type: "array",
|
|
279
|
+
items: { type: "string" },
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
additionalProperties: false,
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
messages: {
|
|
286
|
+
unboundDesignValue:
|
|
287
|
+
"This project publishes {{axis}} as design variables, so `{{property}}: {{value}}` is a copied number that changes without warning. Use the published {{axis}} variable, or ask for one to be published for this value.",
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
create(context) {
|
|
292
|
+
const options = context.options[0] ?? {};
|
|
293
|
+
const typedAxes = new Set(options.typedAxes ?? []);
|
|
294
|
+
// `0` and `1` are ignored by default: a zero is the absence of a value and
|
|
295
|
+
// a one is almost always a hairline border, and neither is a number any
|
|
296
|
+
// design system publishes as a token. Reporting them is the noise that
|
|
297
|
+
// gets a rule switched off.
|
|
298
|
+
const ignoreValues = new Set(options.ignoreValues ?? [0, 1]);
|
|
299
|
+
const tokenSourcePaths = options.tokenSourcePaths ?? [
|
|
300
|
+
"/theme/",
|
|
301
|
+
"/themes/",
|
|
302
|
+
"/tokens/",
|
|
303
|
+
"/design-system/",
|
|
304
|
+
"/design-tokens/",
|
|
305
|
+
];
|
|
306
|
+
|
|
307
|
+
// Nothing is typed until the project says so. This is the regime gate: on a
|
|
308
|
+
// project with no declared variable collections the rule visits nothing.
|
|
309
|
+
if (typedAxes.size === 0) return {};
|
|
310
|
+
|
|
311
|
+
const filename = context.filename ?? context.getFilename();
|
|
312
|
+
if (isTokenSource(filename, tokenSourcePaths)) return {};
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Report one finding when its axis is typed and its value is not exempt.
|
|
316
|
+
* @param {object} node - Node to attach the report to.
|
|
317
|
+
* @param {string} property - Human-readable property name.
|
|
318
|
+
* @param {string | number} value - The literal value.
|
|
319
|
+
* @param {string | null} axis - Resolved axis.
|
|
320
|
+
* @returns {void}
|
|
321
|
+
*/
|
|
322
|
+
const reportIfTyped = (node, property, value, axis) => {
|
|
323
|
+
if (axis === null || !typedAxes.has(axis)) return;
|
|
324
|
+
if (ignoreValues.has(value)) return;
|
|
325
|
+
context.report({
|
|
326
|
+
node,
|
|
327
|
+
messageId: "unboundDesignValue",
|
|
328
|
+
data: { axis, property, value: String(value) },
|
|
329
|
+
});
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Shared handler for a keyed node carrying a single literal value.
|
|
334
|
+
* @param {object} node - Property or JSXAttribute node.
|
|
335
|
+
* @param {object} valueNode - The node holding the value.
|
|
336
|
+
* @returns {void}
|
|
337
|
+
*/
|
|
338
|
+
const checkKeyedLiteral = (node, valueNode) => {
|
|
339
|
+
const rawKey = keyNameOf(node);
|
|
340
|
+
const literal = literalValueOf(valueNode);
|
|
341
|
+
if (literal === null) return;
|
|
342
|
+
const property = rawKey === null ? null : normalizeProperty(rawKey);
|
|
343
|
+
reportIfTyped(
|
|
344
|
+
valueNode,
|
|
345
|
+
rawKey ?? "value",
|
|
346
|
+
literal.value,
|
|
347
|
+
axisFor(property, literal.value)
|
|
348
|
+
);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
Property(node) {
|
|
353
|
+
checkKeyedLiteral(node, node.value);
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
JSXAttribute(node) {
|
|
357
|
+
const valueNode =
|
|
358
|
+
node.value?.type === "JSXExpressionContainer"
|
|
359
|
+
? node.value.expression
|
|
360
|
+
: node.value;
|
|
361
|
+
checkKeyedLiteral(node, valueNode);
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
TaggedTemplateExpression(node) {
|
|
365
|
+
if (!isCssTag(node)) return;
|
|
366
|
+
const body = (node.quasi?.quasis ?? [])
|
|
367
|
+
.map(quasi => quasi.value?.cooked ?? quasi.value?.raw ?? "")
|
|
368
|
+
.join("\n${...}\n");
|
|
369
|
+
for (const finding of cssDeclarations(body)) {
|
|
370
|
+
reportIfTyped(node, finding.property, finding.value, finding.axis);
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
},
|
|
375
|
+
};
|
|
@@ -124,6 +124,11 @@ export function getExpoConfig({
|
|
|
124
124
|
// UI standards (all off by default)
|
|
125
125
|
"ui-standards/no-classname-outside-ui": "off",
|
|
126
126
|
"ui-standards/no-direct-rn-imports": "off",
|
|
127
|
+
// Regime-aware by declaration: silent until the project names the axes
|
|
128
|
+
// it publishes design variables for. Activate per axis in
|
|
129
|
+
// eslint.config.local.ts, mirroring `design.tokens.axes` in
|
|
130
|
+
// .lisa.config.json — e.g. ["error", { typedAxes: ["color"] }].
|
|
131
|
+
"ui-standards/no-unbound-design-value": "off",
|
|
127
132
|
|
|
128
133
|
// Tailwind
|
|
129
134
|
"tailwindcss/classnames-order": [
|
|
@@ -26,6 +26,22 @@ import * as path from "node:path";
|
|
|
26
26
|
import { byCodeUnit } from "./contract.mjs";
|
|
27
27
|
import { listFiles, posix, resolveInsideRepo } from "./parse.mjs";
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* A path without its trailing slashes.
|
|
31
|
+
*
|
|
32
|
+
* An index scan rather than `.replace(/\/+$/, "")`. A quantified class pinned
|
|
33
|
+
* to `$` is re-attempted from every position before it can fail, which is
|
|
34
|
+
* super-linear in the path's length — S5852. Walking back from the end is one
|
|
35
|
+
* pass and cannot backtrack.
|
|
36
|
+
* @param {string} value - A repo-relative path or prefix.
|
|
37
|
+
* @returns {string} The same path with every trailing `/` removed.
|
|
38
|
+
*/
|
|
39
|
+
const withoutTrailingSlashes = value => {
|
|
40
|
+
let end = value.length;
|
|
41
|
+
while (end > 0 && value[end - 1] === "/") end -= 1;
|
|
42
|
+
return value.slice(0, end);
|
|
43
|
+
};
|
|
44
|
+
|
|
29
45
|
/**
|
|
30
46
|
* Normalize a repo-relative path or path prefix to one comparable spelling.
|
|
31
47
|
*
|
|
@@ -36,10 +52,10 @@ import { listFiles, posix, resolveInsideRepo } from "./parse.mjs";
|
|
|
36
52
|
* @returns {string} Its canonical form; the empty string means the repo root.
|
|
37
53
|
*/
|
|
38
54
|
const canonicalPath = value =>
|
|
39
|
-
posix(String(value))
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
55
|
+
withoutTrailingSlashes(posix(String(value)).replace(/^\.\/+/, "")).replace(
|
|
56
|
+
/^\.$/,
|
|
57
|
+
""
|
|
58
|
+
);
|
|
43
59
|
|
|
44
60
|
/**
|
|
45
61
|
* Whether a repo-relative path lies at or under a path prefix, SEGMENT-WISE.
|
|
@@ -68,6 +68,12 @@ const escapePipeRun = (_match, backslashes) =>
|
|
|
68
68
|
* failures are invisible in review:
|
|
69
69
|
*
|
|
70
70
|
* - Pipes are escaped by RUN, not one at a time — see {@link escapePipeRun}.
|
|
71
|
+
* - `(?<!\\)` in front of that run. Without it the engine may start a run
|
|
72
|
+
* part-way into a longer one, so every position in a backslash run is a
|
|
73
|
+
* fresh attempt and the scan is super-linear in the cell's length — S5852,
|
|
74
|
+
* on author-supplied text. The lookbehind removes no match: a run's
|
|
75
|
+
* leftmost backslash always wins under leftmost-match anyway, so the
|
|
76
|
+
* later starts it forbids were never the ones chosen.
|
|
71
77
|
* - `/\r\n?|\n/` and not `/\r?\n/`. The latter only matches a CR that is
|
|
72
78
|
* FOLLOWED by an LF, so a lone `\r` survives — and CommonMark treats a lone
|
|
73
79
|
* CR as a line ending, which ends the row rather than the cell.
|
|
@@ -93,7 +99,7 @@ const escapePipeRun = (_match, backslashes) =>
|
|
|
93
99
|
*/
|
|
94
100
|
export const cell = text =>
|
|
95
101
|
String(text ?? "")
|
|
96
|
-
.replace(/(\\*)\|/g, escapePipeRun)
|
|
102
|
+
.replace(/(?<!\\)(\\*)\|/g, escapePipeRun)
|
|
97
103
|
.replace(/\r\n?|\n/g, " ");
|
|
98
104
|
|
|
99
105
|
/**
|
|
@@ -163,7 +163,13 @@ export function parseFeatureSource(source, file, platforms) {
|
|
|
163
163
|
pending.push(...tagsOf(trimmed));
|
|
164
164
|
continue;
|
|
165
165
|
}
|
|
166
|
-
|
|
166
|
+
// `(\S.*)` and not `(.+)`, here and on the Scenario line below. `\s*(.+)`
|
|
167
|
+
// lets both halves claim the same run of spaces, so the engine has a
|
|
168
|
+
// choice at every one of them and the match is super-linear in the line's
|
|
169
|
+
// length — S5852, on feature files. `\S` makes the halves disjoint. The
|
|
170
|
+
// subject is already trimmed and the capture is trimmed again, so no line
|
|
171
|
+
// that used to match stops matching.
|
|
172
|
+
const featureMatch = /^Feature:\s*(\S.*)$/.exec(trimmed);
|
|
167
173
|
if (featureMatch) {
|
|
168
174
|
feature = featureMatch[1].trim();
|
|
169
175
|
// Gherkin inherits Feature-level tags down to every scenario in the
|
|
@@ -189,7 +195,7 @@ export function parseFeatureSource(source, file, platforms) {
|
|
|
189
195
|
current = null;
|
|
190
196
|
continue;
|
|
191
197
|
}
|
|
192
|
-
const scenarioMatch = /^Scenario(?: Outline| Template)?:\s*(
|
|
198
|
+
const scenarioMatch = /^Scenario(?: Outline| Template)?:\s*(\S.*)$/.exec(
|
|
193
199
|
trimmed
|
|
194
200
|
);
|
|
195
201
|
if (scenarioMatch) {
|
|
@@ -397,11 +397,17 @@ function flowTags(source) {
|
|
|
397
397
|
for (const line of lines.slice(start + 1)) {
|
|
398
398
|
// The block ends at the document separator, at a non-indented line, or at
|
|
399
399
|
// anything that is not a list item. Each of those is what the regex missed.
|
|
400
|
-
|
|
400
|
+
// The whitespace either side of the item is trimmed in JS rather than by
|
|
401
|
+
// `\s*(.+?)\s*$`. There, three quantifiers compete for the same spaces, so
|
|
402
|
+
// the engine has a choice to make at each one and the match is
|
|
403
|
+
// super-linear in the line's length — S5852, on a workflow file. `(.+)`
|
|
404
|
+
// holds the same "at least one character after the dash" condition the
|
|
405
|
+
// lazy form did, so no line changes its verdict.
|
|
406
|
+
const item = line.match(/^\s+-(.+)$/u);
|
|
401
407
|
if (!item || line.startsWith("---")) {
|
|
402
408
|
break;
|
|
403
409
|
}
|
|
404
|
-
tags.push(item[1].replace(/^["']|["']$/gu, ""));
|
|
410
|
+
tags.push(item[1].trim().replace(/^["']|["']$/gu, ""));
|
|
405
411
|
}
|
|
406
412
|
return tags;
|
|
407
413
|
}
|