@multiplatform.one/config 7.11.0 → 7.16.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/README.md +35 -10
- package/lib/index.js +2 -2
- package/lib/{storybook-CGNSgSBS.js → storybook-DFdvDJ_s.js} +3 -1
- package/lib/storybook.js +1 -1
- package/lib/{vite-D6vMjPwv.js → vite-DXXr5In0.js} +167 -12
- package/lib/vite.js +3 -3
- package/lib/{workspacePublicPackages-B4FGgRYG.js → workspacePublicPackages-BuYRXxFs.js} +10 -3
- package/lint.d.ts +8 -0
- package/lint.mjs +350 -13
- package/oxlint.d.ts +2 -0
- package/oxlint.json +21 -0
- package/oxlint.mjs +168 -1
- package/package.json +8 -4
- package/spec-registry.mjs +183 -0
- package/src/lint.spec.ts +493 -1
- package/src/oxlint.spec.ts +172 -0
- package/src/oxlintPreset.spec.ts +65 -0
- package/src/publicConfig.spec.ts +272 -0
- package/src/publicConfig.ts +170 -0
- package/src/singletonDepAliases.spec.ts +52 -0
- package/src/singletonDepAliases.ts +10 -3
- package/src/spec-registry.spec.ts +107 -0
- package/src/storybook.spec.ts +2 -0
- package/src/storybook.ts +7 -0
- package/src/vite.spec.ts +58 -2
- package/src/vite.ts +53 -8
- package/types/publicConfig.d.ts +36 -0
- package/types/publicConfig.d.ts.map +1 -0
- package/types/singletonDepAliases.d.ts.map +1 -1
- package/types/storybook.d.ts.map +1 -1
- package/types/vite.d.ts +13 -4
- package/types/vite.d.ts.map +1 -1
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
|
|
199
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|