@godxjp/ui 32.12.1 → 32.14.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.
@@ -10,7 +10,10 @@
10
10
  * Transforms (each one idempotent: running twice changes nothing the second time):
11
11
  * 1. fonts are opt-in (gh#1221): after an import of "@godxjp/ui/styles", add the fonts import.
12
12
  * 2. with --godx (gh#1220): add the preset stylesheet after it, and `preset={godxPreset}` (+ its
13
- * import) to every `<AppProvider` that has no `preset`.
13
+ * import) to every element bound to @godxjp/ui's AppProvider (aliases followed) that has no
14
+ * `preset`. An app's own component that happens to be named AppProvider is not one (gh#1327).
15
+ * Rules only rewrite code: never string or template text, comments, or JSX text, and a file no
16
+ * rule applies to is left byte-identical (gh#1327).
14
17
  * 3. component renames/moves (gh#1223): import specifiers rewritten from RENAMES below.
15
18
  * Reported, not changed: an `<AppProvider` with no `defaultLocale` and no preset now starts in `en`
16
19
  * (or `<html lang>`), not `vi` (gh#1219).
@@ -304,6 +307,248 @@ const GODX_TOKENS_CSS = "@godxjp/ui/themes/godx-tokens.css";
304
307
  const CORE_IMPORT_CSS = /^([ \t]*)@import\s+(["'])@godxjp\/ui\/styles\/core\2\s*;[ \t]*$/m;
305
308
  const CORE_IMPORT_JS = /^([ \t]*)import\s+(["'])@godxjp\/ui\/styles\/core\2\s*;?[ \t]*$/m;
306
309
 
310
+ /**
311
+ * gh#1327: which characters of `source` are code. String and template text, regex literals,
312
+ * comments and JSX text are not, so no rule ever rewrites `"…within <AppProvider>"` or a
313
+ * commented-out import. A small hand lexer, not a parser: the codemod ships with no dependencies.
314
+ * Where it is unsure (a TS generic arrow in .tsx), it treats the text as code, as before.
315
+ */
316
+ export function codeMask(file, src) {
317
+ const n = src.length;
318
+ const code = new Uint8Array(n);
319
+ const mark = (from, to) => code.fill(1, from, Math.min(to, n));
320
+ if (file.endsWith(".css")) {
321
+ let i = 0;
322
+ while (i < n) {
323
+ const c = src[i];
324
+ if (c === "/" && src[i + 1] === "*") {
325
+ const end = src.indexOf("*/", i + 2);
326
+ i = end < 0 ? n : end + 2;
327
+ } else if (c === '"' || c === "'") {
328
+ i = skipQuoted(src, i);
329
+ } else {
330
+ code[i] = 1;
331
+ i += 1;
332
+ }
333
+ }
334
+ return code;
335
+ }
336
+ const jsx = !/\.[mc]?ts$/.test(file);
337
+ const isId = (c) => c !== undefined && /[\w$]/.test(c);
338
+ const EXPR_WORDS =
339
+ /^(return|case|default|yield|await|typeof|void|delete|in|of|new|else|do|throw|instanceof)$/;
340
+
341
+ // Code until an unmatched `}` (when `closer`) or the end. Returns the index of that `}`.
342
+ function scanCode(i, closer) {
343
+ let depth = 0;
344
+ let prev = ""; // last significant character
345
+ let word = ""; // last identifier, when prev ended one
346
+ const exprStart = () =>
347
+ prev === "" || "([{,;:=!&|?+-*%^~<>}".includes(prev) || EXPR_WORDS.test(word);
348
+ while (i < n) {
349
+ const c = src[i];
350
+ if (c === "/" && src[i + 1] === "/") {
351
+ const end = src.indexOf("\n", i);
352
+ i = end < 0 ? n : end;
353
+ continue;
354
+ }
355
+ if (c === "/" && src[i + 1] === "*") {
356
+ const end = src.indexOf("*/", i + 2);
357
+ i = end < 0 ? n : end + 2;
358
+ continue;
359
+ }
360
+ if (c === '"' || c === "'") {
361
+ i = skipQuoted(src, i);
362
+ prev = '"';
363
+ word = "";
364
+ continue;
365
+ }
366
+ if (c === "`") {
367
+ i = scanTemplate(i);
368
+ prev = '"';
369
+ word = "";
370
+ continue;
371
+ }
372
+ if (c === "/" && exprStart()) {
373
+ i = skipRegex(i);
374
+ prev = '"';
375
+ word = "";
376
+ continue;
377
+ }
378
+ if (c === "<" && jsx && exprStart()) {
379
+ const end = scanJsxElement(i);
380
+ if (end > 0) {
381
+ i = end;
382
+ prev = ")";
383
+ word = "";
384
+ continue;
385
+ }
386
+ }
387
+ code[i] = 1;
388
+ if (c === "{") depth += 1;
389
+ if (c === "}") {
390
+ if (depth === 0 && closer) return i;
391
+ depth -= 1;
392
+ }
393
+ if (/\s/.test(c)) {
394
+ if (isId(prev)) prev = " "; // `else return`: two words, not `elsereturn`
395
+ } else {
396
+ if (isId(c)) word = isId(prev) && word ? word + c : c;
397
+ else word = "";
398
+ prev = c;
399
+ }
400
+ i += 1;
401
+ }
402
+ return n;
403
+ }
404
+
405
+ function scanTemplate(i) {
406
+ i += 1; // opening backtick
407
+ while (i < n) {
408
+ const c = src[i];
409
+ if (c === "\\") i += 2;
410
+ else if (c === "`") return i + 1;
411
+ else if (c === "$" && src[i + 1] === "{") {
412
+ mark(i, i + 2);
413
+ i = scanCode(i + 2, true);
414
+ mark(i, i + 1);
415
+ i += 1;
416
+ } else i += 1;
417
+ }
418
+ return n;
419
+ }
420
+
421
+ function skipRegex(i) {
422
+ let inClass = false;
423
+ i += 1;
424
+ while (i < n && src[i] !== "\n") {
425
+ const c = src[i];
426
+ if (c === "\\") {
427
+ i += 2;
428
+ continue;
429
+ }
430
+ if (c === "/" && !inClass) {
431
+ i += 1;
432
+ while (isId(src[i])) i += 1;
433
+ return i;
434
+ }
435
+ if (c === "[") inClass = true;
436
+ if (c === "]") inClass = false;
437
+ i += 1;
438
+ }
439
+ return i;
440
+ }
441
+
442
+ // A JSX element (or fragment) starting at `<`. Returns the index after it, or -1 when the `<`
443
+ // is not JSX (a comparison, a TS generic).
444
+ function scanJsxElement(start) {
445
+ let i = start + 1;
446
+ while (/\s/.test(src[i] ?? "")) i += 1;
447
+ if (src[i] === ">") {
448
+ mark(start, i + 1);
449
+ return scanJsxChildren(i + 1);
450
+ }
451
+ const name = /^[A-Za-z_$][\w$.:-]*/.exec(src.slice(i, i + 200));
452
+ if (!name) return -1;
453
+ i += name[0].length;
454
+ // `<T,>(x) =>` / `<T extends …>`: a generic arrow in .tsx, not an element.
455
+ const after = /^\s*(,|extends\b)/.exec(src.slice(i, i + 20));
456
+ if (after) return -1;
457
+ mark(start, i);
458
+ while (i < n) {
459
+ const c = src[i];
460
+ if (c === '"' || c === "'") {
461
+ const end = src.indexOf(c, i + 1); // JSX attribute strings have no escapes
462
+ i = end < 0 ? n : end + 1;
463
+ } else if (c === "/" && src[i + 1] === "/") {
464
+ const end = src.indexOf("\n", i);
465
+ i = end < 0 ? n : end;
466
+ } else if (c === "/" && src[i + 1] === "*") {
467
+ const end = src.indexOf("*/", i + 2);
468
+ i = end < 0 ? n : end + 2;
469
+ } else if (c === "{") {
470
+ mark(i, i + 1);
471
+ i = scanCode(i + 1, true);
472
+ mark(i, i + 1);
473
+ i += 1;
474
+ } else if (c === "/" && src[i + 1] === ">") {
475
+ mark(i, i + 2);
476
+ return i + 2;
477
+ } else if (c === ">") {
478
+ mark(i, i + 1);
479
+ return scanJsxChildren(i + 1);
480
+ } else {
481
+ code[i] = 1;
482
+ i += 1;
483
+ }
484
+ }
485
+ return n;
486
+ }
487
+
488
+ // Children until the matching closing tag. Returns the index after it.
489
+ function scanJsxChildren(i) {
490
+ while (i < n) {
491
+ const c = src[i];
492
+ if (c === "{") {
493
+ mark(i, i + 1);
494
+ i = scanCode(i + 1, true);
495
+ mark(i, i + 1);
496
+ i += 1;
497
+ } else if (c === "<" && src[i + 1] === "/") {
498
+ const end = src.indexOf(">", i);
499
+ mark(i, end < 0 ? n : end + 1);
500
+ return end < 0 ? n : end + 1;
501
+ } else if (c === "<") {
502
+ const end = scanJsxElement(i);
503
+ i = end > 0 ? end : i + 1;
504
+ } else i += 1; // JSX text: apostrophes and quotes here are prose, not strings
505
+ }
506
+ return n;
507
+ }
508
+
509
+ scanCode(0, false);
510
+ return code;
511
+ }
512
+
513
+ function skipQuoted(src, i) {
514
+ const q = src[i];
515
+ i += 1;
516
+ while (i < src.length && src[i] !== q && src[i] !== "\n") i += src[i] === "\\" ? 2 : 1;
517
+ return i + 1;
518
+ }
519
+
520
+ const globalRe = (re) => (re.global ? re : new RegExp(re.source, `${re.flags}g`));
521
+ /** `source.replace(re, fn)`, applied only to matches that start in code (gh#1327). */
522
+ function replaceInCode(file, source, re, fn) {
523
+ const code = codeMask(file, source);
524
+ return source.replace(globalRe(re), (...args) => {
525
+ const offset = args[args.length - 2];
526
+ return code[offset] ? fn(...args) : args[0];
527
+ });
528
+ }
529
+ /** Every match of `re` that starts in code. */
530
+ function codeMatches(file, source, re) {
531
+ const code = codeMask(file, source);
532
+ return [...source.matchAll(globalRe(re))].filter((m) => code[m.index]);
533
+ }
534
+ const escapeRe = (s) => s.replace(/[$.]/g, "\\$&");
535
+
536
+ /**
537
+ * gh#1327: the local names `AppProvider` from @godxjp/ui (any subpath) is bound to in this file,
538
+ * aliases resolved. A JSX element named AppProvider that is the app's own wrapper is not one.
539
+ */
540
+ function providerBindings(file, source) {
541
+ const names = new Set();
542
+ for (const m of codeMatches(file, source, IMPORT_RE)) {
543
+ if (m[1] || !/^@godxjp\/ui(\/|$)/.test(m[4])) continue;
544
+ for (const raw of m[2].split(",")) {
545
+ const [name, alias] = raw.trim().split(/\s+as\s+/);
546
+ if (name === "AppProvider") names.add(alias ?? name);
547
+ }
548
+ }
549
+ return [...names];
550
+ }
551
+
307
552
  export function transformSource(file, source, { godx = false } = {}) {
308
553
  let out = source;
309
554
  const notes = [];
@@ -312,7 +557,7 @@ export function transformSource(file, source, { godx = false } = {}) {
312
557
  const coreImport = isCss ? CORE_IMPORT_CSS : CORE_IMPORT_JS;
313
558
  const written = (indent, q, spec) =>
314
559
  isCss ? `${indent}@import ${q}${spec}${q};` : `${indent}import ${q}${spec}${q};`;
315
- const m = out.match(styleImport);
560
+ const [m] = codeMatches(file, out, styleImport);
316
561
  if (m) {
317
562
  const [line, indent, q] = m;
318
563
  const add = [];
@@ -323,58 +568,68 @@ export function transformSource(file, source, { godx = false } = {}) {
323
568
  } else if (!out.includes(FONTS) && !out.includes("@godxjp/ui/styles/fonts")) {
324
569
  add.push(FONTS);
325
570
  }
326
- if (add.length)
327
- out = out.replace(line, `${line}\n${add.map((spec) => written(indent, q, spec)).join("\n")}`);
571
+ if (add.length) {
572
+ const at = m.index + line.length;
573
+ out = `${out.slice(0, at)}\n${add.map((spec) => written(indent, q, spec)).join("\n")}${out.slice(at)}`;
574
+ }
328
575
  }
329
576
  // styles/core (a shadow root or an app with its own fonts): the colour tokens, never the fonts.
330
- const c = out.match(coreImport);
577
+ const [c] = codeMatches(file, out, coreImport);
331
578
  if (c && godx && !out.includes(GODX_TOKENS_CSS) && !out.includes(GODX_CSS)) {
332
579
  const [line, indent, q] = c;
333
- out = out.replace(line, `${line}\n${written(indent, q, GODX_TOKENS_CSS)}`);
580
+ const at = c.index + line.length;
581
+ out = `${out.slice(0, at)}\n${written(indent, q, GODX_TOKENS_CSS)}${out.slice(at)}`;
334
582
  }
335
583
  // A file the 32.0.0 codemod already touched carries BOTH godx.css and fonts.css: drop the second.
336
584
  if (out.includes(GODX_CSS)) {
337
- out = out.replace(
585
+ const [f] = codeMatches(
586
+ file,
587
+ out,
338
588
  isCss
339
589
  ? /^[ \t]*@import\s+["']@godxjp\/ui\/styles\/fonts(?:\.css)?["']\s*;[ \t]*\n/m
340
590
  : /^[ \t]*import\s+["']@godxjp\/ui\/styles\/fonts(?:\.css)?["']\s*;?[ \t]*\n/m,
341
- "",
342
591
  );
592
+ if (f) out = out.slice(0, f.index) + out.slice(f.index + f[0].length);
343
593
  }
344
- if (!isCss && /<AppProvider\b/.test(out)) {
345
- const tags = out.match(/<AppProvider\b[^>]*>/g) ?? [];
346
- for (const tag of tags) {
347
- if (/\bpreset=/.test(tag)) continue;
348
- if (godx) {
349
- out = out.replace(tag, tag.replace(/^<AppProvider\b/, "<AppProvider preset={godxPreset}"));
350
- } else if (!/\bdefaultLocale=/.test(tag)) {
351
- notes.push(
352
- "<AppProvider> has no defaultLocale and no preset: v32 starts in <html lang> or en (was vi). " +
353
- 'Add defaultLocale="vi" if that is what you meant, or rerun with --godx.',
354
- );
355
- }
594
+ if (!isCss) {
595
+ // gh#1327: only elements bound to @godxjp/ui's AppProvider, under whatever local name.
596
+ for (const name of providerBindings(file, out)) {
597
+ const tagRe = new RegExp(`<${escapeRe(name)}(?=[\\s>/])[^>]*>`, "g");
598
+ out = replaceInCode(file, out, tagRe, (tag) => {
599
+ if (/\bpreset=/.test(tag)) return tag;
600
+ if (godx) return `<${name} preset={godxPreset}${tag.slice(name.length + 1)}`;
601
+ if (!/\bdefaultLocale=/.test(tag)) {
602
+ notes.push(
603
+ "<AppProvider> has no defaultLocale and no preset: v32 starts in <html lang> or en (was vi). " +
604
+ 'Add defaultLocale="vi" if that is what you meant, or rerun with --godx.',
605
+ );
606
+ }
607
+ return tag;
608
+ });
356
609
  }
357
610
  if (
358
611
  godx &&
359
- out.includes("preset={godxPreset}") &&
612
+ codeMatches(file, out, /preset=\{godxPreset\}/).length &&
360
613
  !/\bgodxPreset\b[^\n]*from\s+["']@godxjp\/ui\/themes\/godx["']/.test(out)
361
614
  ) {
362
- out = addImport(out, 'import { godxPreset } from "@godxjp/ui/themes/godx";');
615
+ out = addImport(file, out, 'import { godxPreset } from "@godxjp/ui/themes/godx";');
363
616
  }
364
617
  }
365
- if (!isCss) out = applyRenames(out, notes);
366
- if (!isCss) out = narrowLocaleType(out, notes, godx);
618
+ if (!isCss) out = applyRenames(file, out, notes);
619
+ if (!isCss) out = narrowLocaleType(file, out, notes, godx);
367
620
  return { out, changed: out !== source, notes };
368
621
  }
369
622
 
370
623
  const IMPORT_RE =
371
624
  /import\s*(type\s+)?\{([^}]*)\}\s*from\s*(["'])(@godxjp\/[\w-]+(?:\/[\w-]+)?)\3\s*;?/g;
372
625
 
373
- function applyRenames(source, notes) {
626
+ const DROPPED = "\u0000codemod-dropped-import\u0000";
627
+
628
+ function applyRenames(file, source, notes) {
374
629
  let out = source;
375
630
  const moved = new Map(); // toMod -> [{ spec, typeOnly }]
376
631
  const jsxRenames = [];
377
- out = out.replace(IMPORT_RE, (stmt, typeKw, body, q, mod) => {
632
+ out = replaceInCode(file, out, IMPORT_RE, (stmt, typeKw, body, q, mod) => {
378
633
  const keep = [];
379
634
  let touched = false;
380
635
  for (const raw of body
@@ -398,16 +653,24 @@ function applyRenames(source, notes) {
398
653
  const list = moved.get(toMod) ?? [];
399
654
  list.push({ spec: newSpec, typeOnly: Boolean(typeKw) || inlineType });
400
655
  moved.set(toMod, list);
401
- if (!alias && (toName !== name || r.add || r.map)) jsxRenames.push([name, toName, r]);
656
+ // gh#1327: an aliased import keeps its local name, but still needs the merge's props.
657
+ if (alias && (r.add || r.map)) jsxRenames.push([alias, alias, r]);
658
+ else if (!alias && (toName !== name || r.add || r.map)) jsxRenames.push([name, toName, r]);
402
659
  if (r.note) notes.push(`${name} → ${toName}${toMod !== mod ? ` (${toMod})` : ""}: ${r.note}`);
403
660
  }
404
661
  if (!touched) return stmt;
405
- return keep.length ? `import ${typeKw ?? ""}{ ${keep.join(", ")} } from ${q}${mod}${q};` : "";
662
+ return keep.length
663
+ ? `import ${typeKw ?? ""}{ ${keep.join(", ")} } from ${q}${mod}${q};`
664
+ : DROPPED;
406
665
  });
666
+ // An emptied import goes with its own line break; nothing else is re-spaced (gh#1327).
667
+ out = out.replace(new RegExp(`${DROPPED}[ \\t]*\\n?`, "g"), "");
407
668
  for (const [from, to, r] of jsxRenames) {
408
669
  // Opening tags first, so the props the merge needs land on the element itself.
409
- out = out.replace(
410
- new RegExp(`<${from}(?=[\\s>/])([^>]*?)(/?)>`, "g"),
670
+ out = replaceInCode(
671
+ file,
672
+ out,
673
+ new RegExp(`<${escapeRe(from)}(?=[\\s>/])([^>]*?)(/?)>`, "g"),
411
674
  (_all, attrs, selfClose) => {
412
675
  let a = attrs;
413
676
  for (const [prop, values] of Object.entries(r.map ?? {})) {
@@ -429,7 +692,12 @@ function applyRenames(source, notes) {
429
692
  },
430
693
  );
431
694
  // Closing tags and compound members (`</Callout>`, `<Callout.Title>`).
432
- out = out.replace(new RegExp(`(</|<)${from}(?=[.>])`, "g"), `$1${to}`);
695
+ out = replaceInCode(
696
+ file,
697
+ out,
698
+ new RegExp(`(</|<)${escapeRe(from)}(?=[.>])`, "g"),
699
+ (_all, open) => `${open}${to}`,
700
+ );
433
701
  }
434
702
  for (const [toMod, specs] of moved) {
435
703
  for (const typeOnly of [false, true]) {
@@ -438,26 +706,27 @@ function applyRenames(source, notes) {
438
706
  const existing = new RegExp(
439
707
  `import\\s*${typeOnly ? "type\\s+" : ""}\\{([^}]*)\\}\\s*from\\s*(["'])${toMod.replace(/[/]/g, "\\/")}\\2\\s*;?`,
440
708
  );
441
- const m = out.match(existing);
709
+ const [m] = codeMatches(file, out, existing);
442
710
  if (m) {
443
711
  const have = m[1]
444
712
  .split(",")
445
713
  .map((x) => x.trim())
446
714
  .filter(Boolean);
447
715
  const merged = [...new Set([...have, ...names])];
448
- out = out.replace(
449
- m[0],
450
- `import ${typeOnly ? "type " : ""}{ ${merged.join(", ")} } from ${m[2]}${toMod}${m[2]};`,
451
- );
716
+ out =
717
+ out.slice(0, m.index) +
718
+ `import ${typeOnly ? "type " : ""}{ ${merged.join(", ")} } from ${m[2]}${toMod}${m[2]};` +
719
+ out.slice(m.index + m[0].length);
452
720
  } else {
453
721
  out = addImport(
722
+ file,
454
723
  out,
455
724
  `import ${typeOnly ? "type " : ""}{ ${[...new Set(names)].join(", ")} } from "${toMod}";`,
456
725
  );
457
726
  }
458
727
  }
459
728
  }
460
- return out.replace(/\n{3,}/g, "\n\n");
729
+ return out;
461
730
  }
462
731
 
463
732
  /**
@@ -466,8 +735,10 @@ function applyRenames(source, notes) {
466
735
  * A GoDX product only uses the built-ins, so under --godx the type is renamed (behaviour unchanged).
467
736
  * Otherwise it is reported: the app decides whether it will register other locales.
468
737
  */
469
- function narrowLocaleType(source, notes, godx) {
470
- const imp = source.match(
738
+ function narrowLocaleType(file, source, notes, godx) {
739
+ const [imp] = codeMatches(
740
+ file,
741
+ source,
471
742
  /import\s*(type\s+)?\{[^}]*\bAppLocale\b[^}]*\}\s*from\s*["']@godxjp\/ui(?:\/app)?["']/,
472
743
  );
473
744
  if (!imp) return source;
@@ -477,11 +748,11 @@ function narrowLocaleType(source, notes, godx) {
477
748
  );
478
749
  return source;
479
750
  }
480
- return source.replace(/\bAppLocale\b/g, "BuiltInLocale");
751
+ return replaceInCode(file, source, /\bAppLocale\b/g, () => "BuiltInLocale");
481
752
  }
482
753
 
483
- function addImport(source, line) {
484
- const imports = [...source.matchAll(/^import[^;]*;[ \t]*$/gm)];
754
+ function addImport(file, source, line) {
755
+ const imports = codeMatches(file, source, /^import[^;]*;[ \t]*$/gm);
485
756
  if (!imports.length) return `${line}\n${source}`;
486
757
  const last = imports[imports.length - 1];
487
758
  const at = last.index + last[0].length;
@@ -4,11 +4,25 @@ import {
4
4
  ensureClaudeHooks,
5
5
  ensureClaudeMd,
6
6
  ensureMcpJson,
7
+ ensureUserGuard,
7
8
  outOfProjectUiMcpReport,
8
9
  shouldSkip,
9
10
  writeWorkflowMd,
10
11
  } from "./_agent-setup.mjs";
11
12
 
13
+ // `--user` (gh#1324): the user-level guard. It touches no project file, so it needs no consumer
14
+ // package.json and is not refused anywhere — it is checked before shouldSkip on purpose.
15
+ if (process.argv.includes("--user")) {
16
+ console.log("\n @godxjp/ui user-level guard:");
17
+ for (const line of ensureUserGuard()) console.log(` • ${line}`);
18
+ console.log(`
19
+ Every Claude Code session now loads it, wherever it starts. Restart running sessions.
20
+ A .tsx/.jsx write in a repo whose .mcp.json mounts @godxjp/ui-mcp is blocked until the agent
21
+ consults the catalog; without the MCP it can use npx --no-install @godxjp/ui mcp-call <tool>.
22
+ `);
23
+ process.exit(0);
24
+ }
25
+
12
26
  const root = process.env.INIT_CWD || process.cwd();
13
27
 
14
28
  const skip = shouldSkip(root);
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `godxjp-ui mcp-call <tool> [json-args]` — query the godx-ui catalog without an MCP client
4
+ * (gh#1324). It launches EXACTLY what the MCP client would — the repo's `.mcp.json` catalog entry
5
+ * (command, args, env), or the version-pinned default when there is none — with `call <tool> …`
6
+ * appended, so the answer comes from the same pinned server and the same `dispatchTool`.
7
+ *
8
+ * Network: the same as the MCP itself — `npx @godxjp/ui-mcp@<pin>` may download the server on a
9
+ * cold npx cache, exactly as starting the MCP would.
10
+ */
11
+ import { spawnSync } from "node:child_process";
12
+
13
+ import { findUiMcpConfig, mcpServerFor } from "./_agent-setup.mjs";
14
+
15
+ const root = process.env.INIT_CWD || process.cwd();
16
+ const found = findUiMcpConfig(root);
17
+ const entry = found?.entry ?? mcpServerFor(root);
18
+ const args = [...(Array.isArray(entry.args) ? entry.args : []), "call", ...process.argv.slice(2)];
19
+
20
+ const r = spawnSync(entry.command, args, {
21
+ cwd: found?.dir ?? root,
22
+ env: { ...process.env, ...(entry.env ?? {}) },
23
+ // stdin closed: a server too old to know `call` starts its stdio loop, reads EOF and exits,
24
+ // instead of waiting forever on the agent's terminal.
25
+ stdio: ["ignore", "pipe", "inherit"],
26
+ encoding: "utf8",
27
+ maxBuffer: 64 * 1024 * 1024,
28
+ });
29
+ if (r.error) {
30
+ console.error(`mcp-call: could not launch ${entry.command}: ${r.error.message}`);
31
+ process.exit(1);
32
+ }
33
+ if (!r.stdout && r.status === 0) {
34
+ console.error(
35
+ `mcp-call: ${[entry.command, ...(entry.args ?? [])].join(" ")} printed nothing — that ` +
36
+ "@godxjp/ui-mcp predates `call`. Upgrade @godxjp/ui and run `npx godxjp-ui sync-rules`.",
37
+ );
38
+ process.exit(1);
39
+ }
40
+ process.stdout.write(r.stdout ?? "", () => process.exit(r.status ?? 1));
@@ -337,6 +337,9 @@ const CARD_LIMIT = 5;
337
337
  const BADGE_LIMIT = 5;
338
338
  const ALERT_LIMIT = 4;
339
339
  const STATCARD_ROW = 3;
340
+ /** design-complex-admin anti-pattern #3: 3+ fields is a create flow, not a filter (gh#1324). */
341
+ const FORM_ON_LIST_FIELDS = 3;
342
+ const LIST_TAGS = new Set(["DataTable", "Table", "List", "ListRow"]);
340
343
 
341
344
  /**
342
345
  * @type {{id:string, severity:'error'|'warn', test:RegExp, message:string, standard?:string,
@@ -987,6 +990,16 @@ const RULES = [
987
990
  matches: kpiRowMatches,
988
991
  message: `${STATCARD_ROW}+ StatCards outside a dashboard — a row count is not a KPI. Keep StatCard rows for a dashboard with comparison or trend (delta); a list shows its total in Pagination showTotal.`,
989
992
  },
993
+ {
994
+ id: "form-on-list-page",
995
+ replacement:
996
+ "the create form on its own route (or a focused Dialog/Sheet); the list page links to it",
997
+ scope: "consumer",
998
+ severity: "warn",
999
+ spansElement: true,
1000
+ matches: formOnListPageMatches,
1001
+ message: `A Form with ${FORM_ON_LIST_FIELDS}+ FormFields and a DataTable/Table/List/ListRow on the same page — a create form inlined beside its list. Give create its own route (or a focused Dialog/Sheet) and keep the list page a list (design-complex-admin anti-pattern #3).`,
1002
+ },
990
1003
  {
991
1004
  id: "empty-state-icon-in-card",
992
1005
  replacement: 'EmptyState title + action, no icon (or variant="compact")',
@@ -1783,6 +1796,70 @@ function* nestedCardMatches(source) {
1783
1796
  }
1784
1797
  }
1785
1798
 
1799
+ /** Overlay surfaces: a form inside one is the focused create flow, not an inline one. */
1800
+ const OVERLAY_TAG = /^(?:Dialog|AlertDialog|Sheet|Drawer|Popover|Modal)/;
1801
+ /**
1802
+ * `onSubmit={(e) => e.preventDefault()}` — a Form that never submits is a filter or parameter bar
1803
+ * laid out with FormFields, not a create flow. Measured on the consumer repos (gh#1324): all 4
1804
+ * first-draft hits had this shape (2 filter bars, 1 review-workspace editor, 1 precheck parameter
1805
+ * form), and none was a create form.
1806
+ */
1807
+ const NO_OP_SUBMIT =
1808
+ /\bonSubmit\s*=\s*\{\s*\(?\s*(\w+)\s*\)?\s*=>\s*(?:\{\s*)?\1\.preventDefault\(\)\s*;?\s*\}?\s*\}/;
1809
+
1810
+ /**
1811
+ * `form-on-list-page`: a Form holding ≥FORM_ON_LIST_FIELDS FormFields and a list surface
1812
+ * (DataTable / Table / List / ListRow) in the same file — unless each sits in a DIFFERENT
1813
+ * PageContainer (a file that renders a list page and a create page pairs nothing across them; a
1814
+ * create sub-component with no PageContainer of its own still pairs with the page's list) —
1815
+ * neither inside an overlay, the list not inside the
1816
+ * Form (an editor with a line-items Table is a form, not a list page), and the Form actually
1817
+ * submitting (see NO_OP_SUBMIT). One finding per such Form.
1818
+ */
1819
+ function* formOnListPageMatches(source) {
1820
+ const opened = []; // Forms whose children frame has not been seen yet
1821
+ const byFrame = new Map(); // a Form's children-frame id → { index, openTag, scope, skip, fields }
1822
+ const listScopes = new Set(); // PageContainer children-frame ids (or "file") holding a list
1823
+ for (const { tag, index, openTag, stack } of jsxElements(source)) {
1824
+ // A Form's children frame is pushed when its opening tag closes, so the first time an unknown
1825
+ // one shows up in a stack it belongs to the LATEST Form still waiting (a Form with no element
1826
+ // children never claims one and simply stays behind).
1827
+ for (const f of stack) {
1828
+ if (f.tag === "Form" && !f.attr && !byFrame.has(f.id) && opened.length) {
1829
+ byFrame.set(f.id, opened.pop());
1830
+ }
1831
+ }
1832
+ const overlay = stack.some((f) => OVERLAY_TAG.test(f.tag));
1833
+ const scope = stack.findLast((f) => f.tag === "PageContainer" && !f.attr)?.id ?? "file";
1834
+ if (tag === "Form") {
1835
+ if (!/\/\s*>$/.test(openTag)) {
1836
+ const attrs = topLevelAttrs(openTag);
1837
+ // No onSubmit / action / spread: the Form cannot submit, so it is a filter or parameter
1838
+ // bar laid out with FormFields, like the NO_OP_SUBMIT shape.
1839
+ const submits = /\b(?:onSubmit|action)\s*=/.test(attrs) || /\{\s*\.\.\./.test(openTag);
1840
+ const skip = overlay || !submits || NO_OP_SUBMIT.test(openTag);
1841
+ opened.push({ index, openTag, scope, skip, fields: 0 });
1842
+ }
1843
+ } else if (tag === "FormField") {
1844
+ const form = stack.findLast((f) => f.tag === "Form" && !f.attr);
1845
+ const entry = form && byFrame.get(form.id);
1846
+ // `label={false}` is a layout slot (the submit row), not a field the user fills in.
1847
+ const isField = !/\blabel\s*=\s*\{\s*false\s*\}/.test(openTag);
1848
+ if (entry && isField) entry.fields += 1;
1849
+ } else if (LIST_TAGS.has(tag) && !overlay && !stack.some((f) => f.tag === "Form")) {
1850
+ listScopes.add(scope);
1851
+ }
1852
+ }
1853
+ for (const form of byFrame.values()) {
1854
+ const samePage =
1855
+ listScopes.size > 0 &&
1856
+ (form.scope === "file" || listScopes.has("file") || listScopes.has(form.scope));
1857
+ if (!form.skip && form.fields >= FORM_ON_LIST_FIELDS && samePage) {
1858
+ yield { 0: `${form.fields} FormFields beside a list: ${form.openTag}`, index: form.index };
1859
+ }
1860
+ }
1861
+ }
1862
+
1786
1863
  /** ≥3 StatCards in a file whose path does not say it is a dashboard. */
1787
1864
  function* kpiRowMatches(source, rel = "") {
1788
1865
  if (/dashboard/i.test(rel)) return;