@multiplatform.one/config 7.6.3 → 7.7.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/lint.mjs CHANGED
@@ -27,6 +27,22 @@
27
27
  * whose path is not a DRAWING member in docs/theme-propagation-spec.md.
28
28
  * The oxlint rule skips registered drawings; this check is what makes
29
29
  * adding an undeclared drawing file still fail.
30
+ * 7. Fail on a METRO SUBPATH WITHOUT A FALLBACK (MPO-185) — a package.json
31
+ * subpath export in a source-consumed package whose Metro condition points
32
+ * into `dist/` with no `<pkg>/<subpath>.*` file on disk. Metro never
33
+ * reconsiders the map's `source` condition after the dist miss, so the
34
+ * first import of that subpath fails the whole bundle.
35
+ * 8. Fail on a REACT-NATIVE NAMED IMPORT WITH NO REACT-NATIVE-WEB EXPORT
36
+ * (MPO-199) — a `import { X } from "react-native"` in a file the web
37
+ * target resolves, where react-native-web exports no X. The alias makes
38
+ * that a module-evaluation SyntaxError, which takes the whole preview
39
+ * bundle down rather than one component.
40
+ * 9. Fail on an `import.meta` TOKEN in public/<pkg>/src (MPO-204) — in a
41
+ * classic script it is a PARSE-time SyntaxError, and two bundles execute
42
+ * mpo as one (the vxrn/rolldown Hermes bundle, and any Metro bundle for
43
+ * platform=web, i.e. an Expo DOM component). Node-only build tooling is
44
+ * exempt by construction: a file that imports a `node:` builtin cannot run
45
+ * in a browser or on Hermes anyway.
30
46
  *
31
47
  * Usage:
32
48
  * import { runConventionChecks } from "@multiplatform.one/config/lint";
@@ -253,12 +269,16 @@ function keysOfBlock(body, indent) {
253
269
  let sourceFileCache;
254
270
  function sourceFiles() {
255
271
  if (sourceFileCache) return sourceFileCache;
256
- /** @type {{ rel: string, src: string }[]} */
272
+ /** @type {{ abs: string, rel: string, src: string }[]} */
257
273
  const files = [];
258
274
  const collect = (filePath) => {
259
275
  if (!/\.(tsx|ts|jsx|js)$/.test(filePath)) return;
260
276
  if (/\.(spec|test)\./.test(filePath)) return;
261
- files.push({ rel: relative(ROOT, filePath), src: readFileSync(filePath, "utf8") });
277
+ files.push({
278
+ abs: filePath,
279
+ rel: relative(ROOT, filePath),
280
+ src: readFileSync(filePath, "utf8"),
281
+ });
262
282
  };
263
283
  for (const dir of [PUBLIC_DIR, APPS_DIR, FEATURES_DIR]) walkFiles(dir, collect);
264
284
  sourceFileCache = files;
@@ -778,6 +798,391 @@ function checkUndeclaredDrawings() {
778
798
  return { violations, ok: true };
779
799
  }
780
800
 
801
+ /**
802
+ * MPO-109 WORKSPACE PATHS SHADOW — `compilerOptions.paths` REPLACES the
803
+ * inherited map, it never merges with it. `tsconfig.base.json` extends the
804
+ * generated `tamagui-workspace-paths.generated.json`, so one local entry in a
805
+ * package tsconfig hides every `@multiplatform.one/*` mapping from that
806
+ * program and the whole package fails to resolve its own workspace. That is
807
+ * where apps/one's 578 typecheck errors came from, and features, apps/storybook,
808
+ * apps/storybook-expo, apps/uxpin and public/keycloak each carried the same
809
+ * override.
810
+ *
811
+ * The fix for a deep-import alias is the package's own `exports` map, which the
812
+ * generator reads. Escape (rare, for a program that deliberately does not
813
+ * extend the base): `// workspace-paths-escape:` with a reason on the line
814
+ * above `"paths"`.
815
+ */
816
+ function checkTsconfigWorkspacePaths() {
817
+ /** @type {string[]} */
818
+ const violations = [];
819
+ /** @type {string[]} */
820
+ const files = [];
821
+ const featuresTsconfig = join(FEATURES_DIR, "tsconfig.json");
822
+ if (existsSync(featuresTsconfig)) files.push(featuresTsconfig);
823
+ for (const dir of [APPS_DIR, PACKAGES_DIR, PUBLIC_DIR]) {
824
+ if (!existsSync(dir)) continue;
825
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
826
+ if (!entry.isDirectory()) continue;
827
+ const candidate = join(dir, entry.name, "tsconfig.json");
828
+ if (existsSync(candidate)) files.push(candidate);
829
+ }
830
+ }
831
+ for (const file of files) {
832
+ const src = readFileSync(file, "utf8");
833
+ // Whole-line comments only: a `//` inside a string (the $schema URL) must
834
+ // survive, and these files are JSONC, not JSON5.
835
+ const stripped = src.replace(/^[ \t]*\/\/.*$/gm, "");
836
+ let doc;
837
+ try {
838
+ doc = JSON.parse(stripped);
839
+ } catch {
840
+ continue;
841
+ }
842
+ const paths = doc?.compilerOptions?.paths;
843
+ if (!paths || typeof paths !== "object") continue;
844
+ const extendsField = doc.extends;
845
+ const extendsBase = Array.isArray(extendsField)
846
+ ? extendsField.some((e) => typeof e === "string" && e.includes("tsconfig.base"))
847
+ : typeof extendsField === "string" && extendsField.includes("tsconfig.base");
848
+ if (!extendsBase) continue;
849
+ const decl = src.match(/^[ \t]*"paths"\s*:/m);
850
+ const index = decl?.index ?? 0;
851
+ const before = src.slice(0, index);
852
+ if (/workspace-paths-escape:/.test(before.split("\n").slice(-3).join("\n"))) continue;
853
+ violations.push(
854
+ `${relative(ROOT, file)}:${lineAt(src, index)} — declares \`compilerOptions.paths\` (${Object.keys(paths).join(", ")}) while extending tsconfig.base`,
855
+ );
856
+ }
857
+ return violations;
858
+ }
859
+
860
+ /**
861
+ * Metro (MPO-185): the condition names Metro matches, per platform, when
862
+ * `resolver.unstable_enablePackageExports` is on. `require`/`import` are
863
+ * Metro's own defaults; Expo's metro-config adds `react-native` for ios and
864
+ * android and `browser` for web. `default` always matches. Keys are matched
865
+ * in the ORDER the exports map declares them, first hit wins, and neither
866
+ * `source` nor `types` is ever in the set.
867
+ */
868
+ const METRO_PLATFORM_CONDITIONS = {
869
+ ios: new Set(["react-native", "import", "require", "default"]),
870
+ web: new Set(["browser", "import", "require", "default"]),
871
+ };
872
+ /** What Metro's file-based fallback will accept at `<pkg>/<subpath>`. */
873
+ const METRO_FALLBACK_EXTS = ["ts", "tsx", "js", "jsx", "mjs", "cjs"];
874
+
875
+ /**
876
+ * First target Metro would pick from an exports-map entry for one condition
877
+ * set, walking nested condition objects in declaration order.
878
+ * @param {unknown} target
879
+ * @param {Set<string>} conditions
880
+ * @returns {string | null}
881
+ */
882
+ function metroExportTarget(target, conditions) {
883
+ if (typeof target === "string") return target;
884
+ if (Array.isArray(target)) {
885
+ for (const item of target) {
886
+ const hit = metroExportTarget(item, conditions);
887
+ if (hit) return hit;
888
+ }
889
+ return null;
890
+ }
891
+ if (!target || typeof target !== "object") return null;
892
+ for (const [key, value] of Object.entries(target)) {
893
+ if (!conditions.has(key)) continue;
894
+ const hit = metroExportTarget(value, conditions);
895
+ if (hit) return hit;
896
+ }
897
+ return null;
898
+ }
899
+
900
+ /**
901
+ * @param {unknown} target
902
+ * @returns {boolean} true when any condition in the entry is `source`
903
+ */
904
+ function declaresSourceCondition(target) {
905
+ if (!target || typeof target !== "object") return false;
906
+ if (Array.isArray(target)) return target.some(declaresSourceCondition);
907
+ return Object.entries(target).some(
908
+ ([key, value]) => key === "source" || declaresSourceCondition(value),
909
+ );
910
+ }
911
+
912
+ /**
913
+ * @param {string} pkgRoot
914
+ * @param {string} subpath an exports key such as `./seam`
915
+ */
916
+ function hasMetroFallback(pkgRoot, subpath) {
917
+ const base = join(pkgRoot, subpath.replace(/^\.\//, ""));
918
+ if (METRO_FALLBACK_EXTS.some((ext) => existsSync(`${base}.${ext}`))) return true;
919
+ if (existsSync(join(base, "package.json"))) return true;
920
+ return METRO_FALLBACK_EXTS.some((ext) => existsSync(join(base, `index.${ext}`)));
921
+ }
922
+
923
+ /**
924
+ * Metro subpath fallback (MPO-185).
925
+ *
926
+ * Metro reads the exports map, picks the first condition it recognises, and
927
+ * if that file is missing it does NOT reconsider `source` or `default`: it
928
+ * warns and retries as a plain file lookup for `<pkg>/<subpath>.*`. The
929
+ * packages under public/ and packages/ are consumed from source and never
930
+ * built, so a subpath whose Metro condition points into `dist/` fails the
931
+ * whole bundle the first time anything in a Metro graph imports it. The `.`
932
+ * export is exempt because its file fallback lands on the package root, where
933
+ * `main`/`module` point at src.
934
+ *
935
+ * A package is source-consumed when the entry or the package declares a
936
+ * `source` condition, or `main` points into src/. A genuinely built package
937
+ * (keycloak-js: no `source`, `main` in dist, `prepare` builds it) is skipped.
938
+ */
939
+ function checkMetroSubpathFallbacks() {
940
+ /** @type {string[]} */
941
+ const violations = [];
942
+ for (const dir of [PUBLIC_DIR, PACKAGES_DIR]) {
943
+ if (!existsSync(dir)) continue;
944
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
945
+ if (!entry.isDirectory()) continue;
946
+ const pkgRoot = join(dir, entry.name);
947
+ const pkgJsonPath = join(pkgRoot, "package.json");
948
+ if (!existsSync(pkgJsonPath)) continue;
949
+ let pkg;
950
+ try {
951
+ pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
952
+ } catch {
953
+ continue;
954
+ }
955
+ const exportsMap = pkg?.exports;
956
+ if (!exportsMap || typeof exportsMap !== "object" || Array.isArray(exportsMap)) continue;
957
+ const packageFromSource =
958
+ typeof pkg.source === "string" ||
959
+ (typeof pkg.main === "string" && /^(\.\/)?src\//.test(pkg.main)) ||
960
+ declaresSourceCondition(exportsMap["."]);
961
+ for (const [subpath, target] of Object.entries(exportsMap)) {
962
+ if (!subpath.startsWith("./") || subpath.includes("*")) continue;
963
+ if (!packageFromSource && !declaresSourceCondition(target)) continue;
964
+ if (hasMetroFallback(pkgRoot, subpath)) continue;
965
+ for (const [platform, conditions] of Object.entries(METRO_PLATFORM_CONDITIONS)) {
966
+ const resolved = metroExportTarget(target, conditions);
967
+ if (!resolved || !resolved.startsWith("./dist/")) continue;
968
+ violations.push(
969
+ `${relative(ROOT, pkgJsonPath)} — "${subpath}" resolves to ${resolved} on ${platform}, which is unbuilt; add ${relative(ROOT, join(pkgRoot, `${subpath.replace(/^\.\//, "")}.ts`))} re-exporting the source entry`,
970
+ );
971
+ break;
972
+ }
973
+ }
974
+ }
975
+ }
976
+ return violations;
977
+ }
978
+
979
+ /**
980
+ * react-native-web's export list, read from the copy installed under ROOT.
981
+ *
982
+ * `dist/index.js` is the file the web target evaluates — it is RNW's `module`
983
+ * entry and what the storybook alias resolves `react-native` to — so its
984
+ * `export { … }` clauses ARE the vocabulary. Reading them is what keeps this
985
+ * check from drifting when RNW is upgraded; a hand-kept list would go stale
986
+ * the first time upstream adds or drops an export.
987
+ *
988
+ * @returns {Set<string> | undefined} undefined when RNW is not installed, which
989
+ * disables the check rather than failing a partial install.
990
+ */
991
+ function readReactNativeWebExports() {
992
+ const entry = join(ROOT, "node_modules", "react-native-web", "dist", "index.js");
993
+ if (!existsSync(entry)) return undefined;
994
+ /** @type {Set<string>} */
995
+ const names = new Set();
996
+ for (const clause of readFileSync(entry, "utf8").matchAll(/export\s*\{([^}]*)\}/g)) {
997
+ for (const specifier of clause[1].split(",")) {
998
+ const name = specifier
999
+ .trim()
1000
+ .split(/\s+as\s+/)
1001
+ .pop();
1002
+ if (name) names.add(name);
1003
+ }
1004
+ }
1005
+ return names.size ? names : undefined;
1006
+ }
1007
+
1008
+ /** Platform-suffixed files the web resolver never reaches. */
1009
+ const NATIVE_ONLY_FILE_RE = /\.(native|ios|android)\.(tsx|ts|jsx|js)$/;
1010
+
1011
+ /**
1012
+ * `import … from "react-native"`, including the multi-line brace form.
1013
+ *
1014
+ * The body may contain no quote and no `;`, which is what stops the non-greedy
1015
+ * match from swallowing an earlier `import … from "react"` and attributing
1016
+ * ITS names to react-native.
1017
+ */
1018
+ const REACT_NATIVE_IMPORT_RE = /import\s+([^;'"]*?)from\s*["']react-native["']/g;
1019
+
1020
+ /** @param {string} absPath @param {string} dir */
1021
+ function isUnder(absPath, dir) {
1022
+ const rel = relative(dir, absPath);
1023
+ return rel !== "" && !rel.startsWith("..");
1024
+ }
1025
+
1026
+ /**
1027
+ * react-native named import with no react-native-web counterpart (MPO-199).
1028
+ *
1029
+ * Every web build aliases `react-native` to `react-native-web`
1030
+ * (apps/storybook/.storybook/main.ts, createStorybookViteConfig, the app vite
1031
+ * config). RNW does not implement all of react-native, so a NAMED import of
1032
+ * something it never exports is not a missing feature at runtime — it is a
1033
+ * module-evaluation SyntaxError, and one failing module takes the WHOLE bundle
1034
+ * with it. That is how `import { ActionSheetIOS } from "react-native"` in
1035
+ * Select (2b94d9eeb) made every story in the gallery render blank, not just
1036
+ * Select's.
1037
+ *
1038
+ * A `Platform.OS` guard inside the file cannot save it: the import fails
1039
+ * before any code runs. The two legal shapes are a namespace import, which
1040
+ * lands on `undefined` for a missing name (8c62c2a6f, the fix), and a
1041
+ * `.native.tsx` / `.ios.tsx` sibling the web resolver never reaches.
1042
+ *
1043
+ * Scope is public/ and features/ — the trees the preview bundle is built from.
1044
+ * Type-only specifiers are erased before any bundler sees them, so
1045
+ * `import type { GestureResponderEvent }` is fine and stays fine.
1046
+ */
1047
+ function checkReactNativeWebExports() {
1048
+ const exported = readReactNativeWebExports();
1049
+ if (!exported) return { ok: false, violations: /** @type {string[]} */ ([]) };
1050
+ /** @type {string[]} */
1051
+ const violations = [];
1052
+ for (const { abs, rel, src } of sourceFiles()) {
1053
+ if (!isUnder(abs, PUBLIC_DIR) && !isUnder(abs, FEATURES_DIR)) continue;
1054
+ if (NATIVE_ONLY_FILE_RE.test(abs)) continue;
1055
+ if (!src.includes("react-native")) continue;
1056
+ for (const statement of src.matchAll(REACT_NATIVE_IMPORT_RE)) {
1057
+ const clause = statement[1].trim();
1058
+ if (/^type\b/.test(clause)) continue;
1059
+ const named = clause.match(/\{([\s\S]*)\}/);
1060
+ // No brace group means a default or `* as ns` import, and a namespace
1061
+ // read of a missing name is `undefined`, never a SyntaxError.
1062
+ if (!named) continue;
1063
+ for (const specifier of named[1].split(",")) {
1064
+ const entry = specifier.trim();
1065
+ if (!entry || /^type\b/.test(entry)) continue;
1066
+ const name = entry.split(/\s+as\s+/)[0].trim();
1067
+ if (!name || exported.has(name)) continue;
1068
+ violations.push(
1069
+ `${rel}:${lineAt(src, statement.index ?? 0)} — ${name} (react-native-web exports no ${name})`,
1070
+ );
1071
+ }
1072
+ }
1073
+ }
1074
+ return { ok: true, violations };
1075
+ }
1076
+
1077
+ /**
1078
+ * Offsets in `src` that are live CODE — not inside a comment, a string or a
1079
+ * template literal. One left-to-right pass. Regex literals are treated as
1080
+ * code, and a `${…}` interpolation is treated as string, which can only ever
1081
+ * make this check miss something, never invent a violation.
1082
+ *
1083
+ * @param {string} src
1084
+ * @returns {Uint8Array} 1 at every code offset
1085
+ */
1086
+ function codeOffsets(src) {
1087
+ const mask = new Uint8Array(src.length);
1088
+ let i = 0;
1089
+ while (i < src.length) {
1090
+ const char = src[i];
1091
+ const next = src[i + 1];
1092
+ if (char === "/" && next === "/") {
1093
+ while (i < src.length && src[i] !== "\n") i++;
1094
+ continue;
1095
+ }
1096
+ if (char === "/" && next === "*") {
1097
+ i += 2;
1098
+ while (i < src.length && !(src[i] === "*" && src[i + 1] === "/")) i++;
1099
+ i += 2;
1100
+ continue;
1101
+ }
1102
+ if (char === '"' || char === "'" || char === "`") {
1103
+ const quote = char;
1104
+ i++;
1105
+ while (i < src.length) {
1106
+ if (src[i] === "\\") {
1107
+ i += 2;
1108
+ continue;
1109
+ }
1110
+ if (src[i] === quote) {
1111
+ i++;
1112
+ break;
1113
+ }
1114
+ i++;
1115
+ }
1116
+ continue;
1117
+ }
1118
+ mask[i] = 1;
1119
+ i++;
1120
+ }
1121
+ return mask;
1122
+ }
1123
+
1124
+ const IMPORT_META_RE = /\bimport\s*\.\s*meta\b/g;
1125
+ const IMPORT_META_SCANNED_RE = /\.(tsx|ts|jsx|js|mjs|cjs|mts|cts)$/;
1126
+
1127
+ /**
1128
+ * A VALUE import of a node: builtin. `import type { … } from "node:fs"` is
1129
+ * erased, so it proves nothing about where the file runs and does not count.
1130
+ */
1131
+ const NODE_BUILTIN_IMPORT_RE =
1132
+ /(?:^|\n)\s*import\s+(?!type\s)[^;'"]*from\s*["']node:[^"']+["']|require\(\s*["']node:[^"']+["']\s*\)|import\(\s*["']node:[^"']+["']\s*\)/;
1133
+
1134
+ /**
1135
+ * `import.meta` in a public package's runtime source (MPO-204).
1136
+ *
1137
+ * MPO-192's ruling, generalised. `import.meta` is legal only in an ES module;
1138
+ * in a CLASSIC script it is a PARSE-time SyntaxError, so no `typeof
1139
+ * import.meta` guard and no try/catch around it ever gets to run — the file
1140
+ * simply never parses and takes its consumers down with it, silently. Two
1141
+ * bundles execute mpo as a classic script: the vxrn/rolldown native bundle for
1142
+ * Hermes, and any Metro bundle built for platform=web, which is exactly what
1143
+ * an Expo DOM component (`'use dom'`) is. A `.web.ts` twin is not an escape
1144
+ * either: One's web build and tamagui-build resolve `.web.ts` the way Metro
1145
+ * does, so a twin lands in the Vite build too.
1146
+ *
1147
+ * The replacement is a static `process.env.<KEY>` member read inside a
1148
+ * try/catch (public/platform/src/config/runtimeConfig.ts), or, for an SSR
1149
+ * flag, `isServer` from @multiplatform.one/platform, which is split at the
1150
+ * FILE level rather than by a bundler define.
1151
+ *
1152
+ * Exempt, by construction rather than by allowlist: a file that imports a
1153
+ * `node:` builtin as a value is Node-only build tooling
1154
+ * (@multiplatform.one/config's vite/vitest configs, the CLI,
1155
+ * @multiplatform.one/utils/dev) and could not run in a browser or on Hermes
1156
+ * whatever it did with import.meta. Specs are exempt for the same reason —
1157
+ * vitest runs them in Node and provides `import.meta.env` itself.
1158
+ */
1159
+ function checkImportMetaInPublicSource() {
1160
+ /** @type {string[]} */
1161
+ const violations = [];
1162
+ if (!existsSync(PUBLIC_DIR)) return violations;
1163
+ for (const pkg of readdirSync(PUBLIC_DIR, { withFileTypes: true })) {
1164
+ if (!pkg.isDirectory() || pkg.name.startsWith(".") || pkg.name === "node_modules") continue;
1165
+ walkFiles(join(PUBLIC_DIR, pkg.name, "src"), (filePath) => {
1166
+ if (!IMPORT_META_SCANNED_RE.test(filePath)) return;
1167
+ if (/\.(spec|test)\./.test(filePath)) return;
1168
+ const src = readFileSync(filePath, "utf8");
1169
+ if (!IMPORT_META_RE.test(src)) {
1170
+ IMPORT_META_RE.lastIndex = 0;
1171
+ return;
1172
+ }
1173
+ IMPORT_META_RE.lastIndex = 0;
1174
+ if (NODE_BUILTIN_IMPORT_RE.test(src)) return;
1175
+ const mask = codeOffsets(src);
1176
+ for (const match of src.matchAll(IMPORT_META_RE)) {
1177
+ const index = match.index ?? 0;
1178
+ if (mask[index] !== 1) continue;
1179
+ violations.push(`${relative(ROOT, filePath)}:${lineAt(src, index)}`);
1180
+ }
1181
+ });
1182
+ }
1183
+ return violations;
1184
+ }
1185
+
781
1186
  /**
782
1187
  * Run structural convention checks over a consumer tree.
783
1188
  *
@@ -791,6 +1196,10 @@ export function runConventionChecks(options = {}) {
791
1196
  const sizeRecipeEscape = checkSizeRecipeEscape();
792
1197
  const storyShape = checkStoryFileShape();
793
1198
  const undeclaredDrawings = checkUndeclaredDrawings();
1199
+ const tsconfigPaths = checkTsconfigWorkspacePaths();
1200
+ const metroFallbacks = checkMetroSubpathFallbacks();
1201
+ const rnWebExports = checkReactNativeWebExports();
1202
+ const importMeta = checkImportMetaInPublicSource();
794
1203
  const tokens = checkTransitionTokens();
795
1204
  const styleTokens = checkStyleTokens();
796
1205
  const themeNames = checkThemeNames();
@@ -857,6 +1266,42 @@ export function runConventionChecks(options = {}) {
857
1266
  console.error("");
858
1267
  }
859
1268
 
1269
+ if (tsconfigPaths.length) {
1270
+ failed = true;
1271
+ console.error(
1272
+ "Convention (MPO-109 WORKSPACE PATHS SHADOW): `compilerOptions.paths` REPLACES the map tsconfig.base.json inherits from tamagui-workspace-paths.generated.json, so one local entry hides every `@multiplatform.one/*` mapping from that program. Declare the alias in the target package's `exports` (the generator reads it) instead. Escape with `// workspace-paths-escape: <reason>` above the key.\n",
1273
+ );
1274
+ for (const msg of tsconfigPaths) console.error(` ${msg}`);
1275
+ console.error("");
1276
+ }
1277
+
1278
+ if (metroFallbacks.length) {
1279
+ failed = true;
1280
+ console.error(
1281
+ "Convention (MPO-185 METRO SUBPATH FALLBACK): a subpath export in a source-consumed package must have a `<pkg>/<subpath>.ts` file re-exporting its source entry. Metro resolves the exports map to dist/, finds nothing (public/ is never built), and falls back to a FILE lookup at that path — it never reconsiders `source`, so the first import of the subpath fails the whole bundle. A genuinely built package carries no `source` condition and is skipped.\n",
1282
+ );
1283
+ for (const msg of metroFallbacks) console.error(` ${msg}`);
1284
+ console.error("");
1285
+ }
1286
+
1287
+ if (rnWebExports.violations.length) {
1288
+ failed = true;
1289
+ console.error(
1290
+ 'Convention (MPO-199 REACT-NATIVE-WEB EXPORT): a NAMED import from "react-native" must name something react-native-web exports. Every web build aliases react-native to react-native-web, so a name it does not export is a module-evaluation SyntaxError that takes the WHOLE bundle down — every story in the gallery, not just the importing component — and a Platform.OS guard cannot help, because the import fails before any code runs. Read it off a namespace import (`import * as ReactNative from "react-native"`) or move the call into a .native.tsx sibling. `import type { … }` is erased and never counts.\n',
1291
+ );
1292
+ for (const msg of rnWebExports.violations) console.error(` ${msg}`);
1293
+ console.error("");
1294
+ }
1295
+
1296
+ if (importMeta.length) {
1297
+ failed = true;
1298
+ console.error(
1299
+ "Convention (MPO-204 NO import.meta IN public/*/src): `import.meta` is legal only in an ES module. In a CLASSIC script it is a PARSE-time SyntaxError, so no `typeof import.meta` guard and no try/catch around it ever runs — the file never parses and takes its consumers with it, silently. Two bundles execute mpo as a classic script: the vxrn/rolldown native bundle for Hermes, and any Metro bundle for platform=web, which is what an Expo DOM component (`'use dom'`) is. Read a static `process.env.<KEY>` member expression inside try/catch instead (public/platform/src/config/runtimeConfig.ts), or take an SSR flag from `isServer` in @multiplatform.one/platform. A .web.ts twin is not an escape — One's web build and tamagui-build resolve it like Metro. Node-only build tooling (a file importing a `node:` builtin) and specs are exempt.\n",
1300
+ );
1301
+ for (const msg of importMeta) console.error(` ${msg}`);
1302
+ console.error("");
1303
+ }
1304
+
860
1305
  if (tokens.violations.length) {
861
1306
  failed = true;
862
1307
  console.error(
@@ -897,6 +1342,7 @@ export function runConventionChecks(options = {}) {
897
1342
  ["style token", styleTokens.ok],
898
1343
  ["theme name", themeNames.ok],
899
1344
  ["intent", intents.ok],
1345
+ ["react-native-web export", rnWebExports.ok],
900
1346
  ]) {
901
1347
  if (!ok) {
902
1348
  console.warn(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/config",
3
- "version": "7.6.3",
3
+ "version": "7.7.1",
4
4
  "description": "Shared build and test configuration presets for multiplatform.one",
5
5
  "keywords": [
6
6
  "config",
@@ -102,7 +102,7 @@
102
102
  "vite-plugin-external": "^6.2.2",
103
103
  "vite-plugin-i18next-loader": "^3.1.3",
104
104
  "vitest": "^4.1.5",
105
- "@multiplatform.one/utils": "7.6.3"
105
+ "@multiplatform.one/utils": "7.7.1"
106
106
  },
107
107
  "devDependencies": {
108
108
  "tsdown": "^0.21.10",
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The shared `types` array is load-bearing — MPO-109.
3
+ *
4
+ * `public/config/tsconfig/base.json` sets `compilerOptions.types`, which turns
5
+ * OFF automatic @types inclusion for every program in the workspace. Anything
6
+ * missing from that list is simply absent, with no error at the point of loss.
7
+ *
8
+ * `chai` is the one that bit. @vitest/expect declares
9
+ *
10
+ * interface Assertion<T> extends VitestAssertion<Chai.Assertion, T>, ...
11
+ *
12
+ * so with no global `Chai` namespace the base interface collapses and takes
13
+ * `.not` and the jest-dom matchers with it. Measured on 2026-09-04 against
14
+ * origin/main cb157dd0f: public/components 421 -> 26, public/forms 343 -> 4,
15
+ * features 168 -> 138, public/utils 4 -> 0, and no package got worse.
16
+ *
17
+ * Same shape as the workspace `paths` map: a config key that REPLACES rather
18
+ * than merges, quietly dropping what it did not enumerate.
19
+ */
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import { describe, expect, it } from "vitest";
23
+
24
+ const baseTsconfig = path.resolve(__dirname, "../tsconfig/base.json");
25
+
26
+ /** JSONC: whole-line `//` comments only, plus trailing commas. */
27
+ function readJsonc(file: string): any {
28
+ const src = fs.readFileSync(file, "utf8");
29
+ return JSON.parse(src.replace(/^[ \t]*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1"));
30
+ }
31
+
32
+ describe("base tsconfig types (MPO-109)", () => {
33
+ it("keeps chai in the explicit types array", () => {
34
+ const types = readJsonc(baseTsconfig)?.compilerOptions?.types;
35
+ expect(types).toContain("chai");
36
+ });
37
+
38
+ it("keeps the jest-dom vitest matchers alongside it", () => {
39
+ const types = readJsonc(baseTsconfig)?.compilerOptions?.types;
40
+ expect(types).toContain("@testing-library/jest-dom/vitest");
41
+ });
42
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Emitting programs must not inherit the workspace `paths` map — MPO-109.
3
+ *
4
+ * `tsconfig.base.json` extends `tamagui-workspace-paths.generated.json`, which
5
+ * points every `@multiplatform.one/*` specifier at a sibling's repo-relative
6
+ * `src/`. That is right for a typecheck program and wrong for one that emits
7
+ * declarations under a `rootDir`: a `paths` hit makes the sibling source a file
8
+ * OF THAT PROGRAM, so `rootDir` rejects it —
9
+ *
10
+ * TS6059 File 'public/utils/src/dev.ts' is not under rootDir '.../src'
11
+ * TS6307 ... is not listed within the file list of project ...
12
+ *
13
+ * Every emitting package clears the map and resolves siblings through their
14
+ * exports `source` condition instead (node_modules-external under
15
+ * preserveSymlinks, so `rootDir` never sees them). The three vite plugins had
16
+ * no build config at all and compiled straight off the base, which is how
17
+ * `@multiplatform.one/utils/dev` broke `build-gnome`. The bare-name form was
18
+ * already reachable on main, so this guard exists to stop the next one.
19
+ */
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import { describe, expect, it } from "vitest";
23
+
24
+ const root = path.resolve(__dirname, "../../..");
25
+ const publicDir = path.join(root, "public");
26
+
27
+ /** JSONC: whole-line `//` comments only, plus trailing commas. */
28
+ function readJsonc(file: string): any {
29
+ const src = fs.readFileSync(file, "utf8");
30
+ return JSON.parse(src.replace(/^[ \t]*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1"));
31
+ }
32
+
33
+ function extendsBase(doc: any): boolean {
34
+ const value = doc?.extends;
35
+ const list = Array.isArray(value) ? value : [value];
36
+ return list.some((entry) => typeof entry === "string" && entry.includes("tsconfig.base"));
37
+ }
38
+
39
+ /** The config each package's `build` script actually hands to tsc. */
40
+ function buildConfigs(): { pkg: string; file: string; doc: any }[] {
41
+ const found: { pkg: string; file: string; doc: any }[] = [];
42
+ for (const entry of fs.readdirSync(publicDir, { withFileTypes: true })) {
43
+ if (!entry.isDirectory()) continue;
44
+ const pkgJson = path.join(publicDir, entry.name, "package.json");
45
+ if (!fs.existsSync(pkgJson)) continue;
46
+ const build = JSON.parse(fs.readFileSync(pkgJson, "utf8"))?.scripts?.build;
47
+ if (typeof build !== "string") continue;
48
+ const named = build.match(/tsc\s+(?:-p|--project)\s+(\S+)/)?.[1];
49
+ const file = path.join(publicDir, entry.name, named ?? "tsconfig.json");
50
+ if (!/\btsc\b/.test(build) || !fs.existsSync(file)) continue;
51
+ found.push({ pkg: entry.name, file, doc: readJsonc(file) });
52
+ }
53
+ return found;
54
+ }
55
+
56
+ describe("emitting tsconfigs (MPO-109)", () => {
57
+ it("finds the build configs", () => {
58
+ expect(buildConfigs().length).toBeGreaterThan(10);
59
+ });
60
+
61
+ it("never lets a rootDir-constrained emitting program inherit the workspace paths map", () => {
62
+ const offenders = buildConfigs()
63
+ .filter(({ doc }) => {
64
+ const co = doc?.compilerOptions ?? {};
65
+ if (co.noEmit === true || co.rootDir === undefined) return false;
66
+ if (!extendsBase(doc)) return false;
67
+ // Clearing the inherited map is the fix; `{}` counts.
68
+ return co.paths === undefined;
69
+ })
70
+ .map(({ pkg, file }) => `${pkg} (${path.relative(root, file)})`);
71
+ expect(offenders).toEqual([]);
72
+ });
73
+
74
+ it("gives every such program the exports `source` condition to resolve siblings with", () => {
75
+ const missing = buildConfigs()
76
+ .filter(({ doc }) => {
77
+ const co = doc?.compilerOptions ?? {};
78
+ if (co.noEmit === true || co.rootDir === undefined) return false;
79
+ if (!extendsBase(doc)) return false;
80
+ return !(co.customConditions ?? []).includes("source");
81
+ })
82
+ .map(({ pkg }) => pkg);
83
+ expect(missing).toEqual([]);
84
+ });
85
+ });