@multiplatform.one/config 7.4.0 → 7.5.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/lint.mjs ADDED
@@ -0,0 +1,778 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Structural convention checks that oxlint cannot express as file lints.
4
+ *
5
+ * 1. Fail on `.css` files under apps/* (vendor/bench trees ignored)
6
+ * 2. Fail if apps/<name> lacks features/<name> (tooling shells exempt)
7
+ * 3. Fail on SILENT NO-OP TOKENS — a string literal handed to a design-system
8
+ * prop that names nothing in the corresponding registry. Tamagui does not
9
+ * type-check these (the workspace only augments `TamaguiCustomConfig` with
10
+ * shorthands, so theme/token names stay open strings) and it does not throw
11
+ * or warn at the call site either: `getTokenForKey` returns `undefined` and
12
+ * the prop is dropped, so the styling simply never happens. Each check
13
+ * reads its vocabulary from the registry's own source so it cannot drift.
14
+ * 4. Fail on SIZE-RECIPE ESCAPE (LC-65) — JSX numeric literals that set
15
+ * control chrome by hand (`height={N}`, `minHeight={N}`,
16
+ * `paddingHorizontal={N}`, `fontSize={N}`). Identifiers
17
+ * (`MIN_PRESS_TARGET`, `pressTargetHitSlop`, `recipe.height`,
18
+ * `knobProps.control.height`) are fine. Escape with `sizeRecipeEscape` or
19
+ * `size-recipe-escape:` on the previous line or same statement. Pilot:
20
+ * Button/, InputParts/, fields/Select/ only.
21
+ *
22
+ * Usage:
23
+ * import { runConventionChecks } from "@multiplatform.one/config/lint";
24
+ * runConventionChecks({ roots: { root, apps, features, public } });
25
+ *
26
+ * CLI: node lint.mjs (roots default to process.cwd())
27
+ * Exit 1 on any violation.
28
+ */
29
+
30
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
31
+ import { createRequire } from "node:module";
32
+ import { join, relative } from "node:path";
33
+ import { pathToFileURL } from "node:url";
34
+
35
+ /**
36
+ * @typedef {{ root?: string, apps?: string, features?: string, public?: string }} ConventionRoots
37
+ * @typedef {{ roots?: ConventionRoots }} RunConventionChecksOptions
38
+ */
39
+
40
+ /** @type {string} */
41
+ let ROOT = process.cwd();
42
+ /** @type {string} */
43
+ let APPS_DIR = join(ROOT, "apps");
44
+ /** @type {string} */
45
+ let FEATURES_DIR = join(ROOT, "features");
46
+ /** @type {string} */
47
+ let PUBLIC_DIR = join(ROOT, "public");
48
+
49
+ /**
50
+ * @param {ConventionRoots} [roots]
51
+ */
52
+ export function resolveRoots(roots = {}) {
53
+ const root = roots.root ?? process.cwd();
54
+ return {
55
+ root,
56
+ apps: roots.apps ?? join(root, "apps"),
57
+ features: roots.features ?? join(root, "features"),
58
+ public: roots.public ?? join(root, "public"),
59
+ };
60
+ }
61
+
62
+ /**
63
+ * @param {ConventionRoots} [roots]
64
+ */
65
+ function applyRoots(roots) {
66
+ const resolved = resolveRoots(roots);
67
+ ROOT = resolved.root;
68
+ APPS_DIR = resolved.apps;
69
+ FEATURES_DIR = resolved.features;
70
+ PUBLIC_DIR = resolved.public;
71
+ sourceFileCache = undefined;
72
+ requireFromRoot = createRequire(join(ROOT, "package.json"));
73
+ }
74
+
75
+ /** Shared platform / docs / chain shells — not product apps from
76
+ * mpo init --app. Extension targets (vscode, webext) are no longer shells:
77
+ * they live INSIDE the product app (apps/<name>/vscode, apps/<name>/webext)
78
+ * alongside the gnome/tauri targets. */
79
+ const FEATURES_TWIN_EXEMPT = new Set([
80
+ "frappe",
81
+ "keycloak",
82
+ "storybook",
83
+ "storybook-expo",
84
+ "uxpin",
85
+ "vocs",
86
+ "ethereum",
87
+ "solana",
88
+ "sui",
89
+ ]);
90
+
91
+ const CSS_IGNORE_DIR_NAMES = new Set([
92
+ "node_modules",
93
+ "dist",
94
+ ".dist",
95
+ ".tamagui",
96
+ "build",
97
+ "coverage",
98
+ "env", // frappe bench python env
99
+ "sites",
100
+ "logs",
101
+ "storybook-static", // storybook build output
102
+ "playwright-report", // playwright html report (regenerated per e2e run)
103
+ "test-results", // playwright traces/attachments
104
+ ]);
105
+
106
+ /** Build-output dirs are prefix-matched so variants like dist-spa-e2e are covered. */
107
+ const CSS_IGNORE_DIR_PREFIXES = ["dist-", "dist_"];
108
+
109
+ /** Path prefixes under apps/ that are never scanned for .css */
110
+ const CSS_IGNORE_PREFIXES = [
111
+ join("frappe", "apps"), // upstream frappe apps tree
112
+ join("frappe", "env"),
113
+ join("frappe", "sites"),
114
+ // Keycloakify vendored theme resources (not app-authored styles)
115
+ join("keycloak", "public", "keycloakify-dev-resources"),
116
+ // Platform-forced: vscode webviews rewrite <link href> to vscode-resource
117
+ // URIs and url() resolves relative to the emitted stylesheet — inline
118
+ // <style> would resolve against vscode-webview:// instead (LC-33 fonts).
119
+ join("one", "vscode", "webview", "fonts.css"),
120
+ ];
121
+
122
+ /** Generated Tamagui CSS extract filenames (not hand-authored app styles). */
123
+ const GENERATED_CSS_NAMES = new Set(["tamagui.css", "tamagui.content.css"]);
124
+
125
+ /**
126
+ * @param {string} dir
127
+ * @param {(filePath: string) => void} onFile
128
+ */
129
+ function walkFiles(dir, onFile) {
130
+ if (!existsSync(dir)) return;
131
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
132
+ if (entry.name.startsWith(".") && entry.name !== ".storybook") {
133
+ // skip VCS / cache dirs; allow .storybook
134
+ if (entry.isDirectory()) continue;
135
+ }
136
+ if (CSS_IGNORE_DIR_NAMES.has(entry.name)) continue;
137
+ if (
138
+ entry.isDirectory() &&
139
+ CSS_IGNORE_DIR_PREFIXES.some((prefix) => entry.name.startsWith(prefix))
140
+ ) {
141
+ continue;
142
+ }
143
+ const full = join(dir, entry.name);
144
+ if (entry.isDirectory()) {
145
+ walkFiles(full, onFile);
146
+ } else if (entry.isFile()) {
147
+ onFile(full);
148
+ }
149
+ }
150
+ }
151
+
152
+ function isCssIgnored(absPath) {
153
+ const rel = relative(APPS_DIR, absPath);
154
+ if (CSS_IGNORE_PREFIXES.some((prefix) => rel === prefix || rel.startsWith(prefix + "/"))) {
155
+ return true;
156
+ }
157
+ const base = rel.split(/[/\\]/).pop() ?? "";
158
+ return GENERATED_CSS_NAMES.has(base);
159
+ }
160
+
161
+ function listAppNames() {
162
+ if (!existsSync(APPS_DIR)) return [];
163
+ return readdirSync(APPS_DIR, { withFileTypes: true })
164
+ .filter((d) => d.isDirectory() && !d.name.startsWith(".") && d.name !== "node_modules")
165
+ .map((d) => d.name)
166
+ .sort();
167
+ }
168
+
169
+ function checkCssInApps() {
170
+ /** @type {string[]} */
171
+ const violations = [];
172
+ if (!existsSync(APPS_DIR)) return violations;
173
+
174
+ walkFiles(APPS_DIR, (filePath) => {
175
+ if (!filePath.endsWith(".css")) return;
176
+ if (isCssIgnored(filePath)) return;
177
+ violations.push(relative(ROOT, filePath));
178
+ });
179
+ return violations;
180
+ }
181
+
182
+ // ── Silent no-op token registries ─────────────────────────────────────────
183
+
184
+ /** Bound to the consumer root so SHC/Lookout resolve their own Tamagui copies. */
185
+ let requireFromRoot = createRequire(join(process.cwd(), "package.json"));
186
+
187
+ /**
188
+ * Resolve a registry package. A package that cannot be resolved disables its
189
+ * check (reported by `runConventionChecks`) instead of crashing the run — a
190
+ * fresh clone with a partial install must still get the structural checks.
191
+ * @param {string} name
192
+ */
193
+ function tryRequire(name) {
194
+ try {
195
+ return requireFromRoot(name);
196
+ } catch {
197
+ return undefined;
198
+ }
199
+ }
200
+
201
+ /** @param {string} relPath */
202
+ function readPublicSource(relPath) {
203
+ const full = join(PUBLIC_DIR, relPath);
204
+ return existsSync(full) ? readFileSync(full, "utf8") : "";
205
+ }
206
+
207
+ /**
208
+ * Body of an object/interface literal that starts at `marker`, from its first
209
+ * `{` to the first line-anchored closing brace. Used to read a registry's keys
210
+ * out of the source that declares them.
211
+ * @param {string} src
212
+ * @param {string} marker
213
+ * @param {string} end
214
+ */
215
+ function sliceBlock(src, marker, end) {
216
+ const start = src.indexOf(marker);
217
+ if (start === -1) return "";
218
+ const open = src.indexOf("{", start);
219
+ if (open === -1) return "";
220
+ const close = src.indexOf(end, open);
221
+ return close === -1 ? "" : src.slice(open, close);
222
+ }
223
+
224
+ /**
225
+ * Top-level keys declared in an object/interface literal body. Numeric keys
226
+ * count — scale registries like the font-size table are keyed `1:`…`16:`.
227
+ * @param {string} body
228
+ * @param {number} indent
229
+ */
230
+ function keysOfBlock(body, indent) {
231
+ /** @type {string[]} */
232
+ const keys = [];
233
+ const re = new RegExp(`^ {${indent}}([A-Za-z0-9][A-Za-z0-9]*)\\s*:`, "gm");
234
+ for (const m of body.matchAll(re)) keys.push(m[1]);
235
+ return keys;
236
+ }
237
+
238
+ /** Source files every token check scans, read once and shared. */
239
+ let sourceFileCache;
240
+ function sourceFiles() {
241
+ if (sourceFileCache) return sourceFileCache;
242
+ /** @type {{ rel: string, src: string }[]} */
243
+ const files = [];
244
+ const collect = (filePath) => {
245
+ if (!/\.(tsx|ts|jsx|js)$/.test(filePath)) return;
246
+ if (/\.(spec|test)\./.test(filePath)) return;
247
+ files.push({ rel: relative(ROOT, filePath), src: readFileSync(filePath, "utf8") });
248
+ };
249
+ for (const dir of [PUBLIC_DIR, APPS_DIR, FEATURES_DIR]) walkFiles(dir, collect);
250
+ sourceFileCache = files;
251
+ return files;
252
+ }
253
+
254
+ /**
255
+ * 1-based line of a match offset.
256
+ * @param {string} src
257
+ * @param {number} index
258
+ */
259
+ function lineAt(src, index) {
260
+ let line = 1;
261
+ for (let i = 0; i < index; i++) if (src.charCodeAt(i) === 10) line += 1;
262
+ return line;
263
+ }
264
+
265
+ /**
266
+ * Run one prop-literal regex over every scanned file.
267
+ * @param {RegExp} re
268
+ * @param {(match: RegExpMatchArray) => string | undefined} check returns the violation text, or undefined when valid
269
+ */
270
+ function scanLiterals(re, check) {
271
+ /** @type {string[]} */
272
+ const violations = [];
273
+ for (const { rel, src } of sourceFiles()) {
274
+ for (const m of src.matchAll(re)) {
275
+ const message = check(m);
276
+ if (message) violations.push(`${rel}:${lineAt(src, m.index ?? 0)} — ${message}`);
277
+ }
278
+ }
279
+ return violations;
280
+ }
281
+
282
+ /**
283
+ * The animation vocabulary is a CLOSED set (css.ts / reactNative.ts register
284
+ * exactly these keys). A `transition="X"` with an X outside the set is not a
285
+ * type error — Tamagui silently ignores the unknown animation name, so the
286
+ * element renders with NO animation. That is invisible until someone notices
287
+ * "this doesn't animate" (e.g. DatePicker's `transition="quicker"` typo, which
288
+ * killed the selected-date enter animation across all five pickers). Read the
289
+ * registry from source so this check can never drift from the tokens.
290
+ * @returns {Set<string>}
291
+ */
292
+ function readAnimationTokens() {
293
+ const tokens = new Set(["none"]); // valid non-animating value
294
+ const src = readPublicSource("theme/src/theme/animations/css.ts");
295
+ if (!src) return tokens;
296
+ for (const key of keysOfBlock(sliceBlock(src, "animationConfig =", "} as const"), 2)) {
297
+ tokens.add(key);
298
+ }
299
+ return tokens;
300
+ }
301
+
302
+ /** Flag `transition="literal"` / `transition={"literal"}` using an unregistered token. */
303
+ function checkTransitionTokens() {
304
+ const valid = readAnimationTokens();
305
+ // Only string LITERALS directly on the prop — dynamic values (variables,
306
+ // ternaries, knobProps.transition) resolve at runtime and are out of scope.
307
+ const re = /\btransition=(?:"([a-zA-Z0-9]+)"|\{\s*"([a-zA-Z0-9]+)"\s*\})/g;
308
+ const violations = scanLiterals(re, (m) => {
309
+ const token = m[1] ?? m[2];
310
+ return valid.has(token) ? undefined : `transition="${token}"`;
311
+ });
312
+ return { violations, valid: [...valid].filter((t) => t !== "none").sort() };
313
+ }
314
+
315
+ /**
316
+ * Every registered Tamagui theme name, plus each sub-theme SEGMENT a `theme`
317
+ * prop may name on its own (`theme="accent"` selects `light_accent` under the
318
+ * light scheme). Rebuilt from the same three sources `createThemesBuilder`
319
+ * composes: the theme-builder's generated names for this base/children/accent
320
+ * shape, the stock `@tamagui/themes` names, and the gray aliases declared in
321
+ * createThemes.ts. Palettes are irrelevant here — only the NAMES are.
322
+ * @returns {Set<string>}
323
+ */
324
+ function readThemeNames() {
325
+ /** @type {Set<string>} */
326
+ const names = new Set();
327
+ const builder = tryRequire("@tamagui/theme-builder");
328
+ const stock = tryRequire("@tamagui/themes");
329
+ const src = readPublicSource("theme/src/theme/createThemes.ts");
330
+ if (!builder?.createThemes || !stock?.themes || !src) return names;
331
+ const ramp = Array.from({ length: 14 }, (_, i) => `hsl(0, 0%, ${i * 7}%)`);
332
+ const palette = { palette: { dark: ramp, light: ramp } };
333
+ /** @type {Record<string, unknown>} */
334
+ const childrenThemes = {};
335
+ for (const key of keysOfBlock(sliceBlock(src, "const childrenThemes =", "\n };"), 4)) {
336
+ childrenThemes[key] = palette;
337
+ }
338
+ try {
339
+ for (const name of Object.keys(
340
+ builder.createThemes({ base: palette, childrenThemes, accent: palette }),
341
+ )) {
342
+ names.add(name);
343
+ }
344
+ } catch {
345
+ return new Set();
346
+ }
347
+ for (const name of Object.keys(stock.themes)) names.add(name);
348
+ for (const m of sliceBlock(src, "const grayTheme =", "\n};").matchAll(
349
+ /^ {2}([a-zA-Z][a-zA-Z0-9_]*)\s*:/gm,
350
+ )) {
351
+ names.add(m[1]);
352
+ }
353
+ /** @type {string[]} */
354
+ const segments = [];
355
+ for (const name of names) for (const part of name.split("_").slice(1)) segments.push(part);
356
+ for (const segment of segments) names.add(segment);
357
+ return names;
358
+ }
359
+
360
+ /**
361
+ * Every key resolvable as `$value` on a color-bearing prop, composed exactly
362
+ * the way `createThemesBuilder` composes the live themes: the structural keys
363
+ * the theme-builder generates (colorN / background0N / accentN / …), the stock
364
+ * base themes, the Radix ramps createThemes.ts splats into `extra`, the shadow
365
+ * slots, and the project's semantic aliases from `getBaseTheme`.
366
+ * @returns {Set<string>}
367
+ */
368
+ function readThemeKeys() {
369
+ /** @type {Set<string>} */
370
+ const keys = new Set();
371
+ const builder = tryRequire("@tamagui/theme-builder");
372
+ const stock = tryRequire("@tamagui/themes");
373
+ const colors = tryRequire("@tamagui/colors");
374
+ const src = readPublicSource("theme/src/theme/createThemes.ts");
375
+ const builderSrc = readPublicSource("theme/src/theme/defaults/builderOptions.ts");
376
+ if (!builder?.createThemes || !stock?.themes || !colors || !src || !builderSrc) return keys;
377
+ const ramp = Array.from({ length: 14 }, (_, i) => `hsl(0, 0%, ${i * 7}%)`);
378
+ const palette = { palette: { dark: ramp, light: ramp } };
379
+ try {
380
+ const generated = builder.createThemes({
381
+ base: palette,
382
+ childrenThemes: { warning: palette },
383
+ accent: palette,
384
+ });
385
+ for (const theme of Object.values(generated)) for (const k of Object.keys(theme)) keys.add(k);
386
+ } catch {
387
+ return new Set();
388
+ }
389
+ for (const name of ["light", "dark"]) {
390
+ for (const k of Object.keys(stock.themes[name] ?? {})) keys.add(k);
391
+ }
392
+ for (const m of src.matchAll(/\.\.\.Colors\.([A-Za-z0-9]+)\s*,/g)) {
393
+ for (const k of Object.keys(colors[m[1]] ?? {})) keys.add(k);
394
+ }
395
+ for (const k of keysOfBlock(sliceBlock(src, "export interface Shadows", "\n}"), 2)) keys.add(k);
396
+ keys.add("shadowColor");
397
+ for (const k of keysOfBlock(sliceBlock(builderSrc, "function getBaseTheme", "\n}"), 4))
398
+ keys.add(k);
399
+ return keys;
400
+ }
401
+
402
+ /** Font-size steps the house fonts register (`defaults/fonts.ts`). */
403
+ function readFontSizeSteps() {
404
+ /** @type {Set<string>} */
405
+ const steps = new Set();
406
+ const src = readPublicSource("theme/src/theme/defaults/fonts.ts");
407
+ if (!src) return steps;
408
+ for (const k of keysOfBlock(sliceBlock(src, "const interBaseSizes", "\n};"), 2)) steps.add(k);
409
+ return steps;
410
+ }
411
+
412
+ /**
413
+ * Flag a `$token` style literal that resolves to NOTHING.
414
+ *
415
+ * `getTokenForKey` looks a `$value` up in the active theme, then in the token
416
+ * scale its prop belongs to (`tokenCategories`), then in `tokens.space` as a
417
+ * universal fallback — and when all three miss it returns `undefined`, so the
418
+ * prop is dropped from the style entirely. The only signal is a one-time,
419
+ * collapsed dev console group that names neither the file nor the component
420
+ * and which Tamagui itself documents as "sometimes harmless", so in practice
421
+ * an unregistered token is invisible (e.g. `color="$colorSubtle"` in
422
+ * GeolocationMap.native, a token no theme ever defined).
423
+ *
424
+ * Font props are the one exception to the space fallback: `fontSize` resolves
425
+ * against the active font's own size scale and, on a miss, passes the RAW
426
+ * "$n" string through as a CSS length — equally silent, equally dead.
427
+ */
428
+ function checkStyleTokens() {
429
+ const helpers = tryRequire("@tamagui/helpers");
430
+ const themesPkg = tryRequire("@tamagui/themes");
431
+ const shorthandsPkg = tryRequire("@tamagui/shorthands");
432
+ const themeKeys = readThemeKeys();
433
+ const fontSizeSteps = readFontSizeSteps();
434
+ if (!helpers?.tokenCategories || !themesPkg?.tokens || !themeKeys.size || !fontSizeSteps.size) {
435
+ return { violations: [], ok: false };
436
+ }
437
+ const tokens = themesPkg.tokens;
438
+ const spaceScale = new Set(Object.keys(tokens.space ?? {}));
439
+ /** @type {Map<string, Set<string>>} prop → every `$value` that resolves */
440
+ const byProp = new Map();
441
+ // Props with a scale of their own (radius / size / zIndex / color).
442
+ for (const [category, props] of Object.entries(helpers.tokenCategories)) {
443
+ const resolvable = new Set([
444
+ ...themeKeys,
445
+ ...Object.keys(tokens[category] ?? {}),
446
+ ...spaceScale,
447
+ ]);
448
+ for (const prop of Object.keys(props)) byProp.set(prop, resolvable);
449
+ }
450
+ // Every other view style prop lands on the space fallback (padding, margin,
451
+ // gap, inset, border widths, …) — the biggest surface by call-site count.
452
+ const spaceResolvable = new Set([...themeKeys, ...spaceScale]);
453
+ for (const prop of Object.keys(helpers.stylePropsAll ?? {})) {
454
+ if (byProp.has(prop)) continue;
455
+ if (helpers.stylePropsTextOnly?.[prop]) continue;
456
+ if (helpers.stylePropsTransform?.[prop]) continue;
457
+ if (helpers.stylePropsUnitless?.[prop]) continue;
458
+ byProp.set(prop, spaceResolvable);
459
+ }
460
+ byProp.set("fontSize", new Set([...themeKeys, ...fontSizeSteps]));
461
+ // Shorthands are the same prop by another name (`bg`, `br`, `fos`, …).
462
+ for (const [short, long] of Object.entries(shorthandsPkg?.shorthands ?? {})) {
463
+ const resolvable = byProp.get(long);
464
+ if (resolvable) byProp.set(short, resolvable);
465
+ }
466
+ const props = [...byProp.keys()].sort((a, b) => b.length - a.length).join("|");
467
+ const re = new RegExp(
468
+ `\\b(${props})=(?:"\\$([A-Za-z0-9]+)"|\\{\\s*"\\$([A-Za-z0-9]+)"\\s*\\})`,
469
+ "g",
470
+ );
471
+ const violations = scanLiterals(re, (m) => {
472
+ const token = m[2] ?? m[3];
473
+ return byProp.get(m[1])?.has(token) ? undefined : `${m[1]}="$${token}"`;
474
+ });
475
+ return { violations, ok: true };
476
+ }
477
+
478
+ /** Flag `theme="literal"` naming a theme that was never built. */
479
+ function checkThemeNames() {
480
+ const valid = readThemeNames();
481
+ if (!valid.size) return { violations: [], ok: false };
482
+ const re = /\btheme=(?:"([A-Za-z][A-Za-z0-9_]*)"|\{\s*"([A-Za-z][A-Za-z0-9_]*)"\s*\})/g;
483
+ const violations = scanLiterals(re, (m) => {
484
+ const name = m[1] ?? m[2];
485
+ return valid.has(name) ? undefined : `theme="${name}"`;
486
+ });
487
+ return { violations, ok: true };
488
+ }
489
+
490
+ /** Intent names registered in `defaultIntents`. */
491
+ function readIntentNames() {
492
+ /** @type {Set<string>} */
493
+ const names = new Set();
494
+ const src = readPublicSource("theme/src/theme/intents.ts");
495
+ if (!src) return names;
496
+ for (const m of sliceBlock(src, "export const defaultIntents", "\n};").matchAll(
497
+ /^ {2}([a-zA-Z][a-zA-Z0-9]*)\s*:\s*\{/gm,
498
+ )) {
499
+ names.add(m[1]);
500
+ }
501
+ return names;
502
+ }
503
+
504
+ /**
505
+ * Flag `useResolvedKnobs({ intent: "literal" })` naming an unregistered intent.
506
+ * `UseResolvedKnobsOptions.intent` is a plain `string` (component-level intent
507
+ * props are typed unions, this one is the open seam), and an unknown name just
508
+ * misses `defaultIntents[intent]` — no overrides merge, so the component keeps
509
+ * its base knobs and renders exactly as if no intent had been passed.
510
+ */
511
+ function checkIntentTokens() {
512
+ const valid = readIntentNames();
513
+ if (!valid.size) return { violations: [], valid: [], ok: false };
514
+ const call = /useResolvedKnobs\(/g;
515
+ const intentArg = /\bintent:\s*"([A-Za-z0-9]+)"/g;
516
+ /** @type {string[]} */
517
+ const violations = [];
518
+ for (const { rel, src } of sourceFiles()) {
519
+ for (const m of src.matchAll(call)) {
520
+ // Options object only — stop at the call's own closing paren.
521
+ let depth = 0;
522
+ let end = (m.index ?? 0) + m[0].length - 1;
523
+ for (; end < src.length; end++) {
524
+ const c = src[end];
525
+ if (c === "(") depth += 1;
526
+ else if (c === ")") {
527
+ depth -= 1;
528
+ if (depth === 0) break;
529
+ }
530
+ }
531
+ const args = src.slice((m.index ?? 0) + m[0].length, end);
532
+ for (const arg of args.matchAll(intentArg)) {
533
+ if (!valid.has(arg[1])) {
534
+ violations.push(`${rel}:${lineAt(src, m.index ?? 0)} — intent: "${arg[1]}"`);
535
+ }
536
+ }
537
+ }
538
+ }
539
+ return { violations, valid: [...valid].sort(), ok: true };
540
+ }
541
+
542
+ /**
543
+ * Two more silent-animation-death shapes, both found dead in the live sweep
544
+ * (`p-results/fix-animation-sweep.md`) and neither catchable by the token
545
+ * guard above, because the token is valid — it is the PROP that is wrong.
546
+ *
547
+ * 1. `animation` is the tamagui 1.x spelling. On 2.x the prop is `transition`
548
+ * and `animation` is NOT an alias, so it is dropped and the node sits at
549
+ * `transition-duration: 0s`. It hides in two spellings: the JSX attribute,
550
+ * and an `animation:` key inside a conditional spread.
551
+ * 2. LC-24: `enterStyle`/`exitStyle` with no transition driver on the same
552
+ * element is dead code — the styles are declared and never interpolated
553
+ * (raw tamagui Popover/Dialog Content bypassing the house wrappers).
554
+ */
555
+ function checkAnimationProps() {
556
+ /** @type {string[]} */
557
+ const legacyProp = [];
558
+ /** @type {string[]} */
559
+ const driverless = [];
560
+ const scan = (filePath) => {
561
+ if (!/\.(tsx|jsx)$/.test(filePath)) return;
562
+ if (/\.(spec|test|stories)\./.test(filePath)) return;
563
+ const src = readFileSync(filePath, "utf8");
564
+ const rel = relative(ROOT, filePath);
565
+ // Any `…transition`-valued animation key, so prefixed spellings like
566
+ // `ctx.knobProps.transition` are caught too. Deliberately NOT "any value":
567
+ // that also matches TS annotations (`animation: any`), css-in-js, and
568
+ // unrelated config objects that have nothing to do with the tamagui prop.
569
+ for (const m of src.matchAll(/\banimation=\{|\banimation:\s*[\w$.[\]?]*\btransition\b/g)) {
570
+ const line = src.slice(0, m.index).split("\n").length;
571
+ legacyProp.push(`${rel}:${line} — \`${m[0]}\` (tamagui 1.x prop; use \`transition\`)`);
572
+ }
573
+ // Element-scoped: an enter/exitStyle needs a transition on the SAME tag.
574
+ // `=>` is allowed through: a `>` inside an arrow-function prop otherwise
575
+ // truncates the tag and hides everything after it from this check.
576
+ for (const tag of src.matchAll(/<[A-Z][\w.]*(?:\s(?:=>|[^>])*)?>/gs)) {
577
+ const t = tag[0];
578
+ if (!/\b(enterStyle|exitStyle)=/.test(t)) continue;
579
+ if (/\btransition[=:]/.test(t)) continue;
580
+ const line = src.slice(0, tag.index).split("\n").length;
581
+ driverless.push(`${rel}:${line} — enter/exitStyle with no \`transition\` driver (LC-24)`);
582
+ }
583
+ };
584
+ for (const dir of [PUBLIC_DIR, APPS_DIR, FEATURES_DIR]) walkFiles(dir, scan);
585
+ return { legacyProp, driverless };
586
+ }
587
+
588
+ function checkFeaturesTwins() {
589
+ /** @type {string[]} */
590
+ const violations = [];
591
+ for (const name of listAppNames()) {
592
+ if (FEATURES_TWIN_EXEMPT.has(name)) continue;
593
+ const twin = join(FEATURES_DIR, name);
594
+ if (!existsSync(twin) || !statSync(twin).isDirectory()) {
595
+ violations.push(`apps/${name} lacks features/${name} (app ↔ features twin required)`);
596
+ }
597
+ }
598
+ return violations;
599
+ }
600
+
601
+ /**
602
+ * LC-65 SIZE-RECIPE ESCAPE — numeric JSX literals that paint control chrome
603
+ * by hand, bypassing the size recipe. Pilot is narrow on purpose: only
604
+ * Button/, InputParts/, and fields/Select/ under public/forms/src. Widen
605
+ * the prefix list when the rest of the catalog migrates.
606
+ *
607
+ * Flagged: height={N} / minHeight={N} / paddingHorizontal={N} / fontSize={N}
608
+ * where N is a number. Token strings (`"$4"`) and identifiers
609
+ * (`MIN_PRESS_TARGET`, `recipe.height`, `knobProps.control.height`) do not
610
+ * match. Escape: `sizeRecipeEscape` or `size-recipe-escape:` on the previous
611
+ * line or in the same statement (the prop, including a multiline `{…}`).
612
+ */
613
+ function checkSizeRecipeEscape() {
614
+ const formsSrc = join(PUBLIC_DIR, "forms", "src");
615
+ const pilotPrefixes = [
616
+ "public/forms/src/Button/",
617
+ "public/forms/src/InputParts/",
618
+ "public/forms/src/fields/Select/",
619
+ // Migrated onto generated recipe families 2026-08-14 (catalog wave):
620
+ "public/forms/src/Chip/",
621
+ "public/forms/src/Skeleton/",
622
+ "public/forms/src/fields/PhoneInput/",
623
+ "public/forms/src/fields/SearchInput/",
624
+ "public/forms/src/fields/Slider/",
625
+ "public/forms/src/fields/Stepper/",
626
+ "public/forms/src/fields/Switch/",
627
+ ];
628
+ const re = /\b(height|minHeight|paddingHorizontal|fontSize)=\{\s*(-?\d+(?:\.\d+)?)\s*\}/g;
629
+ /** @type {string[]} */
630
+ const violations = [];
631
+ walkFiles(formsSrc, (filePath) => {
632
+ if (!/\.(tsx|ts)$/.test(filePath)) return;
633
+ const rel = relative(ROOT, filePath);
634
+ if (/\.(spec|stories)\./.test(rel)) return;
635
+ if (rel.includes("__snapshots__")) return;
636
+ if (!pilotPrefixes.some((prefix) => rel.startsWith(prefix))) return;
637
+ const src = readFileSync(filePath, "utf8");
638
+ const lines = src.split("\n");
639
+ for (const m of src.matchAll(re)) {
640
+ const line = lineAt(src, m.index ?? 0);
641
+ const current = lines[line - 1] ?? "";
642
+ const trimmed = current.trim();
643
+ if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) {
644
+ continue;
645
+ }
646
+ const previous = lines[line - 2] ?? "";
647
+ const window = `${previous}\n${current}\n${m[0]}`;
648
+ if (/sizeRecipeEscape|size-recipe-escape:/.test(window)) continue;
649
+ violations.push(`${rel}:${line} — ${m[1]}={${m[2]}}`);
650
+ }
651
+ });
652
+ return violations;
653
+ }
654
+
655
+ /**
656
+ * Run structural convention checks over a consumer tree.
657
+ *
658
+ * @param {RunConventionChecksOptions} [options]
659
+ * @returns {number} 0 when clean, 1 when any check failed
660
+ */
661
+ export function runConventionChecks(options = {}) {
662
+ applyRoots(options.roots);
663
+ const css = checkCssInApps();
664
+ const twins = checkFeaturesTwins();
665
+ const sizeRecipeEscape = checkSizeRecipeEscape();
666
+ const tokens = checkTransitionTokens();
667
+ const styleTokens = checkStyleTokens();
668
+ const themeNames = checkThemeNames();
669
+ const intents = checkIntentTokens();
670
+ const animProps = checkAnimationProps();
671
+ let failed = false;
672
+
673
+ if (animProps.legacyProp.length) {
674
+ failed = true;
675
+ console.error(
676
+ "Convention: `animation` is the tamagui 1.x prop — on 2.x it is silently dropped (no animation at all). Use `transition`.\n",
677
+ );
678
+ for (const msg of animProps.legacyProp) console.error(` ${msg}`);
679
+ console.error("");
680
+ }
681
+
682
+ if (animProps.driverless.length) {
683
+ failed = true;
684
+ console.error(
685
+ "Convention (LC-24): enterStyle/exitStyle without a `transition` on the same element never interpolates — the styles are dead code.\n",
686
+ );
687
+ for (const msg of animProps.driverless) console.error(` ${msg}`);
688
+ console.error("");
689
+ }
690
+
691
+ if (css.length) {
692
+ failed = true;
693
+ console.error("Convention: .css files are banned under apps/*\n");
694
+ for (const file of css) console.error(` ${file}`);
695
+ console.error("");
696
+ }
697
+
698
+ if (twins.length) {
699
+ failed = true;
700
+ console.error("Convention: each product app must have a features/<name> twin\n");
701
+ for (const msg of twins) console.error(` ${msg}`);
702
+ console.error("");
703
+ }
704
+
705
+ if (sizeRecipeEscape.length) {
706
+ failed = true;
707
+ console.error(
708
+ "Convention (LC-65 SIZE-RECIPE ESCAPE): control chrome (height / minHeight / paddingHorizontal / fontSize) must come from the size recipe, not a numeric literal. Escape with `sizeRecipeEscape` or `// size-recipe-escape:` on the previous line or same statement. Pilot: Button/, InputParts/, fields/Select/.\n",
709
+ );
710
+ for (const msg of sizeRecipeEscape) console.error(` ${msg}`);
711
+ console.error("");
712
+ }
713
+
714
+ if (tokens.violations.length) {
715
+ failed = true;
716
+ console.error(
717
+ `Convention: transition="…" must use a registered animation token (${tokens.valid.join(", ")}, or "none"). An unknown token silently disables the animation.\n`,
718
+ );
719
+ for (const msg of tokens.violations) console.error(` ${msg}`);
720
+ console.error("");
721
+ }
722
+
723
+ if (styleTokens.violations.length) {
724
+ failed = true;
725
+ console.error(
726
+ "Convention: a `$token` style literal must resolve — as a theme key ($color11, $borderColor, $accentBackground, a Radix ramp step), as a token in the prop's own scale (radius/size/zIndex/color), or as a space token. None of those matched, so Tamagui drops the prop and the style never applies.\n",
727
+ );
728
+ for (const msg of styleTokens.violations) console.error(` ${msg}`);
729
+ console.error("");
730
+ }
731
+
732
+ if (themeNames.violations.length) {
733
+ failed = true;
734
+ console.error(
735
+ 'Convention: theme="…" must name a built theme or sub-theme (a scheme like light/dark, a hue like blue/red/green, a semantic child like error/warning/success, accent, or a state like active/alt1/alt2). An unbuilt name silently inherits the PARENT theme, so the element renders untinted.\n',
736
+ );
737
+ for (const msg of themeNames.violations) console.error(` ${msg}`);
738
+ console.error("");
739
+ }
740
+
741
+ if (intents.violations.length) {
742
+ failed = true;
743
+ console.error(
744
+ `Convention: useResolvedKnobs({ intent }) must name a registered intent (${intents.valid.join(", ")}). An unknown name merges no overrides, so the component silently renders with its base knobs.\n`,
745
+ );
746
+ for (const msg of intents.violations) console.error(` ${msg}`);
747
+ console.error("");
748
+ }
749
+
750
+ for (const [name, ok] of [
751
+ ["style token", styleTokens.ok],
752
+ ["theme name", themeNames.ok],
753
+ ["intent", intents.ok],
754
+ ]) {
755
+ if (!ok) {
756
+ console.warn(
757
+ `lint-conventions: skipped the ${name} check — its registry could not be read (incomplete install?).`,
758
+ );
759
+ }
760
+ }
761
+
762
+ if (failed) {
763
+ console.error(
764
+ "See AGENTS.md (package boundaries / import rules). Structural checks: @multiplatform.one/config/lint",
765
+ );
766
+ return 1;
767
+ }
768
+
769
+ console.log("lint-conventions: ok");
770
+ return 0;
771
+ }
772
+
773
+ const invokedDirectly =
774
+ typeof process.argv[1] === "string" && import.meta.url === pathToFileURL(process.argv[1]).href;
775
+
776
+ if (invokedDirectly) {
777
+ process.exit(runConventionChecks());
778
+ }