@multiplatform.one/config 7.11.0 → 7.15.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 CHANGED
@@ -54,6 +54,22 @@
54
54
  * platform=web, i.e. an Expo DOM component). Node-only build tooling is
55
55
  * exempt by construction: a file that imports a `node:` builtin cannot run
56
56
  * in a browser or on Hermes anyway.
57
+ * 11. Fail on an UNDECLARED SIZE-RECIPE ESCAPE (MPO-23, `00` L8 E8) — a
58
+ * `sizeRecipeEscape` prop, key or call, or a `size-recipe-escape:` comment,
59
+ * whose file and reason are not a row in the spec's `## Size-recipe
60
+ * escapes` table, or a row whose site is gone.
61
+ * 12. Fail on an UNLABELED FOREIGN CONTROL SPECIMEN (LC-56 STORY-HONESTY,
62
+ * MPO-316) — a `*.stories.tsx` that value-imports a raw control primitive
63
+ * (`Input`, `Button`, `Checkbox`, …) from `tamagui` without carrying
64
+ * `parameters.foreignContrastSpecimen: true`. A raw primitive consumes no
65
+ * knobs, so a knob sweep that trusts the story scores the primitive and
66
+ * marks the component checked. Layout and text primitives (XStack,
67
+ * Paragraph, …) are out of scope by rule.
68
+ * 13. Fail on an UNDECLARED COLOUR-LITERAL ESCAPE (MPO-326) — a
69
+ * `hex-escape:` comment, which exempts its file from
70
+ * `mpo-conventions/no-hex-literals`, whose file and reason are not a row
71
+ * in the spec's `## Colour-literal escapes` table, or a row whose comment
72
+ * is gone.
57
73
  *
58
74
  * Usage:
59
75
  * import { runConventionChecks } from "@multiplatform.one/config/lint";
@@ -65,9 +81,11 @@
65
81
 
66
82
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
67
83
  import { createRequire } from "node:module";
68
- import { join, relative } from "node:path";
84
+ import { dirname, join, relative } from "node:path";
69
85
  import { pathToFileURL } from "node:url";
70
86
  import { DRAWING_PRAGMA, isDrawingFile, readDrawingRegistry } from "./drawing-registry.mjs";
87
+ import { findHexEscapes } from "./oxlint.mjs";
88
+ import { readHexEscapeRegistry, readSizeRecipeEscapeRegistry } from "./spec-registry.mjs";
71
89
 
72
90
  /**
73
91
  * @typedef {{ root?: string, apps?: string, features?: string, public?: string, packages?: string }} ConventionRoots
@@ -154,12 +172,17 @@ const CSS_IGNORE_PREFIXES = [
154
172
  join("frappe", "sites"),
155
173
  // Keycloakify vendored theme resources (not app-authored styles)
156
174
  join("keycloak", "public", "keycloakify-dev-resources"),
157
- // Platform-forced: vscode webviews rewrite <link href> to vscode-resource
158
- // URIs and url() resolves relative to the emitted stylesheet — inline
159
- // <style> would resolve against vscode-webview:// instead (LC-33 fonts).
160
- join("one", "vscode", "webview", "fonts.css"),
161
175
  ];
162
176
 
177
+ /**
178
+ * Paths inside ANY product app that are never scanned for .css: `mpo init`
179
+ * renames apps/one to each product app (MPO-318), so these cannot be pinned to
180
+ * `one`. Platform-forced: vscode webviews rewrite <link href> to
181
+ * vscode-resource URIs and url() resolves relative to the emitted stylesheet —
182
+ * inline <style> would resolve against vscode-webview:// instead (LC-33 fonts).
183
+ */
184
+ const CSS_IGNORE_APP_FILES = new Set(["vscode/webview/fonts.css"]);
185
+
163
186
  /** Generated Tamagui CSS extract filenames (not hand-authored app styles). */
164
187
  const GENERATED_CSS_NAMES = new Set(["tamagui.css", "tamagui.content.css"]);
165
188
 
@@ -195,8 +218,9 @@ function isCssIgnored(absPath) {
195
218
  if (CSS_IGNORE_PREFIXES.some((prefix) => rel === prefix || rel.startsWith(prefix + "/"))) {
196
219
  return true;
197
220
  }
198
- const base = rel.split(/[/\\]/).pop() ?? "";
199
- return GENERATED_CSS_NAMES.has(base);
221
+ const parts = rel.split(/[/\\]/);
222
+ if (CSS_IGNORE_APP_FILES.has(parts.slice(1).join("/"))) return true;
223
+ return GENERATED_CSS_NAMES.has(parts.at(-1) ?? "");
200
224
  }
201
225
 
202
226
  function listAppNames() {
@@ -239,9 +263,25 @@ function tryRequire(name) {
239
263
  }
240
264
  }
241
265
 
242
- /** @param {string} relPath */
266
+ /**
267
+ * A registry source under public/<pkg>/, or, when the tree has no
268
+ * public/<pkg> (every project `mpo init` scaffolds), the same path inside the
269
+ * installed @multiplatform.one/<pkg>, which publishes its src/ (MPO-352).
270
+ * @param {string} relPath
271
+ */
243
272
  function readPublicSource(relPath) {
244
- const full = join(PUBLIC_DIR, relPath);
273
+ const [pkg, ...rest] = relPath.split("/");
274
+ let full = join(PUBLIC_DIR, relPath);
275
+ if (!existsSync(join(PUBLIC_DIR, pkg))) {
276
+ try {
277
+ full = join(
278
+ dirname(requireFromRoot.resolve(`@multiplatform.one/${pkg}/package.json`)),
279
+ ...rest,
280
+ );
281
+ } catch {
282
+ return "";
283
+ }
284
+ }
245
285
  return existsSync(full) ? readFileSync(full, "utf8") : "";
246
286
  }
247
287
 
@@ -335,9 +375,11 @@ function scanLiterals(re, check) {
335
375
  * @returns {Set<string>}
336
376
  */
337
377
  function readAnimationTokens() {
338
- const tokens = new Set(["none"]); // valid non-animating value
378
+ /** @type {Set<string>} */
379
+ const tokens = new Set();
339
380
  const src = readPublicSource("theme/src/theme/animations/css.ts");
340
381
  if (!src) return tokens;
382
+ tokens.add("none"); // valid non-animating value
341
383
  for (const key of keysOfBlock(sliceBlock(src, "animationConfig =", "} as const"), 2)) {
342
384
  tokens.add(key);
343
385
  }
@@ -347,6 +389,7 @@ function readAnimationTokens() {
347
389
  /** Flag `transition="literal"` / `transition={"literal"}` using an unregistered token. */
348
390
  function checkTransitionTokens() {
349
391
  const valid = readAnimationTokens();
392
+ if (!valid.size) return { violations: [], valid: [], ok: false };
350
393
  // Only string LITERALS directly on the prop — dynamic values (variables,
351
394
  // ternaries, knobProps.transition) resolve at runtime and are out of scope.
352
395
  const re = /\btransition=(?:"([a-zA-Z0-9]+)"|\{\s*"([a-zA-Z0-9]+)"\s*\})/g;
@@ -354,7 +397,7 @@ function checkTransitionTokens() {
354
397
  const token = m[1] ?? m[2];
355
398
  return valid.has(token) ? undefined : `transition="${token}"`;
356
399
  });
357
- return { violations, valid: [...valid].filter((t) => t !== "none").sort() };
400
+ return { violations, valid: [...valid].filter((t) => t !== "none").sort(), ok: true };
358
401
  }
359
402
 
360
403
  /**
@@ -639,7 +682,7 @@ function checkAnimationProps() {
639
682
  for (const tag of src.matchAll(/<[A-Z][\w.]*(?:\s(?:=>|[^>])*)?>/gs)) {
640
683
  const t = tag[0];
641
684
  if (!/\b(enterStyle|exitStyle)=/.test(t)) continue;
642
- if (/\btransition[=:]/.test(t)) continue;
685
+ if (/\btransition(?:[=:]|Props\()/.test(t)) continue;
643
686
  const line = src.slice(0, tag.index).split("\n").length;
644
687
  driverless.push(`${rel}:${line} — enter/exitStyle with no \`transition\` driver (LC-24)`);
645
688
  }
@@ -715,6 +758,178 @@ function checkSizeRecipeEscape() {
715
758
  return violations;
716
759
  }
717
760
 
761
+ const ESCAPE_REASON = String.raw`(?:"([^"\n]*)"|'([^'\n]*)'|\x60([^\x60$\n]*)\x60)`;
762
+ /** `sizeRecipeEscape="…"`, `sizeRecipeEscape={"…"}`, `sizeRecipeEscape: "…"`. */
763
+ const ESCAPE_PROP_RE = new RegExp(
764
+ String.raw`\bsizeRecipeEscape(?:=\{?\s*|\s*:\s*)${ESCAPE_REASON}`,
765
+ "g",
766
+ );
767
+ const ESCAPE_ATTR_RE = /\bsizeRecipeEscape=\{/g;
768
+ const ESCAPE_CALL_RE = /(?<!function\s+)\bsizeRecipeEscape\s*\(/g;
769
+ const ESCAPE_COMMENT_RE = /(?<!`)(?:\/\/|\/\*)\s*size-recipe-escape:\s*(.*?)\s*(?:\*\/.*)?$/;
770
+ const ESCAPE_REASON_RE = new RegExp(ESCAPE_REASON, "g");
771
+
772
+ /** @param {string} line */
773
+ function isCommentLine(line) {
774
+ const trimmed = line.trim();
775
+ return trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*");
776
+ }
777
+
778
+ /**
779
+ * Every size-recipe escape the code writes, with the reason it gives. A call
780
+ * with no string argument, or a JSX prop whose value is not a string literal
781
+ * (`sizeRecipeEscape={reason}`), is returned with `reason: undefined`: the
782
+ * registry can only match a reason it can read.
783
+ * @param {string} src
784
+ * @returns {{ line: number, form: string, reason: string | undefined }[]}
785
+ */
786
+ export function findSizeRecipeEscapes(src) {
787
+ /** @type {{ line: number, form: string, reason: string | undefined }[]} */
788
+ const sites = [];
789
+ const lines = src.split("\n");
790
+ const literalProps = new Set();
791
+ for (const m of src.matchAll(ESCAPE_PROP_RE)) {
792
+ literalProps.add(m.index);
793
+ const line = lineAt(src, m.index ?? 0);
794
+ if (isCommentLine(lines[line - 1] ?? "")) continue;
795
+ sites.push({ line, form: "prop", reason: (m[1] ?? m[2] ?? m[3] ?? "").trim() });
796
+ }
797
+ for (const m of src.matchAll(ESCAPE_ATTR_RE)) {
798
+ if (literalProps.has(m.index)) continue;
799
+ const line = lineAt(src, m.index ?? 0);
800
+ if (isCommentLine(lines[line - 1] ?? "")) continue;
801
+ sites.push({ line, form: "prop", reason: undefined });
802
+ }
803
+ for (const m of src.matchAll(ESCAPE_CALL_RE)) {
804
+ const line = lineAt(src, m.index ?? 0);
805
+ if (isCommentLine(lines[line - 1] ?? "")) continue;
806
+ let depth = 0;
807
+ let end = (m.index ?? 0) + m[0].length - 1;
808
+ for (; end < src.length; end++) {
809
+ if (src[end] === "(") depth += 1;
810
+ else if (src[end] === ")") {
811
+ depth -= 1;
812
+ if (depth === 0) break;
813
+ }
814
+ }
815
+ const args = src.slice((m.index ?? 0) + m[0].length, end);
816
+ const literals = [...args.matchAll(ESCAPE_REASON_RE)];
817
+ const last = literals[literals.length - 1];
818
+ sites.push({
819
+ line,
820
+ form: "call",
821
+ reason: last ? (last[1] ?? last[2] ?? last[3] ?? "").trim() : undefined,
822
+ });
823
+ }
824
+ lines.forEach((text, index) => {
825
+ if (text.trim().startsWith("*")) return;
826
+ const m = text.match(ESCAPE_COMMENT_RE);
827
+ if (m) sites.push({ line: index + 1, form: "comment", reason: m[1].trim() });
828
+ });
829
+ return sites.sort((a, b) => a.line - b.line);
830
+ }
831
+
832
+ /**
833
+ * MPO-23 / `00` L8 E8 — `sizeRecipeEscape` is the weakest escape and it had no
834
+ * registry, so it grew silently: any file could write the marker and both the
835
+ * LC-65 check above and `no-raw-geometry` skipped it. Every escape site now
836
+ * has to be declared in the spec's `## Size-recipe escapes` table by file and
837
+ * by the exact reason it gives, and a declared row whose site is gone fails
838
+ * too, so the table cannot rot into a blanket pass. The allowlist is read from
839
+ * the spec, never from the code (the radius-identity-registry shape).
840
+ *
841
+ * Missing spec or missing section → skip, the same as the drawing registry.
842
+ */
843
+ function checkUndeclaredSizeRecipeEscapes() {
844
+ let registry;
845
+ try {
846
+ registry = readSizeRecipeEscapeRegistry(join(ROOT, "docs/theme-propagation-spec.md"));
847
+ } catch (error) {
848
+ return [`size-recipe escape registry: ${error instanceof Error ? error.message : error}`];
849
+ }
850
+ if (!registry.present) return [];
851
+ const declared = new Map();
852
+ for (const { file, reason } of registry.escapes) declared.set(`${file}\n${reason}`, false);
853
+ /** @type {string[]} */
854
+ const violations = [];
855
+ const scan = (filePath) => {
856
+ if (!/\.(tsx|ts|jsx|js)$/.test(filePath) || filePath.endsWith(".d.ts")) return;
857
+ if (/\.(spec|test)\./.test(filePath)) return;
858
+ const src = readFileSync(filePath, "utf8");
859
+ if (!src.includes("sizeRecipeEscape") && !src.includes("size-recipe-escape:")) return;
860
+ const rel = relative(ROOT, filePath).replaceAll("\\", "/");
861
+ for (const site of findSizeRecipeEscapes(src)) {
862
+ if (site.reason === undefined || site.reason === "") {
863
+ violations.push(`${rel}:${site.line} — sizeRecipeEscape with no written reason`);
864
+ continue;
865
+ }
866
+ const key = `${rel}\n${site.reason}`;
867
+ if (declared.has(key)) {
868
+ declared.set(key, true);
869
+ continue;
870
+ }
871
+ violations.push(`${rel}:${site.line} — undeclared escape "${site.reason}"`);
872
+ }
873
+ };
874
+ for (const dir of [PUBLIC_DIR, PACKAGES_DIR, APPS_DIR, FEATURES_DIR]) walkFiles(dir, scan);
875
+ for (const [key, seen] of declared) {
876
+ if (seen) continue;
877
+ const [file, reason] = key.split("\n");
878
+ violations.push(`${file} — declared escape "${reason}" has no site in the code (stale row)`);
879
+ }
880
+ return violations;
881
+ }
882
+
883
+ /**
884
+ * MPO-326 — `no-hex-literals` covers apps/, packages/ and features/, and a
885
+ * surface no theme token reaches (GTK widget CSS outside the provider, a
886
+ * content script on third-party pages, the badge API, Expo build config)
887
+ * opts its file out with a `hex-escape: <reason>` comment. The comment alone
888
+ * would let any file opt out, so every one must be a row in the spec's
889
+ * `## Colour-literal escapes` table by file and exact reason, and a row whose
890
+ * comment is gone fails too (the size-recipe registry shape).
891
+ *
892
+ * Missing spec or missing section → skip, the same as the drawing registry.
893
+ */
894
+ function checkUndeclaredHexEscapes() {
895
+ let registry;
896
+ try {
897
+ registry = readHexEscapeRegistry(join(ROOT, "docs/theme-propagation-spec.md"));
898
+ } catch (error) {
899
+ return [`colour-literal escape registry: ${error instanceof Error ? error.message : error}`];
900
+ }
901
+ if (!registry.present) return [];
902
+ const declared = new Map();
903
+ for (const { file, reason } of registry.escapes) declared.set(`${file}\n${reason}`, false);
904
+ /** @type {string[]} */
905
+ const violations = [];
906
+ const scan = (filePath) => {
907
+ if (!/\.(tsx|ts|jsx|js)$/.test(filePath) || filePath.endsWith(".d.ts")) return;
908
+ if (/\.(spec|test)\./.test(filePath)) return;
909
+ const src = readFileSync(filePath, "utf8");
910
+ const rel = relative(ROOT, filePath).replaceAll("\\", "/");
911
+ for (const site of findHexEscapes(src)) {
912
+ if (!site.reason) {
913
+ violations.push(`${rel}:${site.line} — hex-escape with no written reason`);
914
+ continue;
915
+ }
916
+ const key = `${rel}\n${site.reason}`;
917
+ if (declared.has(key)) {
918
+ declared.set(key, true);
919
+ continue;
920
+ }
921
+ violations.push(`${rel}:${site.line} — undeclared escape "${site.reason}"`);
922
+ }
923
+ };
924
+ for (const dir of [PUBLIC_DIR, PACKAGES_DIR, APPS_DIR, FEATURES_DIR]) walkFiles(dir, scan);
925
+ for (const [key, seen] of declared) {
926
+ if (seen) continue;
927
+ const [file, reason] = key.split("\n");
928
+ violations.push(`${file} — declared escape "${reason}" has no comment in the code (stale row)`);
929
+ }
930
+ return violations;
931
+ }
932
+
718
933
  // ── Story file shape (MPO-93) ─────────────────────────────────────────────
719
934
 
720
935
  const STORY_FILE_RE = /\.(stories|story)\.(js|jsx|mjs|ts|tsx)$/;
@@ -1276,6 +1491,97 @@ function checkGraphVarsCoverage() {
1276
1491
  return violations;
1277
1492
  }
1278
1493
 
1494
+ // ── Story honesty: foreign control specimens (LC-56 / MPO-316) ────────────
1495
+
1496
+ /**
1497
+ * The raw `tamagui` control primitives a story may not mount unlabeled: the
1498
+ * pressables and controls @multiplatform.one/forms owns a house equivalent
1499
+ * for. Layout and text primitives (XStack, YStack, Stack, Paragraph, Text,
1500
+ * View, SizableText, Label) are out of scope BY RULE: roughly 250 story files
1501
+ * import them and they carry no control anatomy to bypass.
1502
+ */
1503
+ const STORY_FOREIGN_CONTROL_PRIMITIVES = new Set([
1504
+ "Button",
1505
+ "Checkbox",
1506
+ "Input",
1507
+ "Progress",
1508
+ "RadioGroup",
1509
+ "Select",
1510
+ "Slider",
1511
+ "Switch",
1512
+ "TextArea",
1513
+ "ToggleGroup",
1514
+ ]);
1515
+
1516
+ /**
1517
+ * The exemption LC-56 introduced and LC-60's radius sweep already reads off
1518
+ * the running story, so a specimen is declared in exactly one place for both
1519
+ * arms.
1520
+ */
1521
+ const STORY_HONESTY_MARKER_RE = /\bforeignContrastSpecimen\s*:\s*true\b/;
1522
+
1523
+ /**
1524
+ * @multiplatform.one/frappe is the data client, not a catalog package
1525
+ * (knob-laws.md § Inventory): its stories demo hooks, and the buttons in them
1526
+ * drive the hook, not a knob probe. It also cannot import a house control:
1527
+ * forms reaches frappe through its @multiplatform.one/storybook devDependency,
1528
+ * so the import would close a workspace cycle.
1529
+ */
1530
+ const STORY_HONESTY_EXEMPT_PREFIXES = ["public/frappe/"];
1531
+
1532
+ const TAMAGUI_IMPORT_RE = /import\s+([^;'"]*?)from\s*["']tamagui["']/g;
1533
+
1534
+ /**
1535
+ * LC-56: stories are the verify substrate for the knob rules, and a raw
1536
+ * tamagui control consumes no knobs. FieldLayout, FieldGroup, Fieldset and
1537
+ * FormGrid measured 9px radius at every `borderRadius` stop because their
1538
+ * stories mounted `Input as TamaguiInput`. A sweep that trusts such a story
1539
+ * manufactures a pass.
1540
+ *
1541
+ * File granularity is deliberate: attributing a JSX usage to one story export
1542
+ * is not a regex job, and the knob probes stay the live arm for a labeled
1543
+ * file. `import type` is erased and never counts.
1544
+ */
1545
+ function checkStoryForeignControls() {
1546
+ /** @type {string[]} */
1547
+ const violations = [];
1548
+ const scan = (filePath) => {
1549
+ if (!STORY_FILE_RE.test(filePath)) return;
1550
+ const rel = relative(ROOT, filePath).replaceAll("\\", "/");
1551
+ if (STORY_HONESTY_EXEMPT_PREFIXES.some((prefix) => rel.startsWith(prefix))) return;
1552
+ const src = readFileSync(filePath, "utf8");
1553
+ if (!src.includes("tamagui")) return;
1554
+ /** @type {{ imported: string, alias: string, line: number }[]} */
1555
+ const controls = [];
1556
+ for (const statement of src.matchAll(TAMAGUI_IMPORT_RE)) {
1557
+ const clause = statement[1].trim();
1558
+ if (/^type\b/.test(clause)) continue;
1559
+ const named = clause.match(/\{([\s\S]*)\}/);
1560
+ if (!named) continue;
1561
+ for (const specifier of named[1].split(",")) {
1562
+ const entry = specifier.trim();
1563
+ if (!entry || /^type\b/.test(entry)) continue;
1564
+ const [imported, alias] = entry.split(/\s+as\s+/).map((part) => part.trim());
1565
+ if (!STORY_FOREIGN_CONTROL_PRIMITIVES.has(imported)) continue;
1566
+ controls.push({
1567
+ imported,
1568
+ alias: alias || imported,
1569
+ line: lineAt(src, statement.index ?? 0),
1570
+ });
1571
+ }
1572
+ }
1573
+ if (!controls.length || STORY_HONESTY_MARKER_RE.test(src)) return;
1574
+ for (const { imported, alias, line } of controls) {
1575
+ const shown = alias === imported ? imported : `${imported} as ${alias}`;
1576
+ violations.push(
1577
+ `${rel}:${line} — \`${shown}\` from "tamagui" with no \`parameters.foreignContrastSpecimen: true\``,
1578
+ );
1579
+ }
1580
+ };
1581
+ for (const dir of [PUBLIC_DIR, PACKAGES_DIR, APPS_DIR, FEATURES_DIR]) walkFiles(dir, scan);
1582
+ return violations;
1583
+ }
1584
+
1279
1585
  /**
1280
1586
  * Run structural convention checks over a consumer tree.
1281
1587
  *
@@ -1287,7 +1593,10 @@ export function runConventionChecks(options = {}) {
1287
1593
  const css = checkCssInApps();
1288
1594
  const twins = checkFeaturesTwins();
1289
1595
  const sizeRecipeEscape = checkSizeRecipeEscape();
1596
+ const undeclaredEscapes = checkUndeclaredSizeRecipeEscapes();
1597
+ const undeclaredHexEscapes = checkUndeclaredHexEscapes();
1290
1598
  const storyShape = checkStoryFileShape();
1599
+ const storyForeignControls = checkStoryForeignControls();
1291
1600
  const undeclaredDrawings = checkUndeclaredDrawings();
1292
1601
  const tsconfigPaths = checkTsconfigWorkspacePaths();
1293
1602
  const metroFallbacks = checkMetroSubpathFallbacks();
@@ -1336,12 +1645,30 @@ export function runConventionChecks(options = {}) {
1336
1645
  if (sizeRecipeEscape.length) {
1337
1646
  failed = true;
1338
1647
  console.error(
1339
- "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",
1648
+ "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: <reason>` on the previous line or same statement, and declare the reason in the spec's `## Size-recipe escapes` table. Pilot: Button/, InputParts/, fields/Select/.\n",
1340
1649
  );
1341
1650
  for (const msg of sizeRecipeEscape) console.error(` ${msg}`);
1342
1651
  console.error("");
1343
1652
  }
1344
1653
 
1654
+ if (undeclaredEscapes.length) {
1655
+ failed = true;
1656
+ console.error(
1657
+ "Convention (MPO-23 SIZE-RECIPE ESCAPE REGISTRY, 00 L8 E8): every sizeRecipeEscape site must be a row in docs/theme-propagation-spec.md `## Size-recipe escapes`, by file and by the exact reason the code writes. The table is the allowlist; writing another escape cannot widen it. A row whose site is gone fails too.\n",
1658
+ );
1659
+ for (const msg of undeclaredEscapes) console.error(` ${msg}`);
1660
+ console.error("");
1661
+ }
1662
+
1663
+ if (undeclaredHexEscapes.length) {
1664
+ failed = true;
1665
+ console.error(
1666
+ "Convention (MPO-326 COLOUR-LITERAL ESCAPE REGISTRY): a `hex-escape: <reason>` comment exempts its file from mpo-conventions/no-hex-literals, so every one must be a row in docs/theme-propagation-spec.md `## Colour-literal escapes`, by file and by the exact reason the comment writes. Reach for a theme token first; the escape is for surfaces no token reaches. A row whose comment is gone fails too.\n",
1667
+ );
1668
+ for (const msg of undeclaredHexEscapes) console.error(` ${msg}`);
1669
+ console.error("");
1670
+ }
1671
+
1345
1672
  if (storyShape.length) {
1346
1673
  failed = true;
1347
1674
  console.error(
@@ -1351,6 +1678,15 @@ export function runConventionChecks(options = {}) {
1351
1678
  console.error("");
1352
1679
  }
1353
1680
 
1681
+ if (storyForeignControls.length) {
1682
+ failed = true;
1683
+ console.error(
1684
+ 'Convention (LC-56 STORY-HONESTY / MPO-316 FOREIGN CONTROL SPECIMEN): a story composes catalog components. A raw control primitive imported from "tamagui" (Input, Button, Checkbox, Switch, Slider, Select, RadioGroup, ToggleGroup, Progress, TextArea) consumes no knobs, so a knob sweep that trusts the story scores the primitive and marks the component checked. Mount the house control from @multiplatform.one/forms (inside a layout: `<Input><Input.Box><Input.Area /></Input.Box></Input>`), or, when the raw primitive is there on purpose to show the contrast, label that story `parameters: { foreignContrastSpecimen: true }` so the probes skip it. Layout and text primitives (XStack, YStack, Paragraph, Text) are out of scope.\n',
1685
+ );
1686
+ for (const msg of storyForeignControls) console.error(` ${msg}`);
1687
+ console.error("");
1688
+ }
1689
+
1354
1690
  if (undeclaredDrawings.violations.length) {
1355
1691
  failed = true;
1356
1692
  console.error(
@@ -1442,6 +1778,7 @@ export function runConventionChecks(options = {}) {
1442
1778
  }
1443
1779
 
1444
1780
  for (const [name, ok] of [
1781
+ ["animation token", tokens.ok],
1445
1782
  ["style token", styleTokens.ok],
1446
1783
  ["theme name", themeNames.ok],
1447
1784
  ["intent", intents.ok],
package/oxlint.d.ts CHANGED
@@ -3,8 +3,10 @@ export const PALETTE_FILE: RegExp;
3
3
  export const CHROME_PROPS: readonly string[];
4
4
  export const LAYOUT_PROPS: readonly string[];
5
5
  export const GEOMETRY_PROPS: readonly string[];
6
+ export const MARGIN_PROPS: readonly string[];
6
7
  export function isPaletteModule(filename: string | undefined): boolean;
7
8
  export function matchColorLiteral(value: unknown): string | undefined;
9
+ export function findHexEscapes(src: string): { line: number; reason: string }[];
8
10
  export const plugin: {
9
11
  meta?: { name?: string };
10
12
  rules?: Record<string, unknown>;
package/oxlint.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "jsPlugins": ["@multiplatform.one/config/oxlint"],
3
+ "rules": {
4
+ "mpo-conventions/no-hex-literals": "error",
5
+ "mpo-conventions/no-raw-typography": "error",
6
+ "mpo-conventions/no-chrome-props": "warn",
7
+ "mpo-conventions/no-raw-geometry": "warn",
8
+ "mpo-conventions/no-child-margin": "warn"
9
+ },
10
+ "overrides": [
11
+ {
12
+ "files": ["**/*.spec.{ts,tsx,js,jsx}", "**/*.test.{ts,tsx,js,jsx}", "**/tests/**"],
13
+ "rules": {
14
+ "mpo-conventions/no-hex-literals": "off",
15
+ "mpo-conventions/no-chrome-props": "off",
16
+ "mpo-conventions/no-raw-geometry": "off",
17
+ "mpo-conventions/no-child-margin": "off"
18
+ }
19
+ }
20
+ ]
21
+ }