@godxjp/ui 32.0.2 → 32.0.4

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "32.0.2",
4
- "godxUiMcp": "32.0.2",
3
+ "version": "32.0.4",
4
+ "godxUiMcp": "32.0.4",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -1810,6 +1810,113 @@ function staleOwnedRules() {
1810
1810
  };
1811
1811
  }
1812
1812
 
1813
+ /*
1814
+ * THE FORMFIELD WALK, shared by `formfield-needs-form` and the cross-file index below (gh#1238).
1815
+ * One pass over a file's open/close JSX tags with a stack, so "same parent" is structural.
1816
+ * `(?<![\w.$])` skips a generic argument (`useRef<HTMLDivElement>`), which is preceded by an
1817
+ * identifier. Returns the bare FormFields grouped by parent, and which component tags this file
1818
+ * renders under a Form and which outside one.
1819
+ *
1820
+ * Every exported tag that renders @godxjp/ui's <Form> around its children counts (gh#1039).
1821
+ * `FormRoot` (@godxjp/ui/form, src/form/form-root.tsx) wraps its <form> in <Form asChild>
1822
+ * whenever a layout prop is set, and is the form shell with one stacked layout otherwise.
1823
+ * Pagination also renders <Form>, but around its own controls, not a consumer's fields. Add a tag
1824
+ * here when another exported wrapper starts rendering <Form> around children.
1825
+ */
1826
+ const FORM_TAGS = new Set(["Form", "FormRoot"]);
1827
+ const formOpenTag = new RegExp(`<(${[...FORM_TAGS].join("|")})(?=[\\s>])([^>]*)>`, "g");
1828
+ function formTagWalk(scanContent) {
1829
+ const stack = [];
1830
+ const bareByParent = new Map();
1831
+ const underForm = new Set();
1832
+ /** Component tag → indexes where this file renders it with no Form above it. */
1833
+ const outsideForm = new Map();
1834
+ let formDepth = 0;
1835
+ for (const m of scanContent.matchAll(
1836
+ /(?<![\w.$])<(\/?)([A-Za-z][\w.]*)(?=[\s>/])([^<>]*?(?:\{[^{}]*\}[^<>]*?)*)(\/?)>/g,
1837
+ )) {
1838
+ const [, close, tag, , selfClose] = m;
1839
+ if (close) {
1840
+ for (let i = stack.length - 1; i >= 0; i -= 1) {
1841
+ if (stack[i].tag === tag) {
1842
+ stack.length = i;
1843
+ break;
1844
+ }
1845
+ }
1846
+ if (FORM_TAGS.has(tag)) formDepth = Math.max(0, formDepth - 1);
1847
+ continue;
1848
+ }
1849
+ if (tag === "FormField" && formDepth === 0) {
1850
+ const parent = stack.at(-1)?.index ?? -1;
1851
+ (bareByParent.get(parent) ?? bareByParent.set(parent, []).get(parent)).push(m.index);
1852
+ }
1853
+ if (/^[A-Z]/.test(tag)) {
1854
+ if (formDepth > 0) underForm.add(tag);
1855
+ else (outsideForm.get(tag) ?? outsideForm.set(tag, []).get(tag)).push(m.index);
1856
+ }
1857
+ if (selfClose) continue;
1858
+ stack.push({ tag, index: m.index });
1859
+ if (FORM_TAGS.has(tag)) formDepth += 1;
1860
+ }
1861
+ return { bareByParent, underForm, outsideForm };
1862
+ }
1863
+
1864
+ /** The PascalCase component declared last before `index` — the one a FormField group sits in. */
1865
+ function enclosingComponent(scanContent, index) {
1866
+ let name = null;
1867
+ for (const m of scanContent
1868
+ .slice(0, index)
1869
+ .matchAll(
1870
+ /(?:^|\n)\s*(?:export\s+)?(?:default\s+)?(?:function\s+([A-Z]\w*)|const\s+([A-Z]\w*)\s*[:=])/g,
1871
+ )) {
1872
+ name = m[1] ?? m[2];
1873
+ }
1874
+ return name;
1875
+ }
1876
+
1877
+ /*
1878
+ * A FIELD GROUP RENDERED INTO ITS CALLER'S FORM (gh#1238). A component that only contributes
1879
+ * fields (`RevisionFields`, rendered inside `<Form asChild errors={form.errors}>` in two dialogs)
1880
+ * has no <Form> in its own file, and wrapping it in one would nest a <form> or open a second Form
1881
+ * context without the caller's errors. So the rule asks the whole project: if every place that
1882
+ * renders this component does so under a Form, its fields have one. Used bare ANYWHERE, it is still
1883
+ * flagged — that keeps the gh#998 defect (fields in a hand-rolled <Flex> nobody wraps) caught.
1884
+ *
1885
+ * The index reads every file under the default scan roots plus any path named on the command line,
1886
+ * not only the files being reported, so `--changed` or a single-file run still sees the callers. Known miss: a default
1887
+ * export imported under another local name does not match; `ui-audit-disable-next-line` covers it.
1888
+ */
1889
+ let formUsageIndex = null;
1890
+ /**
1891
+ * Where `name` is rendered across the project: `under` (some caller renders it inside a Form) and
1892
+ * `bare` (the `file:line` of every caller that does not). Null for a nameless group.
1893
+ */
1894
+ function formUsage(name) {
1895
+ if (!name) return null;
1896
+ if (!formUsageIndex) {
1897
+ formUsageIndex = { under: new Set(), bare: new Map() };
1898
+ // The default roots AND whatever was named: a single file (the editor hook) or a directory
1899
+ // outside the defaults (`resources/js/lib`) must still see the callers that render it.
1900
+ const roots = new Set([...(SELF ? SELF_SCAN_ROOTS : CONSUMER_SCAN_ROOTS), ...dirArgs]);
1901
+ const seen = new Set();
1902
+ for (const dir of roots) {
1903
+ for (const file of walk(isAbsolute(dir) ? dir : join(CWD, dir))) {
1904
+ if (!/\.[jt]sx$/.test(file) || seen.has(file)) continue;
1905
+ seen.add(file);
1906
+ const text = stripComments(readFileSync(file, "utf8"));
1907
+ const { underForm, outsideForm } = formTagWalk(text);
1908
+ for (const tag of underForm) formUsageIndex.under.add(tag);
1909
+ for (const [tag, indexes] of outsideForm) {
1910
+ const list = formUsageIndex.bare.get(tag) ?? formUsageIndex.bare.set(tag, []).get(tag);
1911
+ for (const i of indexes)
1912
+ list.push(`${relative(CWD, file)}:${text.slice(0, i).split("\n").length}`);
1913
+ }
1914
+ }
1915
+ }
1916
+ }
1917
+ return { under: formUsageIndex.under.has(name), bare: formUsageIndex.bare.get(name) ?? [] };
1918
+ }
1919
+
1813
1920
  const findings = [];
1814
1921
  const stale = staleOwnedRules();
1815
1922
  if (stale) findings.push(stale);
@@ -2046,43 +2153,15 @@ for (const dir of SCAN_DIRS) {
2046
2153
  * The walk keeps a stack of open JSX elements so "same parent" is structural. `(?<![\w.$])`
2047
2154
  * skips a generic argument (`useRef<HTMLDivElement>`), which is preceded by an identifier.
2048
2155
  */
2049
- const stack = [];
2050
- const bareByParent = new Map();
2051
- /*
2052
- * Every exported tag that renders @godxjp/ui's <Form> around its children (gh#1039). A field
2053
- * under any of them has the Form layout context, so it must count like a literal <Form>.
2054
- * `FormRoot` (@godxjp/ui/form, src/form/form-root.tsx) wraps its <form> in <Form asChild>
2055
- * whenever a layout prop is set, and is the form shell with one stacked layout otherwise.
2056
- * Pagination also renders <Form>, but around its own controls, not a consumer's fields. Add a
2057
- * tag here when another exported wrapper starts rendering <Form> around children.
2058
- */
2059
- const FORM_TAGS = new Set(["Form", "FormRoot"]);
2060
- const formOpenTag = new RegExp(`<(${[...FORM_TAGS].join("|")})(?=[\\s>])([^>]*)>`, "g");
2061
- let formDepth = 0;
2062
- for (const m of scanContent.matchAll(
2063
- /(?<![\w.$])<(\/?)([A-Za-z][\w.]*)(?=[\s>/])([^<>]*?(?:\{[^{}]*\}[^<>]*?)*)(\/?)>/g,
2064
- )) {
2065
- const [, close, tag, , selfClose] = m;
2066
- if (close) {
2067
- for (let i = stack.length - 1; i >= 0; i -= 1) {
2068
- if (stack[i].tag === tag) {
2069
- stack.length = i;
2070
- break;
2071
- }
2072
- }
2073
- if (FORM_TAGS.has(tag)) formDepth = Math.max(0, formDepth - 1);
2074
- continue;
2075
- }
2076
- if (tag === "FormField" && formDepth === 0) {
2077
- const parent = stack.at(-1)?.index ?? -1;
2078
- (bareByParent.get(parent) ?? bareByParent.set(parent, []).get(parent)).push(m.index);
2079
- }
2080
- if (selfClose) continue;
2081
- stack.push({ tag, index: m.index });
2082
- if (FORM_TAGS.has(tag)) formDepth += 1;
2083
- }
2156
+ const { bareByParent } = formTagWalk(scanContent);
2084
2157
  for (const group of bareByParent.values()) {
2085
2158
  if (group.length < 2) continue;
2159
+ const owner = enclosingComponent(scanContent, group[0]);
2160
+ const usage = formUsage(owner);
2161
+ // Every caller renders this component inside a Form: its fields have one (gh#1238).
2162
+ if (usage?.under && usage.bare.length === 0) continue;
2163
+ // Some callers do and some do not: the fix is at the bare call sites, not in this file.
2164
+ const bareCallers = usage?.under ? usage.bare : [];
2086
2165
  for (const at of group) {
2087
2166
  const lineNo = lineAt(at);
2088
2167
  if (suppressed("formfield-needs-form", lineNo - 1)) continue;
@@ -2092,7 +2171,9 @@ for (const dir of SCAN_DIRS) {
2092
2171
  rule: "formfield-needs-form",
2093
2172
  severity: "error",
2094
2173
  standard: "@godxjp/ui Form (gh#998)",
2095
- message: `${group.length} FormFields under one parent with no <Form> around them. <Form layout="horizontal" labelWidth controlWidth> is what lines fields up — without it each form picks its own layout, and a row of fields in a hand-rolled <Flex> drops the required field's label out of line. Wrap them in <Form>; a row that belongs together is <Flex attached> or <Form columns>. A lone field (a search box, a type-to-confirm input) is not flagged.`,
2174
+ message: bareCallers.length
2175
+ ? `${owner} renders ${group.length} FormFields and is used inside a <Form> elsewhere, but not at ${bareCallers.join(", ")}. Wrap THAT call in <Form> (it has no Form to nest into); do not wrap the fields here, which would nest a <form> or open a second Form context without the caller's errors.`
2176
+ : `${group.length} FormFields under one parent with no <Form> around them. <Form layout="horizontal" labelWidth controlWidth> is what lines fields up — without it each form picks its own layout, and a row of fields in a hand-rolled <Flex> drops the required field's label out of line. Wrap them in <Form>; a row that belongs together is <Flex attached> or <Form columns>. A lone field (a search box, a type-to-confirm input) is not flagged. If every caller renders this component inside a <Form>, it is not flagged either.`,
2096
2177
  snippet: scanContent.slice(at, at + 120).replace(/\s+/g, " "),
2097
2178
  });
2098
2179
  }