@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/lib/index.js +2 -2
- package/lib/{storybook-BnfZmzIL.js → storybook-BuO1e8Sb.js} +98 -1
- package/lib/storybook.js +1 -1
- package/lib/{vite-W9hIeg2_.js → vite-uwqTTN8M.js} +115 -2
- package/lib/vite.js +1 -1
- package/lint.mjs +448 -2
- package/package.json +2 -2
- package/src/baseTsconfigTypes.spec.ts +42 -0
- package/src/emittingTsconfigPaths.spec.ts +85 -0
- package/src/lint.spec.ts +379 -0
- package/src/singletonDepAliases.spec.ts +83 -0
- package/src/singletonDepAliases.ts +105 -0
- package/src/storybook.ts +16 -0
- package/src/tamaguiWorkspacePaths.spec.ts +34 -0
- package/src/vite.ts +165 -1
- package/tsconfig/base.json +10 -1
- package/types/singletonDepAliases.d.ts +12 -0
- package/types/singletonDepAliases.d.ts.map +1 -0
- package/types/storybook.d.ts.map +1 -1
- package/types/vite.d.ts.map +1 -1
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({
|
|
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.
|
|
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.
|
|
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
|
+
});
|