@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.
Files changed (134) hide show
  1. package/all/copy-overwrite/scripts/lisa-floor-collisions.mjs +17 -1
  2. package/all/copy-overwrite/scripts/lisa-gates.mjs +2 -2
  3. package/all/copy-overwrite/scripts/lisa-work-item.mjs +54 -12
  4. package/dist/configs/eslint/expo.d.ts.map +1 -1
  5. package/dist/configs/eslint/expo.js +5 -0
  6. package/dist/configs/eslint/expo.js.map +1 -1
  7. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  8. package/dist/core/lisa-owned-hash-ledger.js +15 -0
  9. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  10. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  11. package/dist/core/upstream-evidence-manifest.js +70 -17
  12. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  13. package/eslint-plugin-ui-standards/README.md +90 -0
  14. package/eslint-plugin-ui-standards/index.js +4 -0
  15. package/eslint-plugin-ui-standards/rules/no-unbound-design-value.js +375 -0
  16. package/expo/copy-overwrite/eslint.expo.ts +5 -0
  17. package/expo/copy-overwrite/scripts/bdd/discover.mjs +20 -4
  18. package/expo/copy-overwrite/scripts/bdd/markdown-cell.mjs +7 -1
  19. package/expo/copy-overwrite/scripts/bdd/parse.mjs +8 -2
  20. package/expo/copy-overwrite/scripts/check-e2e-coverage.mjs +8 -2
  21. package/expo/copy-overwrite/scripts/classify-maestro-failures.mjs +91 -10
  22. package/package.json +1 -1
  23. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  25. package/plugins/lisa/.codex-plugin/skills/lisa-design-intake/SKILL.md +169 -0
  26. package/plugins/lisa/.codex-plugin/skills/lisa-design-intake/agents/openai.yaml +4 -0
  27. package/plugins/lisa/.codex-plugin/skills/lisa-implement/SKILL.md +2 -0
  28. package/plugins/lisa/.codex-plugin/skills/lisa-quality-review/SKILL.md +2 -0
  29. package/plugins/lisa/.codex-plugin/skills/lisa-review-local/SKILL.md +9 -0
  30. package/plugins/lisa/.codex-plugin/skills/lisa-tdd-implementation/SKILL.md +1 -0
  31. package/plugins/lisa/commands/design/intake.md +7 -0
  32. package/plugins/lisa/rules/eager/design-value-binding.md +80 -0
  33. package/plugins/lisa/rules/reference/design-value-binding.md +204 -0
  34. package/plugins/lisa/scripts/design-bindings-probe.mjs +713 -0
  35. package/plugins/lisa/scripts/design-intake-gate.mjs +470 -0
  36. package/plugins/lisa/scripts/design-variable-ids.mjs +565 -0
  37. package/plugins/lisa/skills/lisa-design-intake/SKILL.md +169 -0
  38. package/plugins/lisa/skills/lisa-design-intake/agents/openai.yaml +4 -0
  39. package/plugins/lisa/skills/lisa-implement/SKILL.md +2 -0
  40. package/plugins/lisa/skills/lisa-quality-review/SKILL.md +2 -0
  41. package/plugins/lisa/skills/lisa-review-local/SKILL.md +9 -0
  42. package/plugins/lisa/skills/lisa-tdd-implementation/SKILL.md +1 -0
  43. package/plugins/lisa-agy/commands/lisa/design/intake.md +7 -0
  44. package/plugins/lisa-agy/plugin.json +1 -1
  45. package/plugins/lisa-agy/scripts/design-bindings-probe.mjs +713 -0
  46. package/plugins/lisa-agy/scripts/design-intake-gate.mjs +470 -0
  47. package/plugins/lisa-agy/scripts/design-variable-ids.mjs +565 -0
  48. package/plugins/lisa-agy/skills/lisa-design-intake/SKILL.md +169 -0
  49. package/plugins/lisa-agy/skills/lisa-implement/SKILL.md +2 -0
  50. package/plugins/lisa-agy/skills/lisa-quality-review/SKILL.md +2 -0
  51. package/plugins/lisa-agy/skills/lisa-review-local/SKILL.md +9 -0
  52. package/plugins/lisa-agy/skills/lisa-tdd-implementation/SKILL.md +1 -0
  53. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  55. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  56. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-copilot/commands/lisa/design/intake.md +7 -0
  60. package/plugins/lisa-copilot/rules/eager/design-value-binding.md +80 -0
  61. package/plugins/lisa-copilot/rules/reference/design-value-binding.md +204 -0
  62. package/plugins/lisa-copilot/scripts/design-bindings-probe.mjs +713 -0
  63. package/plugins/lisa-copilot/scripts/design-intake-gate.mjs +470 -0
  64. package/plugins/lisa-copilot/scripts/design-variable-ids.mjs +565 -0
  65. package/plugins/lisa-copilot/skills/lisa-design-intake/SKILL.md +169 -0
  66. package/plugins/lisa-copilot/skills/lisa-implement/SKILL.md +2 -0
  67. package/plugins/lisa-copilot/skills/lisa-quality-review/SKILL.md +2 -0
  68. package/plugins/lisa-copilot/skills/lisa-review-local/SKILL.md +9 -0
  69. package/plugins/lisa-copilot/skills/lisa-tdd-implementation/SKILL.md +1 -0
  70. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-cursor/commands/lisa/design/intake.md +7 -0
  72. package/plugins/lisa-cursor/rules/design-value-binding-reference.mdc +209 -0
  73. package/plugins/lisa-cursor/rules/design-value-binding.mdc +85 -0
  74. package/plugins/lisa-cursor/scripts/design-bindings-probe.mjs +713 -0
  75. package/plugins/lisa-cursor/scripts/design-intake-gate.mjs +470 -0
  76. package/plugins/lisa-cursor/scripts/design-variable-ids.mjs +565 -0
  77. package/plugins/lisa-cursor/skills/lisa-design-intake/SKILL.md +169 -0
  78. package/plugins/lisa-cursor/skills/lisa-implement/SKILL.md +2 -0
  79. package/plugins/lisa-cursor/skills/lisa-quality-review/SKILL.md +2 -0
  80. package/plugins/lisa-cursor/skills/lisa-review-local/SKILL.md +9 -0
  81. package/plugins/lisa-cursor/skills/lisa-tdd-implementation/SKILL.md +1 -0
  82. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  84. package/plugins/lisa-expo-agy/plugin.json +1 -1
  85. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  89. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  90. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  94. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  95. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  99. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  100. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  103. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  104. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  105. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  106. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  107. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  108. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  109. package/plugins/lisa-rails-agy/plugin.json +1 -1
  110. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  111. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  112. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  113. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  114. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  115. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  116. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  117. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  118. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  119. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  120. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  121. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  122. package/plugins/src/base/commands/design/intake.md +7 -0
  123. package/plugins/src/base/rules/eager/design-value-binding.md +80 -0
  124. package/plugins/src/base/rules/reference/design-value-binding.md +204 -0
  125. package/plugins/src/base/scripts/design-bindings-probe.mjs +713 -0
  126. package/plugins/src/base/scripts/design-intake-gate.mjs +470 -0
  127. package/plugins/src/base/scripts/design-variable-ids.mjs +565 -0
  128. package/plugins/src/base/skills/lisa-design-intake/SKILL.md +169 -0
  129. package/plugins/src/base/skills/lisa-implement/SKILL.md +2 -0
  130. package/plugins/src/base/skills/lisa-quality-review/SKILL.md +2 -0
  131. package/plugins/src/base/skills/lisa-review-local/SKILL.md +9 -0
  132. package/plugins/src/base/skills/lisa-tdd-implementation/SKILL.md +1 -0
  133. package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +22 -6
  134. 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
- .replace(/^\.\/+/, "")
41
- .replace(/\/+$/, "")
42
- .replace(/^\.$/, "");
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
- const featureMatch = /^Feature:\s*(.+)$/.exec(trimmed);
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*(.+)$/.exec(
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
- const item = line.match(/^\s+-\s*(.+?)\s*$/u);
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
  }