@gravitee/graphene-core 3.10.0 → 3.11.0-fix-found-211-ajv-unknown-format.338f8cb

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/USAGE_GUIDE.md CHANGED
@@ -17,6 +17,7 @@ This project uses the Graphene design system (`@gravitee/graphene-core`). Follow
17
17
  | `@gravitee/graphene-core/tailwind-theme` | Constrained Tailwind v4 theme: resets default palette/spacing/type and maps utilities to Graphene tokens. Use only if the app runs Tailwind for its own code. |
18
18
  | `@gravitee/graphene-core/testing` | `renderWithGraphene` plus per-component harnesses (`buttonHarness`, `inputHarness`, …) for tests. See the **Testing** section below. |
19
19
  | `@gravitee/graphene-core/eslint` / `@gravitee/graphene-core/tsconfig` | Shared tooling configs |
20
+ | `graphene-check-classes` (bin) | Fails if your source uses a class the pre-built bundle does not contain. See **Verifying class coverage** below. |
20
21
 
21
22
  ## Dependencies (what the host needs)
22
23
 
@@ -116,6 +117,63 @@ import graphene from '@gravitee/graphene-core/eslint';
116
117
  export default [...graphene, { settings: { 'better-tailwindcss': { entryPoint: './src/app.css' } } }];
117
118
  ```
118
119
 
120
+ ### Verifying class coverage (`graphene-check-classes`)
121
+
122
+ ```bash
123
+ npx graphene-check-classes src
124
+ ```
125
+
126
+ Exits non-zero listing every class your source uses that is not in the pre-built bundle, and the files each one
127
+ came from. Nothing to configure — the checker brings its own Tailwind. Run it in CI next to your typecheck.
128
+
129
+ ```
130
+ graphene-check-classes <source>... directories or globs; each is reported separately.
131
+ A path that matches nothing is an error, not a pass
132
+ --advisory <src> report but never fail the run
133
+ --exclude <glob> extra exclusion, relative to the current directory
134
+ (stories, tests and markdown are always excluded)
135
+ --ignore <class> never report this class; repeatable or comma-separated
136
+ ```
137
+
138
+ Exit codes: `0` clean, `1` the check failed, `2` the tool could not run — so CI can tell a broken class from a
139
+ broken install.
140
+
141
+ **Why this needs a tool.** ESLint validates against Tailwind's _generator_; what renders depends on the
142
+ _emitted artifact_. A class can be perfectly valid and still be absent from the bundle, in which case it
143
+ renders as nothing — no error, no warning, a clean typecheck. Two ways that happens:
144
+
145
+ 1. **Outside Graphene's theme entirely** — `bg-yellow-500`, `w-52`. Tailwind's default palette and most of its
146
+ spacing scale do not exist here (see **Token reference** above), so nothing was ever generated for them.
147
+ 2. **A valid Graphene class no Graphene component happens to use**, so nothing ever compiled it. Sizing
148
+ utilities and arbitrary values are the usual casualties: whether one ships depends on whether some unrelated
149
+ component reached for the same value. Not worth reasoning about, and not worth keeping a list of — the last
150
+ list drifted and shipped a regression.
151
+
152
+ **How it decides.** It compiles the sources you point it at with Tailwind and diffs the emitted class set
153
+ against the bundle. Both sides are real Tailwind output, so there are no extraction heuristics and no guessing
154
+ about dynamic strings Tailwind would not have emitted either. The bundle is resolved from `node_modules`, so the
155
+ answer is correct for the version you have pinned rather than for whatever is on `main`.
156
+
157
+ **What it cannot see.** Classes assembled at runtime from fragments (`` `bg-${tone}-500` ``) — Tailwind cannot
158
+ see those either, in any pipeline, which is why they are already discouraged. Everything written out in full,
159
+ including inside `cva` variants and `clsx` conditionals, is covered.
160
+
161
+ **Where it over-reports, and the escape hatch.** Tailwind's scanner reads your files as text, so a class name
162
+ that appears anywhere is discovered — including in a comment. Documenting that `bg-yellow-500` is dead is enough
163
+ to make it a finding. Suppress those with `--ignore bg-yellow-500`; an `--ignore` that stops matching anything
164
+ fails the run, so the list cannot quietly become a set of excused regressions the way a hand-maintained list of
165
+ known-dead classes does.
166
+
167
+ ### Which tier should run it
168
+
169
+ | | |
170
+ | --- | --- |
171
+ | **Tier 1** — no Tailwind of your own | Run it over all your source. Every class you write has to already be in the bundle, which is exactly what this measures. |
172
+ | **Tier 2** — your own Tailwind pipeline | Do not run it over all your source: your pipeline compiles your classes, so reason 2 does not apply to you and a full run reports classes that render fine. Reason 1 still applies — `tailwind-theme` is the same constrained theme, so `bg-yellow-500` and `w-52` are ungeneratable in your build too — but `better-tailwindcss/no-unknown-classes` from the shared ESLint config above already flags those at the point of use, which is the better place. Reach for this tool only for source your own Tailwind does not scan. |
173
+
174
+ The one thing neither tier has to check is Graphene's own components: the bundle is compiled from Graphene's
175
+ source, so it cannot be missing a class Graphene uses.
176
+
119
177
  ### Colors (semantic)
120
178
 
121
179
  Maps to CSS variables from Graphene themes. Use utilities like `bg-*`, `text-*`, `border-*`:
@@ -1 +1 @@
1
- {"version":3,"file":"jsonSchemaResolver.d.ts","sourceRoot":"","sources":["../../../../src/composed/JsonSchemaForm/form-bindings/jsonSchemaResolver.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAA2B,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AACzE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,oCAAoC,CAAC;AAqBpF,iBAAS,kBAAkB,CAAC,YAAY,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChG,MAAM,EAAE,UAAU,EAClB,OAAO,GAAE,yBAA8B,GACtC,QAAQ,CAAC,YAAY,CAAC,CA+DxB;AA2LD,OAAO,EAAE,kBAAkB,EAAE,CAAC"}
1
+ {"version":3,"file":"jsonSchemaResolver.d.ts","sourceRoot":"","sources":["../../../../src/composed/JsonSchemaForm/form-bindings/jsonSchemaResolver.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAA2B,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AACzE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,oCAAoC,CAAC;AAmCpF,iBAAS,kBAAkB,CAAC,YAAY,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChG,MAAM,EAAE,UAAU,EAClB,OAAO,GAAE,yBAA8B,GACtC,QAAQ,CAAC,YAAY,CAAC,CA+DxB;AA2LD,OAAO,EAAE,kBAAkB,EAAE,CAAC"}
package/dist/index.js CHANGED
@@ -21698,7 +21698,7 @@ var $z = [
21698
21698
  "[&_ol]:mb-2 [&_ol]:list-decimal [&_ol]:pl-5",
21699
21699
  "[&_li]:mb-0.5",
21700
21700
  "[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2",
21701
- "[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs",
21701
+ "[&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs",
21702
21702
  "[&_pre]:mb-3 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3",
21703
21703
  "[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-xs",
21704
21704
  "[&_table]:mb-3 [&_table]:w-full [&_table]:text-xs",
@@ -34772,7 +34772,9 @@ var fZ = new nZ.default({
34772
34772
  strict: !1,
34773
34773
  allErrors: !0
34774
34774
  });
34775
- (0, rZ.default)(fZ), fZ.removeKeyword("id"), fZ.addKeyword({
34775
+ (0, rZ.default)(fZ);
34776
+ for (let e of ["text", "gio-code-editor"]) fZ.addFormat(e, !0);
34777
+ fZ.removeKeyword("id"), fZ.addKeyword({
34776
34778
  keyword: "id",
34777
34779
  validate: () => !0
34778
34780
  });
@@ -0,0 +1,19 @@
1
+ /* Theme surface the class-portability checker compiles against, shipped as
2
+ * `dist/styles/_probe-preset.css`. Internal to `graphene-check-classes`; no `exports` mapping.
3
+ *
4
+ * Must declare everything that decides whether a utility can be *generated* — variants, `@utility`,
5
+ * every `@theme` key — and nothing else. Token values and `base-layer` are omitted because they
6
+ * cannot change the class set: Tailwind emits `w-2` because `--spacing-2` is declared, not because
7
+ * the var resolves.
8
+ *
9
+ * Tracks `_dist-entry.css` — what the bundle is actually compiled from — and `preset.css`, its one
10
+ * import that expands into further theme surface. Drift is NOT self-correcting. Over-declaring here
11
+ * is caught by `yarn test:tier1`: `packages/core` starts emitting a class its own bundle lacks.
12
+ * Under-declaring is silent — a theme key those files gain and this one does not simply stops being
13
+ * discoverable, and every enforced target still passes, because `packages/core` passes by
14
+ * construction. `assertProbeParity` in `scripts/check-tier1-classes.mjs` is what holds the three
15
+ * files together; keep it in sync when the import list here changes. */
16
+
17
+ @import 'tw-animate-css';
18
+ @import './theme-directives.css';
19
+ @import './tailwind-theme.css';
@@ -0,0 +1,84 @@
1
+ @import './custom-variants.css';
2
+
3
+ @theme inline {
4
+ /* ── Map semantic tokens to Tailwind utilities ──── */
5
+ --color-background: var(--background);
6
+ --color-foreground: var(--foreground);
7
+ --color-card: var(--card);
8
+ --color-card-foreground: var(--card-foreground);
9
+ --color-popover: var(--popover);
10
+ --color-popover-foreground: var(--popover-foreground);
11
+ --color-primary: var(--primary);
12
+ --color-primary-foreground: var(--primary-foreground);
13
+ --color-secondary: var(--secondary);
14
+ --color-secondary-foreground: var(--secondary-foreground);
15
+ --color-muted: var(--muted);
16
+ --color-muted-foreground: var(--muted-foreground);
17
+ --color-accent: var(--accent);
18
+ --color-accent-foreground: var(--accent-foreground);
19
+ --color-destructive: var(--destructive);
20
+ --color-destructive-foreground: var(--destructive-foreground);
21
+ --color-success: var(--success);
22
+ --color-success-foreground: var(--success-foreground);
23
+ --color-warning: var(--warning);
24
+ --color-warning-foreground: var(--warning-foreground);
25
+ --color-highlight: var(--highlight);
26
+ --color-highlight-foreground: var(--highlight-foreground);
27
+ --color-badge-default-foreground: var(--badge-default-foreground);
28
+ --color-badge-destructive-foreground: var(--badge-destructive-foreground);
29
+ --color-badge-success-foreground: var(--badge-success-foreground);
30
+ --color-badge-warning-foreground: var(--badge-warning-foreground);
31
+ --color-badge-highlight-foreground: var(--badge-highlight-foreground);
32
+ --color-border: var(--border);
33
+ --color-input: var(--input);
34
+ --color-ring: var(--ring);
35
+
36
+ /* ── Sidebar colors ──────────────────────────────── */
37
+ --color-sidebar: var(--sidebar-background);
38
+ --color-sidebar-foreground: var(--sidebar-foreground);
39
+ --color-sidebar-border: var(--sidebar-border);
40
+ --color-sidebar-accent: var(--sidebar-accent);
41
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
42
+ --color-sidebar-primary: var(--sidebar-primary);
43
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
44
+ --color-sidebar-ring: var(--sidebar-ring);
45
+
46
+ /* ── Typography ─────────────────────────────────── */
47
+ --font-sans: var(--graphene-font-sans);
48
+
49
+ /* ── Layout dimensions ──────────────────────────── */
50
+ --spacing-topnav: var(--topnav-height);
51
+ --spacing-content: var(--content-padding);
52
+ --spacing-sidebar-height: var(--sidebar-height);
53
+ --container-content: var(--content-max-width);
54
+ --max-width-content: var(--content-max-width);
55
+ --max-width-focused: var(--content-focused-width);
56
+
57
+ /* ── Animations ──────────────────────────────────── */
58
+ --animate-accordion-down: accordion-down 0.2s ease-out;
59
+ --animate-accordion-up: accordion-up 0.2s ease-out;
60
+
61
+ /* ── Radius ──────────────────────────────────────── */
62
+ --radius-sm: calc(var(--radius) - 0.125rem);
63
+ --radius-md: var(--radius);
64
+ --radius-lg: calc(var(--radius) + 0.125rem);
65
+ --radius-xl: calc(var(--radius) + 0.375rem);
66
+ }
67
+
68
+ @keyframes accordion-down {
69
+ from {
70
+ height: 0;
71
+ }
72
+ to {
73
+ height: var(--radix-accordion-content-height);
74
+ }
75
+ }
76
+
77
+ @keyframes accordion-up {
78
+ from {
79
+ height: var(--radix-accordion-content-height);
80
+ }
81
+ to {
82
+ height: 0;
83
+ }
84
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gravitee/graphene-core",
3
- "version": "3.10.0",
3
+ "version": "3.11.0-fix-found-211-ajv-unknown-format.338f8cb",
4
4
  "type": "module",
5
5
  "description": "Gravitee's Graphene design system: accessible React components, design tokens, icons, and shared ESLint/TypeScript configs. Built on shadcn/ui and Tailwind.",
6
6
  "keywords": [
@@ -53,6 +53,7 @@
53
53
  "./tsconfig": "./tsconfig.shared.json"
54
54
  },
55
55
  "bin": {
56
+ "graphene-check-classes": "./scripts/check-classes.mjs",
56
57
  "graphene-sync-ai-rules": "./scripts/sync-ai-rules.mjs"
57
58
  },
58
59
  "files": [
@@ -61,6 +62,7 @@
61
62
  "eslint.config.shared.mjs",
62
63
  "eslint.legacy.shared.cjs",
63
64
  "tsconfig.shared.json",
65
+ "scripts/check-classes.mjs",
64
66
  "scripts/sync-ai-rules.mjs",
65
67
  "USAGE_GUIDE.md"
66
68
  ],
@@ -77,6 +79,7 @@
77
79
  },
78
80
  "dependencies": {
79
81
  "@base-ui/react": "1.4.1",
82
+ "@tailwindcss/cli": "4.3.0",
80
83
  "@types/json-schema": "7.0.15",
81
84
  "ajv": "8.20.0",
82
85
  "ajv-formats": "3.0.1",
@@ -85,16 +88,19 @@
85
88
  "cmdk": "1.1.1",
86
89
  "date-fns": "4.1.0",
87
90
  "lucide-react": "1.14.0",
91
+ "postcss": "8.5.26",
88
92
  "radix-ui": "1.4.3",
89
93
  "react-day-picker": "9.14.0",
90
94
  "react-dropzone": "15.0.0",
91
95
  "react-resizable-panels": "4.11.1",
92
96
  "sonner": "2.0.7",
93
97
  "tailwind-merge": "3.6.0",
98
+ "tw-animate-css": "1.4.0",
94
99
  "vaul": "1.1.2"
95
100
  },
96
101
  "peerDependencies": {
97
102
  "@fontsource/dm-sans": ">=5.0.0",
103
+ "@gravitee/graphene-core": "3.11.0-fix-found-211-ajv-unknown-format.338f8cb",
98
104
  "@monaco-editor/react": "^4.7.0",
99
105
  "@tanstack/react-table": "^8.0.0",
100
106
  "@testing-library/dom": ">=10.0.0",
@@ -0,0 +1,409 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Fails if a class the given source uses is absent from the pre-built
4
+ * `@gravitee/graphene-core/styles` bundle, where it would silently render as nothing.
5
+ *
6
+ * Rationale and limits: USAGE_GUIDE.md → "Verifying class coverage".
7
+ *
8
+ * Compiles the source with Tailwind and diffs the emitted class set against the bundle's — real
9
+ * Tailwind output on both sides, so no extraction heuristics. Twice per target, unioned:
10
+ *
11
+ * 1. Graphene's theme, the one the bundle was built with. Finds classes the bundle didn't ship.
12
+ * 2. Tailwind's stock theme, plus Graphene's variants so `data-open:` still parses. Finds classes
13
+ * Graphene's theme cannot generate at all. Pass 1 is blind to these — under `--color-*:
14
+ * initial` there is no `yellow-500`, so `bg-yellow-500` emits nothing and the diff has nothing
15
+ * to compare — yet they are just as dead in the bundle, and just as silent.
16
+ *
17
+ * Both passes only *discover* candidate names; bundle membership is the sole test. What discovery
18
+ * cannot narrow is Tailwind's own scan, which reads comments and string literals as readily as
19
+ * `className` — so a class named only in prose is still a finding. `--ignore` is the escape hatch,
20
+ * and it is checked for staleness so it cannot quietly rot into a list of excused regressions.
21
+ *
22
+ * Requires the installed `@gravitee/graphene-core` to have a built `dist/` — always true for a
23
+ * published install; in this repo, run `yarn build` first.
24
+ *
25
+ * Exit codes: 0 clean, 1 the check failed, 2 the tool could not run.
26
+ */
27
+
28
+ import { execFileSync } from 'node:child_process';
29
+ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
30
+ import { createRequire } from 'node:module';
31
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
32
+ import { fileURLToPath } from 'node:url';
33
+ import postcss from 'postcss';
34
+
35
+ const require = createRequire(import.meta.url);
36
+
37
+ // Resolved from this file, not the caller's cwd, so the answer is always about the copy of Graphene
38
+ // the caller actually has installed.
39
+ const PKG_ROOT = fileURLToPath(new URL('..', import.meta.url));
40
+ const PKG = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'));
41
+ const fromExports = (subpath) => {
42
+ const target = PKG.exports[subpath];
43
+ // Conditional exports would need a resolver; these two entries are plain strings by design.
44
+ if (typeof target !== 'string') fail(`export '${subpath}' is not a plain path. This build cannot be probed.`);
45
+ return join(PKG_ROOT, target);
46
+ };
47
+
48
+ const CORE_BUNDLE = fromExports('./styles');
49
+ const STYLES_DIR = dirname(fromExports('./tailwind-theme'));
50
+ const PROBE_PRESET = join(STYLES_DIR, '_probe-preset.css');
51
+ const CUSTOM_VARIANTS = join(STYLES_DIR, 'custom-variants.css');
52
+
53
+ // Not shipped, so not part of the contract. Bare directories are handed to Tailwind for its own
54
+ // file detection rather than an extension list, which is why markdown has to be excluded here: a
55
+ // docs page demoing `h-[200px]` in a code block is not a component that will render unstyled.
56
+ const DEFAULT_EXCLUDES = [
57
+ '**/*.stories.{ts,tsx,js,jsx,mdx}',
58
+ '**/*.{test,spec}.{ts,tsx,js,jsx}',
59
+ '**/__test__/**',
60
+ '**/__tests__/**',
61
+ '**/*.{md,mdx}',
62
+ ];
63
+
64
+ const HELP = `Usage: graphene-check-classes <source>... [options]
65
+
66
+ Fails if any class your source uses is absent from the pre-built
67
+ @gravitee/graphene-core/styles bundle, where it would silently render as nothing.
68
+
69
+ <source> A directory or glob to check. Repeatable; reported separately.
70
+ Must exist — a path that resolves to nothing is an error, not a pass.
71
+ --advisory <source> Same, but never fails the run. Repeatable.
72
+ --advisory-note <text> Printed alongside advisory results (e.g. a tracking issue URL).
73
+ --exclude <glob> Extra exclusion, relative to the current directory. Repeatable.
74
+ Stories, tests and markdown are always excluded — they are not shipped.
75
+ --ignore <class> Never report this class. Repeatable, or comma-separated. An ignore
76
+ that stops matching anything fails the run, so the list cannot rot.
77
+
78
+ Example:
79
+ graphene-check-classes src --advisory src/legacy --ignore bg-yellow-500`;
80
+
81
+ function fail(message) {
82
+ console.error(`graphene-check-classes: ${message}`);
83
+ process.exit(2);
84
+ }
85
+
86
+ function parseArgs(argv) {
87
+ const enforced = [];
88
+ const advisory = [];
89
+ const excludes = [];
90
+ const ignores = new Set();
91
+ let advisoryNote = '';
92
+
93
+ for (let i = 0; i < argv.length; i++) {
94
+ const arg = argv[i];
95
+ const value = () => {
96
+ const next = argv[++i];
97
+ if (next === undefined) fail(`${arg} requires a value`);
98
+ return next;
99
+ };
100
+ if (arg === '--advisory') advisory.push(value());
101
+ else if (arg === '--advisory-note') advisoryNote = value();
102
+ // Resolved here rather than passed through: Tailwind resolves `@source not` against the probe
103
+ // stylesheet, which lives in node_modules, so a relative glob would silently match nothing.
104
+ else if (arg === '--exclude') excludes.push(toAbsoluteGlob(value()));
105
+ else if (arg === '--ignore') {
106
+ for (const name of value().split(',')) {
107
+ if (name.trim()) ignores.add(name.trim());
108
+ }
109
+ } else if (arg === '--help' || arg === '-h') {
110
+ console.log(HELP);
111
+ process.exit(0);
112
+ } else if (arg.startsWith('-')) fail(`unknown option ${arg}`);
113
+ else enforced.push(arg);
114
+ }
115
+
116
+ if (enforced.length === 0 && advisory.length === 0) {
117
+ fail('no sources given. Usage: graphene-check-classes <source>... (see --help)');
118
+ }
119
+ return { advisory, advisoryNote, enforced, excludes, ignores };
120
+ }
121
+
122
+ /**
123
+ * The CLI, plus a directory to compile from. Tailwind resolves `@import 'tailwindcss'` relative to
124
+ * the stylesheet, so the probe cannot live in the OS temp dir; writing it inside the `node_modules`
125
+ * that provides `tailwindcss` makes that resolution work wherever the package manager put it.
126
+ */
127
+ function resolveTailwind() {
128
+ let cliPkgPath;
129
+ try {
130
+ cliPkgPath = require.resolve('@tailwindcss/cli/package.json');
131
+ } catch {
132
+ fail("could not resolve '@tailwindcss/cli'. Reinstall @gravitee/graphene-core, or add it yourself.");
133
+ }
134
+ const cliPkg = JSON.parse(readFileSync(cliPkgPath, 'utf8'));
135
+ const bin = typeof cliPkg.bin === 'string' ? cliPkg.bin : cliPkg.bin.tailwindcss;
136
+ const cli = join(dirname(cliPkgPath), bin);
137
+
138
+ // Prefer whatever `tailwindcss` this script sees; fall back to the CLI's own copy when a strict
139
+ // package manager has nested it out of reach.
140
+ let tailwindPkgPath;
141
+ for (const from of [require, createRequire(cliPkgPath)]) {
142
+ try {
143
+ tailwindPkgPath = from.resolve('tailwindcss/package.json');
144
+ break;
145
+ } catch {
146
+ /* try the next resolution root */
147
+ }
148
+ }
149
+ if (!tailwindPkgPath) fail("could not resolve 'tailwindcss'. Reinstall @gravitee/graphene-core.");
150
+
151
+ // `<…>/node_modules/tailwindcss/package.json` → `<…>/node_modules`
152
+ return { cli, scratchParent: dirname(dirname(tailwindPkgPath)) };
153
+ }
154
+
155
+ const { cli: TAILWIND_CLI, scratchParent: SCRATCH_PARENT } = resolveTailwind();
156
+
157
+ /** Consume `.` then class-name characters, treating `\x` as one literal character. */
158
+ const CLASS_TOKEN = /\.((?:\\.|[^\s.{},:>+~()[\]"'\\])+)/g;
159
+
160
+ /**
161
+ * The utility each rule was generated for, unescaped to match what an author writes in `className`:
162
+ * `.bg-primary\/\[0\.08\]` → `bg-primary/[0.08]`. Walks rule nodes rather than regexing the file, so
163
+ * decimals in declaration values (`0.375rem`) are never read as class selectors.
164
+ *
165
+ * Only the *leftmost* class in each selector counts. Every other one is a class the utility is
166
+ * aimed at rather than a class being asked for: the `.group\/row` in `:is(:where(.group\/row):hover
167
+ * *)`, the `svg.lucide-check` in `[&>svg.lucide-check]:hidden`. Those are never utilities, carry no
168
+ * CSS of their own, and can only be in the bundle if some Graphene component happens to target the
169
+ * same name — so counting them reports classes nobody can act on. Verified against the shipped
170
+ * bundle: leftmost-only drops exactly 26 names, all of them `group`/`peer` markers or third-party
171
+ * hooks (`rdp-button_next`), and no real utility. Applied to both sides, so it stays symmetric.
172
+ *
173
+ * Rules nested under another rule are skipped entirely. Tailwind writes variants as nested CSS
174
+ * (`.group-has-\[\>svg\]\/alert\:col-start-2 { &:is(:where(.group\/alert):has(>svg) *) { … } }`) and
175
+ * only flattens them when minifying, so the inner rule is the same utility seen twice — with the
176
+ * targeted class now leftmost. Without this the probe and the minified bundle disagree about their
177
+ * own output. The test has to walk the whole ancestor chain rather than check the immediate parent:
178
+ * a responsive variant nests rule → `@media` → rule, which is how bare `peer` got through.
179
+ */
180
+ function emittedClasses(css) {
181
+ const found = new Set();
182
+ postcss.parse(css).walkRules((rule) => {
183
+ for (let node = rule.parent; node; node = node.parent) {
184
+ if (node.type === 'rule') return;
185
+ }
186
+ for (const selector of rule.selectors) {
187
+ const first = selector.match(CLASS_TOKEN);
188
+ if (first) found.add(first[0].slice(1).replace(/\\(.)/g, '$1'));
189
+ }
190
+ });
191
+ return found;
192
+ }
193
+
194
+ /** Compile `sourceGlobs` under `themeImports` and return the classes Tailwind emits. */
195
+ function compile(themeImports, sourceGlobs, excludes) {
196
+ const work = mkdtempSync(join(SCRATCH_PARENT, '.graphene-classes-'));
197
+ let tailwindError;
198
+ try {
199
+ const entry = join(work, 'probe.css');
200
+ writeFileSync(
201
+ entry,
202
+ [
203
+ // `source(none)` disables Tailwind's automatic source detection, which would otherwise walk
204
+ // out to the project root and pull in code the caller did not ask about.
205
+ `@import 'tailwindcss' source(none);`,
206
+ ...themeImports.map((file) => `@import '${file}';`),
207
+ ...sourceGlobs.map((glob) => `@source '${glob}';`),
208
+ ...excludes.map((glob) => `@source not '${glob}';`),
209
+ '',
210
+ ].join('\n'),
211
+ );
212
+ const out = join(work, 'probe.out.css');
213
+ try {
214
+ execFileSync(process.execPath, [TAILWIND_CLI, '-i', entry, '-o', out], { stdio: 'pipe' });
215
+ } catch (error) {
216
+ tailwindError = error;
217
+ }
218
+ if (!tailwindError) return emittedClasses(readFileSync(out, 'utf8'));
219
+ } finally {
220
+ rmSync(work, { recursive: true, force: true });
221
+ }
222
+
223
+ // Reported out here, not from the `catch` above: `fail()` calls process.exit(), which skips
224
+ // pending `finally` blocks, so failing inline would strand the scratch directory in the caller's
225
+ // node_modules — the one place it has to live and the last place they would look. A Tailwind
226
+ // failure is the tool not running, not a verdict about the caller's classes; hence exit 2.
227
+ fail(`Tailwind could not compile the probe for ${sourceGlobs.join(', ')}:\n${tailwindError.message}`);
228
+ }
229
+
230
+ const GLOB_CHARS = /[*?[\]{}]/;
231
+
232
+ /** Anchor a caller-supplied glob to the current directory; `@source not` resolves from node_modules. */
233
+ function toAbsoluteGlob(glob) {
234
+ return isAbsolute(glob) ? glob : join(process.cwd(), glob);
235
+ }
236
+
237
+ /** Absolute `@source` glob plus the exclusions that apply to it. */
238
+ function sourceSpec(target, extraExcludes) {
239
+ const abs = isAbsolute(target) ? target : resolve(process.cwd(), target);
240
+ const literal = abs.slice(0, GLOB_CHARS.test(abs) ? abs.search(GLOB_CHARS) : abs.length);
241
+ // A bare directory is handed to Tailwind as-is so its own file detection applies; anything with
242
+ // glob syntax is the caller's business and passes through untouched. `dirname` alone would climb
243
+ // one level too far for the common `src/**/*.tsx` shape, whose literal prefix is already a
244
+ // directory — that only skews `locate`'s search root, but it skews it wider than the target.
245
+ const base = !GLOB_CHARS.test(abs) ? abs : literal.endsWith(sep) ? literal.slice(0, -1) : dirname(literal);
246
+ return {
247
+ base,
248
+ excludes: [...DEFAULT_EXCLUDES.map((glob) => join(base, glob)), ...extraExcludes],
249
+ glob: abs,
250
+ };
251
+ }
252
+
253
+ /**
254
+ * A target that resolves to nothing must be an error. Returning "0 classes, all present" for a
255
+ * renamed directory turns the CI gate into a green light that checks nothing — the exact shape of
256
+ * silent regression this tool exists to catch.
257
+ */
258
+ function assertTargetExists(target, spec) {
259
+ try {
260
+ statSync(spec.base);
261
+ } catch {
262
+ fail(`source '${target}' does not exist (looked for ${spec.base}).`);
263
+ }
264
+ }
265
+
266
+ /** Characters that can continue a Tailwind class, so a match touching one is part of a longer name. */
267
+ const CLASS_CHAR = /[\w\-./:%[\]]/;
268
+
269
+ /** Does `className` appear in `text` as a whole class rather than as a prefix of a longer one? */
270
+ function containsClass(text, className) {
271
+ const escaped = className.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
272
+ return new RegExp(`(?<!${CLASS_CHAR.source})${escaped}(?!${CLASS_CHAR.source})`).test(text);
273
+ }
274
+
275
+ /** Where a missing class is written. Best-effort: returns nothing outside a git repository. */
276
+ function locate(base, className) {
277
+ let hits;
278
+ try {
279
+ // `--untracked` matters more than it looks: the file you just created and have not added yet is
280
+ // exactly when you most need to be told where the dead class is.
281
+ hits = execFileSync('git', ['grep', '-l', '-F', '--untracked', '-e', className, '--', base], {
282
+ encoding: 'utf8',
283
+ stdio: ['ignore', 'pipe', 'ignore'],
284
+ });
285
+ } catch {
286
+ return [];
287
+ }
288
+ return (
289
+ hits
290
+ .split('\n')
291
+ .filter(Boolean)
292
+ .filter((f) => !f.includes('.stories.') && !f.includes('.test.') && !f.includes('.spec.'))
293
+ .filter((f) => !/(^|\/)__tests?__\//.test(f))
294
+ // git's own `-w` bounds on word characters, which does not bound a Tailwind class: it happily
295
+ // matches `[&_code]:rounded` inside `[&_code]:rounded-sm`, and `gap-x` inside `gap-x-4`. Confirm
296
+ // each candidate here instead, where the boundary can be spelled correctly.
297
+ .filter((f) => {
298
+ try {
299
+ return containsClass(readFileSync(f, 'utf8'), className);
300
+ } catch {
301
+ return false;
302
+ }
303
+ })
304
+ );
305
+ }
306
+
307
+ /** Every class the source needs, from both discovery passes (see header). */
308
+ function classesRequiredBy(spec) {
309
+ const constrained = compile([PROBE_PRESET], [spec.glob], spec.excludes);
310
+ const stockTheme = compile([CUSTOM_VARIANTS], [spec.glob], spec.excludes);
311
+ return new Set([...constrained, ...stockTheme]);
312
+ }
313
+
314
+ const { advisory, advisoryNote, enforced, excludes, ignores } = parseArgs(process.argv.slice(2));
315
+
316
+ for (const [label, path] of [
317
+ ['bundle', CORE_BUNDLE],
318
+ ['probe preset', PROBE_PRESET],
319
+ ]) {
320
+ try {
321
+ statSync(path);
322
+ } catch {
323
+ fail(`${label} not found at ${path}. @gravitee/graphene-core@${PKG.version} looks unbuilt — run \`yarn build\`.`);
324
+ }
325
+ }
326
+
327
+ const shipped = emittedClasses(readFileSync(CORE_BUNDLE, 'utf8'));
328
+ console.log(`@gravitee/graphene-core@${PKG.version} — ${shipped.size} classes emitted by its /styles bundle\n`);
329
+
330
+ const usedIgnores = new Set();
331
+
332
+ /** Compile one target and split what it needs into reported misses and ignored ones. */
333
+ function evaluate(target) {
334
+ const spec = sourceSpec(target, excludes);
335
+ assertTargetExists(target, spec);
336
+ const required = classesRequiredBy(spec);
337
+ const absent = [...required].filter((c) => !shipped.has(c)).sort();
338
+ for (const className of absent) {
339
+ if (ignores.has(className)) usedIgnores.add(className);
340
+ }
341
+ return {
342
+ ignored: absent.filter((c) => ignores.has(c)),
343
+ missing: absent.filter((c) => !ignores.has(c)),
344
+ required,
345
+ spec,
346
+ };
347
+ }
348
+
349
+ const suffix = (ignored) => (ignored.length ? ` (${ignored.length} ignored)` : '');
350
+
351
+ let failed = false;
352
+
353
+ for (const target of enforced) {
354
+ const { ignored, missing, required, spec } = evaluate(target);
355
+
356
+ if (missing.length === 0) {
357
+ console.log(` ✓ ${target} — ${required.size} classes, all present in the bundle${suffix(ignored)}`);
358
+ continue;
359
+ }
360
+
361
+ failed = true;
362
+ console.log(` ✗ ${target} — ${missing.length} class(es) absent from the bundle${suffix(ignored)}:\n`);
363
+ for (const className of missing) {
364
+ console.log(` ${className}`);
365
+ for (const file of locate(spec.base, className)) console.log(` ${file}`);
366
+ }
367
+ console.log('');
368
+ }
369
+
370
+ // A bare count, never fatal, so the enforced results above stay readable in CI output.
371
+ for (const target of advisory) {
372
+ const { ignored, missing } = evaluate(target);
373
+ const status = missing.length === 0 ? `✓ ${target} — 0 missing` : `! ${target} — ${missing.length} missing`;
374
+ console.log(` ${status}${suffix(ignored)} (advisory, not enforced${advisoryNote ? ` — ${advisoryNote}` : ''})`);
375
+ }
376
+
377
+ // An ignore list nobody prunes is the "documented list of known-dead classes" this check replaced.
378
+ // Once a class is genuinely fixed or genuinely gone, its ignore has to go with it.
379
+ const staleIgnores = [...ignores].filter((c) => !usedIgnores.has(c)).sort();
380
+ if (staleIgnores.length > 0) {
381
+ failed = true;
382
+ const noun = staleIgnores.length === 1 ? 'entry' : 'entries';
383
+ console.log(`\n ✗ ${staleIgnores.length} --ignore ${noun} matched nothing and should be dropped:\n`);
384
+ for (const className of staleIgnores) console.log(` ${className}`);
385
+ }
386
+
387
+ if (failed) {
388
+ console.log(
389
+ [
390
+ '',
391
+ 'These classes render as no-ops for anyone importing the pre-built',
392
+ '@gravitee/graphene-core/styles without running Tailwind over Graphene themselves.',
393
+ '',
394
+ "Fix by preference order (Graphene's spacing scale is sparse — 0 0.5 1 1.5 2 2.5 3 3.5 4 5 6",
395
+ '7 8 9 10 12 16 20 24 32 40 44 48 56 64 72 80 96 px — so other two-digit steps (w-52, w-60,',
396
+ 'and friends) do NOT exist here):',
397
+ ' 1. Use a standard utility that is actually on that scale (w-[2.5rem] → w-10).',
398
+ ' 2. Move one-off structural values into an inline `style`.',
399
+ ' 3. If the utility is genuinely common, ask for it in the bundle:',
400
+ ` ${PKG.bugs.url}`,
401
+ '',
402
+ 'If the name is not a class at all — prose in a comment, a string that happens to look like',
403
+ 'one — `--ignore <class>` suppresses it, and fails later if it ever stops matching.',
404
+ ].join('\n'),
405
+ );
406
+ process.exit(1);
407
+ }
408
+
409
+ console.log('\nAll enforced sources are portable against this bundle.');