@multiplatform.one/config 7.5.0 → 7.6.1

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/README.md CHANGED
@@ -68,7 +68,14 @@ Convention rules travel with the package. Consuming apps add one line:
68
68
 
69
69
  Then enable `mpo-conventions/no-hex-literals` on the globs you want (mpo's
70
70
  own `oxlintrc.json` turns it on for `features/**`). Palette modules
71
- (`themes/base.ts`, `themes/accent.ts`, `*palette*`) are exempt.
71
+ (`themes/base.ts`, `themes/accent.ts`, `*palette*`) are exempt. Comments
72
+ are not visited, so a JSDoc `#025` is not a hit. Widening to `apps/**`
73
+ and `packages/**` is 182 hits on this tree (153 outside tests) — record
74
+ the count, then burn down; do not flip the glob until then.
75
+
76
+ `no-chrome-props` and `no-raw-geometry` ship in the same plugin (MPO-17).
77
+ Enable them the same way when you start that burndown. Drawing-file
78
+ exceptions come from the spec table `## Drawing files`, not from the code.
72
79
 
73
80
  ### Lint (node-script checks)
74
81
 
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Drawing / chrome-allow registry — parsed from the living rulebook.
3
+ *
4
+ * MPO-17: raw numeric geometry is legal only in files listed as DRAWING
5
+ * members, and chrome props on imported @multiplatform.one/* components are
6
+ * legal only for CHROME-ALLOW members. Both lists are parsed from
7
+ * `docs/theme-propagation-spec.md` `## Drawing files`, the same shape
8
+ * scripts/radius-identity-registry.mjs uses for LC-60. Writing more
9
+ * violations into the code cannot widen either list.
10
+ *
11
+ * A consumer tree with no spec (or no Drawing files section) yields empty
12
+ * lists rather than throwing, so shc can pick the oxlint rules up before it
13
+ * has copied this table.
14
+ *
15
+ * Usage:
16
+ * import { readDrawingRegistry } from "./drawing-registry.mjs";
17
+ * node drawing-registry.mjs [--json]
18
+ */
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { pathToFileURL } from "node:url";
22
+
23
+ export const DRAWING_HEADING = "## Drawing files";
24
+ export const DRAWING_CLASS = "DRAWING";
25
+ export const CHROME_ALLOW_CLASS = "CHROME-ALLOW";
26
+
27
+ /** `// mpo-drawing` or `/* mpo-drawing` at the start of a line. */
28
+ export const DRAWING_PRAGMA = /^\s*(?:\/\/|\/\*)\s*mpo-drawing\b/m;
29
+
30
+ /**
31
+ * @param {string} [cwd]
32
+ */
33
+ export function defaultSpecPath(cwd = process.cwd()) {
34
+ return join(cwd, "docs/theme-propagation-spec.md");
35
+ }
36
+
37
+ /** `Members: **Avatar**, **Radio disc**.` → ["Avatar", "Radio disc"]. */
38
+ function parseMembers(text) {
39
+ const match = text.match(/Members:\s*(.*)$/i);
40
+ if (!match) return [];
41
+ return [...match[1].matchAll(/\*\*([^*]+)\*\*/g)].map((m) => m[1].trim()).filter(Boolean);
42
+ }
43
+
44
+ /**
45
+ * @param {string} markdown
46
+ * @param {string} specPath
47
+ */
48
+ function parseDrawingTable(markdown, specPath) {
49
+ const lines = markdown.split("\n");
50
+ const heading = lines.findIndex((line) => /^##\s+Drawing files\s*$/.test(line));
51
+ if (heading === -1) return null;
52
+
53
+ /** @type {{ id: string, text: string }[]} */
54
+ const rows = [];
55
+ let seenTable = false;
56
+ for (let i = heading + 1; i < lines.length; i++) {
57
+ const line = lines[i].trim();
58
+ if (line.startsWith("##")) break;
59
+ if (!line.startsWith("|")) {
60
+ if (seenTable) break;
61
+ continue;
62
+ }
63
+ seenTable = true;
64
+ const cells = line
65
+ .split("|")
66
+ .slice(1, -1)
67
+ .map((c) => c.trim());
68
+ if (cells.length < 2) continue;
69
+ if (/^:?-{2,}/.test(cells[0])) continue;
70
+ if (cells[0].toLowerCase() === "class") continue;
71
+ rows.push({ id: cells[0], text: cells[1] });
72
+ }
73
+ if (rows.length === 0) {
74
+ throw new Error(`${specPath}: "${DRAWING_HEADING}" has no class rows`);
75
+ }
76
+ return rows;
77
+ }
78
+
79
+ /**
80
+ * @param {string} [specPath]
81
+ * @returns {{
82
+ * specPath: string,
83
+ * present: boolean,
84
+ * files: string[],
85
+ * chromeAllow: string[],
86
+ * }}
87
+ */
88
+ export function readDrawingRegistry(specPath = defaultSpecPath()) {
89
+ if (!existsSync(specPath)) {
90
+ return { specPath, present: false, files: [], chromeAllow: [] };
91
+ }
92
+ const rows = parseDrawingTable(readFileSync(specPath, "utf8"), specPath);
93
+ if (!rows) {
94
+ return { specPath, present: false, files: [], chromeAllow: [] };
95
+ }
96
+
97
+ /** @type {Record<string, { text: string, members: string[] }>} */
98
+ const classes = {};
99
+ for (const row of rows) {
100
+ classes[row.id] = { text: row.text, members: parseMembers(row.text) };
101
+ }
102
+ if (!classes[DRAWING_CLASS]) {
103
+ throw new Error(`${specPath}: "${DRAWING_HEADING}" has no ${DRAWING_CLASS} row`);
104
+ }
105
+ if (!classes[CHROME_ALLOW_CLASS]) {
106
+ throw new Error(`${specPath}: "${DRAWING_HEADING}" has no ${CHROME_ALLOW_CLASS} row`);
107
+ }
108
+
109
+ return {
110
+ specPath,
111
+ present: true,
112
+ files: classes[DRAWING_CLASS].members,
113
+ chromeAllow: classes[CHROME_ALLOW_CLASS].members,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * True when `filename` is one of the registered drawing files.
119
+ * Members are path suffixes (`listing/glyphs/CanvasGlyph.tsx`) or basenames.
120
+ *
121
+ * @param {string | undefined} filename
122
+ * @param {string[]} files
123
+ */
124
+ export function isDrawingFile(filename, files) {
125
+ if (!filename || !files.length) return false;
126
+ const normalized = filename.replaceAll("\\", "/");
127
+ return files.some((entry) => {
128
+ const needle = entry.replaceAll("\\", "/").replace(/^\.\//, "");
129
+ return normalized === needle || normalized.endsWith(`/${needle}`);
130
+ });
131
+ }
132
+
133
+ const invokedDirectly =
134
+ typeof process.argv[1] === "string" && import.meta.url === pathToFileURL(process.argv[1]).href;
135
+
136
+ if (invokedDirectly) {
137
+ const registry = readDrawingRegistry();
138
+ if (process.argv.includes("--json")) {
139
+ console.log(JSON.stringify(registry, null, 2));
140
+ } else {
141
+ console.log(`drawing registry from ${registry.specPath} (present=${registry.present})`);
142
+ console.log(` DRAWING: ${registry.files.join(", ") || "(none)"}`);
143
+ console.log(` CHROME-ALLOW: ${registry.chromeAllow.join(", ") || "(none)"}`);
144
+ }
145
+ }
package/lib/index.js CHANGED
@@ -1,5 +1,6 @@
1
- import { t as createViteConfig } from "./vite-C7884n3X.js";
2
- import { t as createStorybookViteConfig } from "./storybook-CrdwuS_Y.js";
1
+ import { t as createViteConfig } from "./vite-W9hIeg2_.js";
2
+ import { t as createStorybookViteConfig } from "./storybook-CSzjegfP.js";
3
+ import { n as tamaguiWorkspacePathsFile, t as ensureTamaguiWorkspacePaths } from "./tamaguiWorkspacePaths-ZIcqybtn.js";
3
4
  import { createVitestConfig } from "./vitest.js";
4
5
 
5
- export { createStorybookViteConfig, createViteConfig, createVitestConfig };
6
+ export { createStorybookViteConfig, createViteConfig, createVitestConfig, ensureTamaguiWorkspacePaths, tamaguiWorkspacePathsFile };
@@ -1,4 +1,5 @@
1
1
  import { r as resolvePackageMainSource, t as discoverPublicPackageRoots } from "./workspacePublicPackages-COicQSj4.js";
2
+ import { t as ensureTamaguiWorkspacePaths } from "./tamaguiWorkspacePaths-ZIcqybtn.js";
2
3
  import { createRequire } from "node:module";
3
4
  import fs from "node:fs";
4
5
  import path from "node:path";
@@ -89,6 +90,7 @@ function resolvePackageDir(pkgName, root, requireFrom) {
89
90
  * ```
90
91
  */
91
92
  function createStorybookViteConfig(options = {}) {
93
+ ensureTamaguiWorkspacePaths();
92
94
  const workspaceRoot = options.workspaceRoot || findWorkspaceRoot();
93
95
  const aliases = {
94
96
  ...discoverPackageAliases(path.join(workspaceRoot, "packages")),
package/lib/storybook.js CHANGED
@@ -1,3 +1,3 @@
1
- import { t as createStorybookViteConfig } from "./storybook-CrdwuS_Y.js";
1
+ import { t as createStorybookViteConfig } from "./storybook-CSzjegfP.js";
2
2
 
3
3
  export { createStorybookViteConfig };
@@ -0,0 +1,58 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { execFileSync } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ //#region src/tamaguiWorkspacePaths.ts
7
+ const tamaguiWorkspacePathsFile = "tsconfig/tamagui-workspace-paths.generated.json";
8
+ const generatorFile = "../../scripts/generate-tamagui-workspace-paths.mjs";
9
+ function thisPackageDir() {
10
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
11
+ }
12
+ function ensureTamaguiWorkspacePaths(options = {}) {
13
+ const { configPackageDir = thisPackageDir(), log = console.warn } = options;
14
+ const generatedPath = path.resolve(configPackageDir, tamaguiWorkspacePathsFile);
15
+ const generatorPath = path.resolve(configPackageDir, generatorFile);
16
+ if (fs.existsSync(generatedPath)) return {
17
+ generatedPath,
18
+ generatorPath,
19
+ status: "present"
20
+ };
21
+ if (!fs.existsSync(generatorPath)) return {
22
+ generatedPath,
23
+ generatorPath,
24
+ status: "no-generator"
25
+ };
26
+ try {
27
+ execFileSync(process.execPath, [generatorPath], { stdio: [
28
+ "ignore",
29
+ "ignore",
30
+ "pipe"
31
+ ] });
32
+ } catch (error) {
33
+ const message = error instanceof Error ? error.message : String(error);
34
+ log?.(`[@multiplatform.one/config] could not regenerate ${tamaguiWorkspacePathsFile}: ${message}`);
35
+ return {
36
+ generatedPath,
37
+ generatorPath,
38
+ status: "failed"
39
+ };
40
+ }
41
+ if (!fs.existsSync(generatedPath)) {
42
+ log?.(`[@multiplatform.one/config] ${path.basename(generatorPath)} ran but did not write ${tamaguiWorkspacePathsFile}`);
43
+ return {
44
+ generatedPath,
45
+ generatorPath,
46
+ status: "failed"
47
+ };
48
+ }
49
+ log?.(`[@multiplatform.one/config] ${tamaguiWorkspacePathsFile} was missing (the root prepare script did not run; a global ignore-scripts skips it). Regenerated it. Run \`pnpm generate:tamagui-paths\` after install to skip this step.`);
50
+ return {
51
+ generatedPath,
52
+ generatorPath,
53
+ status: "generated"
54
+ };
55
+ }
56
+
57
+ //#endregion
58
+ export { tamaguiWorkspacePathsFile as n, ensureTamaguiWorkspacePaths as t };
@@ -1,4 +1,5 @@
1
1
  import { t as discoverPublicPackageRoots } from "./workspacePublicPackages-COicQSj4.js";
2
+ import { t as ensureTamaguiWorkspacePaths } from "./tamaguiWorkspacePaths-ZIcqybtn.js";
2
3
  import { createRequire } from "node:module";
3
4
  import fs from "node:fs";
4
5
  import path from "node:path";
@@ -77,6 +78,7 @@ function lookupProjectRoot() {
77
78
  * ```
78
79
  */
79
80
  function createViteConfig(options = {}) {
81
+ ensureTamaguiWorkspacePaths();
80
82
  const { projectRoot: _projectRoot, publicConfigKeys = [], tamagui = true, i18n = false, one = false, ssr, server, plugins: extraPlugins = [], externalReactRouter, ssrDeps, ssrEntries, ssrCjsExternal, nativeFixes } = options;
81
83
  const projectRoot = _projectRoot || findProjectRoot();
82
84
  dotenv.config({ path: path.resolve(projectRoot, ".env") });
package/lib/vite.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as createViteConfig } from "./vite-C7884n3X.js";
1
+ import { t as createViteConfig } from "./vite-W9hIeg2_.js";
2
2
  import { n as publicPackageViteSourceAliases, t as discoverPublicPackageRoots } from "./workspacePublicPackages-COicQSj4.js";
3
3
 
4
4
  export { createViteConfig, discoverPublicPackageRoots, publicPackageViteSourceAliases };
package/lib/vitest.js CHANGED
@@ -1,8 +1,9 @@
1
+ import { t as ensureTamaguiWorkspacePaths } from "./tamaguiWorkspacePaths-ZIcqybtn.js";
1
2
  import Module from "node:module";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
- import { tamaguiPlugin } from "@tamagui/vite-plugin";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { tamaguiPlugin } from "@tamagui/vite-plugin";
6
7
  import react from "@vitejs/plugin-react";
7
8
 
8
9
  //#region src/vitest.ts
@@ -47,6 +48,7 @@ function pinNodeReactRequire(reactDir, reactDomDir, schedulerDir) {
47
48
  * ```
48
49
  */
49
50
  function createVitestConfig(options = {}) {
51
+ ensureTamaguiWorkspacePaths();
50
52
  const { tamaguiConfig = "./tests/tamagui.config.ts", environment = "jsdom", setupFiles = [], testTimeout = 3e4, inlineDeps = [], externalDeps = [], aliases = {}, coverage, replaceReactNative = true, dedupeReact = true, rootNodeModules: _rootNodeModules, conditions = [
51
53
  "source",
52
54
  "test",
package/lint.d.ts CHANGED
@@ -3,6 +3,7 @@ export type ConventionRoots = {
3
3
  apps?: string;
4
4
  features?: string;
5
5
  public?: string;
6
+ packages?: string;
6
7
  };
7
8
 
8
9
  export type RunConventionChecksOptions = {
@@ -14,6 +15,7 @@ export function resolveRoots(roots?: ConventionRoots): {
14
15
  apps: string;
15
16
  features: string;
16
17
  public: string;
18
+ packages: string;
17
19
  };
18
20
 
19
21
  export function runConventionChecks(options?: RunConventionChecksOptions): number;
package/lint.mjs CHANGED
@@ -18,6 +18,15 @@
18
18
  * `knobProps.control.height`) are fine. Escape with `sizeRecipeEscape` or
19
19
  * `size-recipe-escape:` on the previous line or same statement. Pilot:
20
20
  * Button/, InputParts/, fields/Select/ only.
21
+ * 5. Fail on a STORY FILE THE CSF INDEXER CANNOT READ (MPO-93) — a
22
+ * `*.stories.*` file that exports a named `meta` beside `export default`
23
+ * (`meta` is reserved for CSF Factories, so the indexer reports "missing
24
+ * default export"), or one with no default export at all. One such file
25
+ * kills the whole gallery and no package build ever sees it.
26
+ * 6. Fail on an UNDECLARED DRAWING FILE (MPO-17) — a `// mpo-drawing` pragma
27
+ * whose path is not a DRAWING member in docs/theme-propagation-spec.md.
28
+ * The oxlint rule skips registered drawings; this check is what makes
29
+ * adding an undeclared drawing file still fail.
21
30
  *
22
31
  * Usage:
23
32
  * import { runConventionChecks } from "@multiplatform.one/config/lint";
@@ -31,9 +40,10 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
31
40
  import { createRequire } from "node:module";
32
41
  import { join, relative } from "node:path";
33
42
  import { pathToFileURL } from "node:url";
43
+ import { DRAWING_PRAGMA, isDrawingFile, readDrawingRegistry } from "./drawing-registry.mjs";
34
44
 
35
45
  /**
36
- * @typedef {{ root?: string, apps?: string, features?: string, public?: string }} ConventionRoots
46
+ * @typedef {{ root?: string, apps?: string, features?: string, public?: string, packages?: string }} ConventionRoots
37
47
  * @typedef {{ roots?: ConventionRoots }} RunConventionChecksOptions
38
48
  */
39
49
 
@@ -45,6 +55,8 @@ let APPS_DIR = join(ROOT, "apps");
45
55
  let FEATURES_DIR = join(ROOT, "features");
46
56
  /** @type {string} */
47
57
  let PUBLIC_DIR = join(ROOT, "public");
58
+ /** @type {string} */
59
+ let PACKAGES_DIR = join(ROOT, "packages");
48
60
 
49
61
  /**
50
62
  * @param {ConventionRoots} [roots]
@@ -56,6 +68,7 @@ export function resolveRoots(roots = {}) {
56
68
  apps: roots.apps ?? join(root, "apps"),
57
69
  features: roots.features ?? join(root, "features"),
58
70
  public: roots.public ?? join(root, "public"),
71
+ packages: roots.packages ?? join(root, "packages"),
59
72
  };
60
73
  }
61
74
 
@@ -68,6 +81,7 @@ function applyRoots(roots) {
68
81
  APPS_DIR = resolved.apps;
69
82
  FEATURES_DIR = resolved.features;
70
83
  PUBLIC_DIR = resolved.public;
84
+ PACKAGES_DIR = resolved.packages;
71
85
  sourceFileCache = undefined;
72
86
  requireFromRoot = createRequire(join(ROOT, "package.json"));
73
87
  }
@@ -399,10 +413,24 @@ function readThemeKeys() {
399
413
  return keys;
400
414
  }
401
415
 
402
- /** Font-size steps the house fonts register (`defaults/fonts.ts`). */
416
+ /**
417
+ * Font-size steps the house fonts register. MPO-19 X8/X9 retired the
418
+ * hand-mirrored `interBaseSizes` table — `defaults/fonts.ts` now READS its
419
+ * size tables from `@tamagui/config/v5`'s `defaultConfig.fonts`, so this
420
+ * registry reads the same source (the era pin is load-bearing). The source
421
+ * parse survives as a fallback for consumer trees that still carry the
422
+ * transcribed table.
423
+ */
403
424
  function readFontSizeSteps() {
404
425
  /** @type {Set<string>} */
405
426
  const steps = new Set();
427
+ const cfg = tryRequire("@tamagui/config/v5");
428
+ for (const font of Object.values(cfg?.defaultConfig?.fonts ?? {})) {
429
+ // Bare keys (`1`…`16`, `true`) — checkStyleTokens compares the captured
430
+ // token name without its `$`, same shape keysOfBlock returned.
431
+ for (const k of Object.keys(font?.size ?? {})) steps.add(k);
432
+ }
433
+ if (steps.size) return steps;
406
434
  const src = readPublicSource("theme/src/theme/defaults/fonts.ts");
407
435
  if (!src) return steps;
408
436
  for (const k of keysOfBlock(sliceBlock(src, "const interBaseSizes", "\n};"), 2)) steps.add(k);
@@ -566,7 +594,11 @@ function checkAnimationProps() {
566
594
  // `ctx.knobProps.transition` are caught too. Deliberately NOT "any value":
567
595
  // that also matches TS annotations (`animation: any`), css-in-js, and
568
596
  // 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)) {
597
+ // The lookbehind keeps `data-animation={…}` (probe attributes: Spinner,
598
+ // Pagination) out: `\b` alone matches between `-` and `animation`.
599
+ for (const m of src.matchAll(
600
+ /(?<![\w-])animation=\{|(?<![\w-])animation:\s*[\w$.[\]?]*\btransition\b/g,
601
+ )) {
570
602
  const line = src.slice(0, m.index).split("\n").length;
571
603
  legacyProp.push(`${rel}:${line} — \`${m[0]}\` (tamagui 1.x prop; use \`transition\`)`);
572
604
  }
@@ -652,6 +684,100 @@ function checkSizeRecipeEscape() {
652
684
  return violations;
653
685
  }
654
686
 
687
+ // ── Story file shape (MPO-93) ─────────────────────────────────────────────
688
+
689
+ const STORY_FILE_RE = /\.(stories|story)\.(js|jsx|mjs|ts|tsx)$/;
690
+
691
+ /**
692
+ * Names exported through `export { a, b as c }` statements.
693
+ * @param {string} src
694
+ * @returns {Set<string>}
695
+ */
696
+ function braceExportNames(src) {
697
+ const names = new Set();
698
+ for (const m of src.matchAll(/^export\s*\{([^}]*)\}/gm)) {
699
+ for (const item of m[1].split(",")) {
700
+ const parts = item.trim().split(/\s+as\s+/);
701
+ const name = (parts[1] ?? parts[0] ?? "").trim();
702
+ if (name) names.add(name);
703
+ }
704
+ }
705
+ return names;
706
+ }
707
+
708
+ /**
709
+ * Storybook's CSF indexer accepts a story file in exactly two shapes: classic
710
+ * CSF (`const meta = {...}; export default meta`) or CSF Factories
711
+ * (`export const meta = preview.meta({...})`, no default export). A file that
712
+ * exports a NAMED `meta` next to `export default` is neither — the indexer
713
+ * takes the named export as the factory shape, finds no factory call, and
714
+ * reports "CSF: missing default export (line 1, col 0)". One such file fails
715
+ * the whole index, the preview build, and `storybook dev` exits non-zero.
716
+ * Nothing else catches it: package builds exclude `.stories.` files, so the
717
+ * pipeline stays green. Three files did exactly this on main (MPO-93).
718
+ */
719
+ function checkStoryFileShape() {
720
+ /** @type {string[]} */
721
+ const violations = [];
722
+ const scan = (filePath) => {
723
+ if (!STORY_FILE_RE.test(filePath)) return;
724
+ const rel = relative(ROOT, filePath);
725
+ const src = readFileSync(filePath, "utf8");
726
+ const braced = braceExportNames(src);
727
+ const namedMeta =
728
+ src.match(/^export\s+(?:const|let|var|function|class)\s+meta\b/m) ??
729
+ (braced.has("meta") ? src.match(/^export\s*\{[^}]*\bmeta\b[^}]*\}/m) : null);
730
+ const hasDefault = /^export\s+default\b/m.test(src) || braced.has("default");
731
+ const isFactory = /^export\s+(?:const|let|var)\s+meta\b[^=\n]*=\s*[\w$.]+\.meta\s*\(/m.test(
732
+ src,
733
+ );
734
+ if (namedMeta && hasDefault) {
735
+ violations.push(
736
+ `${rel}:${lineAt(src, namedMeta.index ?? 0)} — exports a named \`meta\` beside \`export default\`; drop the \`export\` (\`meta\` is reserved for CSF Factories)`,
737
+ );
738
+ } else if (namedMeta && !isFactory) {
739
+ violations.push(
740
+ `${rel}:${lineAt(src, namedMeta.index ?? 0)} — exports a named \`meta\` that is not a CSF factory call (\`preview.meta(...)\`) and has no default export`,
741
+ );
742
+ } else if (!namedMeta && !hasDefault) {
743
+ violations.push(`${rel} — no default export (classic CSF needs \`export default meta\`)`);
744
+ }
745
+ };
746
+ for (const dir of [PUBLIC_DIR, PACKAGES_DIR, APPS_DIR, FEATURES_DIR]) walkFiles(dir, scan);
747
+ return violations;
748
+ }
749
+
750
+ /**
751
+ * MPO-17: a `// mpo-drawing` pragma is not itself an exemption. The file
752
+ * must also be a DRAWING member in the spec table. Missing spec → skip
753
+ * (shc can run the oxlint rules before it copies the table).
754
+ */
755
+ function checkUndeclaredDrawings() {
756
+ const specPath = join(ROOT, "docs/theme-propagation-spec.md");
757
+ let registry;
758
+ try {
759
+ registry = readDrawingRegistry(specPath);
760
+ } catch (error) {
761
+ return {
762
+ violations: [`drawing registry: ${error instanceof Error ? error.message : String(error)}`],
763
+ ok: false,
764
+ };
765
+ }
766
+ if (!registry.present) return { violations: [], ok: true };
767
+ /** @type {string[]} */
768
+ const violations = [];
769
+ const scan = (filePath) => {
770
+ if (!/\.(tsx|ts|jsx|js)$/.test(filePath)) return;
771
+ const src = readFileSync(filePath, "utf8");
772
+ if (!DRAWING_PRAGMA.test(src)) return;
773
+ const rel = relative(ROOT, filePath);
774
+ if (isDrawingFile(rel, registry.files) || isDrawingFile(filePath, registry.files)) return;
775
+ violations.push(`${rel} — // mpo-drawing without a DRAWING Members row in ## Drawing files`);
776
+ };
777
+ for (const dir of [PUBLIC_DIR, PACKAGES_DIR, APPS_DIR, FEATURES_DIR]) walkFiles(dir, scan);
778
+ return { violations, ok: true };
779
+ }
780
+
655
781
  /**
656
782
  * Run structural convention checks over a consumer tree.
657
783
  *
@@ -663,6 +789,8 @@ export function runConventionChecks(options = {}) {
663
789
  const css = checkCssInApps();
664
790
  const twins = checkFeaturesTwins();
665
791
  const sizeRecipeEscape = checkSizeRecipeEscape();
792
+ const storyShape = checkStoryFileShape();
793
+ const undeclaredDrawings = checkUndeclaredDrawings();
666
794
  const tokens = checkTransitionTokens();
667
795
  const styleTokens = checkStyleTokens();
668
796
  const themeNames = checkThemeNames();
@@ -711,6 +839,24 @@ export function runConventionChecks(options = {}) {
711
839
  console.error("");
712
840
  }
713
841
 
842
+ if (storyShape.length) {
843
+ failed = true;
844
+ console.error(
845
+ 'Convention (MPO-93 STORY FILE SHAPE): a story file must be classic CSF (`export default meta`, no named `meta` export) or a CSF factory (`export const meta = preview.meta(...)`). Anything else makes Storybook\'s indexer fail the WHOLE gallery with "CSF: missing default export", and no package build ever sees it.\n',
846
+ );
847
+ for (const msg of storyShape) console.error(` ${msg}`);
848
+ console.error("");
849
+ }
850
+
851
+ if (undeclaredDrawings.violations.length) {
852
+ failed = true;
853
+ console.error(
854
+ "Convention (MPO-17 DRAWING REGISTRY): `// mpo-drawing` is only legal on a file listed as a DRAWING member in docs/theme-propagation-spec.md. Adding an undeclared drawing file still fails.\n",
855
+ );
856
+ for (const msg of undeclaredDrawings.violations) console.error(` ${msg}`);
857
+ console.error("");
858
+ }
859
+
714
860
  if (tokens.violations.length) {
715
861
  failed = true;
716
862
  console.error(
package/oxlint.d.ts CHANGED
@@ -2,6 +2,7 @@ export const COLOR_LITERAL: RegExp;
2
2
  export const PALETTE_FILE: RegExp;
3
3
  export const CHROME_PROPS: readonly string[];
4
4
  export const LAYOUT_PROPS: readonly string[];
5
+ export const GEOMETRY_PROPS: readonly string[];
5
6
  export function isPaletteModule(filename: string | undefined): boolean;
6
7
  export function matchColorLiteral(value: unknown): string | undefined;
7
8
  export const plugin: {