@hublo/sentinel 1.2.0-alpha.9 → 1.2.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/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  registerAdapters,
8
8
  resolve,
9
9
  setDefaultRunner
10
- } from "./chunk-6RH3LZQZ.js";
10
+ } from "./chunk-Z5I6SEKF.js";
11
11
  export {
12
12
  BaseAdapter,
13
13
  all,
@@ -1,6 +1,20 @@
1
1
  import { UserConfig, Plugin } from 'vite';
2
2
  export { Plugin, PluginOption, UserConfig, UserConfigExport, defineConfig, loadEnv, mergeConfig } from 'vite';
3
3
 
4
+ /** A transformer as the target declares it, passed through to the config unchanged. */
5
+ interface TransformerDeclaration$1 {
6
+ name: string;
7
+ options?: Record<string, unknown>;
8
+ }
9
+ /** An asset as the target declares it, in `@nx/webpack`'s own shape. */
10
+ interface AssetDeclaration {
11
+ glob: string;
12
+ /** Relative to the workspace root, as the target declares it. */
13
+ input: string;
14
+ /** Relative to the output directory. */
15
+ output: string;
16
+ }
17
+
4
18
  interface NestServiceOptions {
5
19
  /** The nx project name. Keys the dependency graph, and names the module in messages. */
6
20
  project: string;
@@ -18,6 +32,23 @@ interface NestServiceOptions {
18
32
  tsconfig?: string;
19
33
  /** Output directory, relative to `workspaceRoot`. */
20
34
  outDir?: string;
35
+ /**
36
+ * TypeScript transformers this service compiles with, as its build target declared them.
37
+ *
38
+ * Read off the webpack target by `--init` and written here, never inferred: what a service
39
+ * compiles with is the service's own. Eight services and BFFs run `@nestjs/swagger/plugin`,
40
+ * which writes the `@ApiProperty` decorators their DTOs do not declare by hand. Building
41
+ * without it succeeds and silently costs them most of their published contract (measured on
42
+ * `institution`: 385 insertions, 1378 deletions in the committed OpenAPI document).
43
+ */
44
+ transformers?: readonly TransformerDeclaration$1[];
45
+ /**
46
+ * Files copied into the output beside the bundle, in the shape `@nx/webpack` declared them.
47
+ *
48
+ * One service here uses it: `planning-period` ships the pug, css and ttf templates it renders
49
+ * printed documents from. Without them the service starts and fails on its first print.
50
+ */
51
+ assets?: readonly AssetDeclaration[];
21
52
  /**
22
53
  * Anything this service needs that the shape does not give it.
23
54
  *
@@ -29,6 +60,13 @@ interface NestServiceOptions {
29
60
  }
30
61
  declare const nestService: (options: NestServiceOptions) => UserConfig;
31
62
 
63
+ /** A TypeScript transformer a service runs at build time, as its build target declares it. */
64
+ interface TransformerDeclaration {
65
+ /** The package to load it from, e.g. `@nestjs/swagger/plugin`. */
66
+ name: string;
67
+ /** Passed to the transformer's own factory, unread here. */
68
+ options?: Record<string, unknown>;
69
+ }
32
70
  interface DecoratorMetadataOptions {
33
71
  /** The module being built. Its tsconfig is the one whose emit must be preserved. */
34
72
  root: string;
@@ -38,6 +76,8 @@ interface DecoratorMetadataOptions {
38
76
  * Relative paths resolve against `root`.
39
77
  */
40
78
  tsconfig?: string;
79
+ /** The transformers this service declared, translated from its webpack target. */
80
+ transformers?: readonly TransformerDeclaration[];
41
81
  }
42
82
  declare const decoratorMetadata: (options: DecoratorMetadataOptions) => Plugin;
43
83
 
@@ -1,37 +1,121 @@
1
1
  // src/roles/build/nest/nest-service.ts
2
- import { join as join3 } from "path";
2
+ import { join as join4 } from "path";
3
3
  import { mergeConfig } from "vite";
4
4
 
5
+ // src/roles/build/nest/copy-assets.ts
6
+ import { cpSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
7
+ import { dirname, join, relative, sep } from "path";
8
+ function globToRegExp(glob) {
9
+ let pattern = "";
10
+ for (let index = 0; index < glob.length; index++) {
11
+ const char = glob[index] ?? "";
12
+ if (char === "*") {
13
+ if (glob[index + 1] === "*") {
14
+ pattern += glob[index + 2] === "/" ? "(?:.*/)?" : ".*";
15
+ index += glob[index + 2] === "/" ? 2 : 1;
16
+ } else {
17
+ pattern += "[^/]*";
18
+ }
19
+ continue;
20
+ }
21
+ if (char === "{") {
22
+ const close = glob.indexOf("}", index);
23
+ if (close === -1)
24
+ throw new Error(`sentinel build(nest): unbalanced \`{\` in asset glob ${glob}`);
25
+ const alternatives = glob.slice(index + 1, close).split(",");
26
+ pattern += `(?:${alternatives.map((one) => one.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`;
27
+ index = close;
28
+ continue;
29
+ }
30
+ pattern += char.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
31
+ }
32
+ return new RegExp(`^${pattern}$`);
33
+ }
34
+ function filesUnder(root) {
35
+ const found = [];
36
+ const walk = (dir) => {
37
+ for (const entry of readdirSync(dir)) {
38
+ const full = join(dir, entry);
39
+ if (statSync(full).isDirectory()) walk(full);
40
+ else found.push(relative(root, full).split(sep).join("/"));
41
+ }
42
+ };
43
+ walk(root);
44
+ return found;
45
+ }
46
+ function resolveAssets(options) {
47
+ const copies = [];
48
+ for (const asset of options.assets) {
49
+ const input = join(options.workspaceRoot, asset.input);
50
+ if (!existsSync(input)) {
51
+ throw new Error(
52
+ `sentinel build(nest): the build target declares an asset from \`${asset.input}\`, which does not exist. webpack copied it into the output, so the built service would be missing files it reads at runtime.`
53
+ );
54
+ }
55
+ const matcher = globToRegExp(asset.glob);
56
+ const matched = filesUnder(input).filter((file) => matcher.test(file));
57
+ if (matched.length === 0) {
58
+ throw new Error(
59
+ `sentinel build(nest): the asset glob \`${asset.glob}\` under \`${asset.input}\` matches no file. An entry that matches nothing is the same silent loss as no entry at all.`
60
+ );
61
+ }
62
+ for (const file of matched) {
63
+ copies.push({ from: join(input, file), to: join(options.outDir, asset.output, file) });
64
+ }
65
+ }
66
+ return copies;
67
+ }
68
+ var copyAssets = (options) => ({
69
+ name: "sentinel:copy-assets",
70
+ // After the bundle is written and the output directory has stopped being emptied.
71
+ closeBundle() {
72
+ for (const { from, to } of resolveAssets(options)) {
73
+ mkdirSync(dirname(to), { recursive: true });
74
+ cpSync(from, to);
75
+ }
76
+ }
77
+ });
78
+
5
79
  // src/roles/build/nest/decorator-metadata.ts
6
- import { existsSync } from "fs";
80
+ import { existsSync as existsSync2 } from "fs";
81
+ import { createRequire } from "module";
7
82
  import path from "path";
8
- import ts from "typescript";
9
83
  var TYPESCRIPT_SOURCE = /\.ts$/;
10
84
  var TSCONFIG_CANDIDATES = ["tsconfig.app.json", "tsconfig.json"];
11
- var assertCompilerApi = () => {
12
- if (typeof ts.transpileModule === "function") return;
13
- throw new Error(
14
- `sentinel build(nest): the resolved typescript (${ts.version ?? "unknown version"}) has no transpileModule API. TypeScript 7 is the native port and exposes none; the emit API lives under @typescript/typescript6. Sentinel pins its own compiler, so this means one was substituted, usually by a pnpm override or an alias on \`typescript\`.`
15
- );
16
- };
17
- var resolveTsconfig = (options) => {
85
+ function loadCompiler(root) {
86
+ const require2 = createRequire(path.join(root, "noop.js"));
87
+ let compiler;
88
+ try {
89
+ compiler = require2("typescript");
90
+ } catch {
91
+ throw new Error(
92
+ `sentinel build(nest): no \`typescript\` resolvable from ${root}. The build emits decorator metadata with the module's own compiler, so one has to be installed there.`
93
+ );
94
+ }
95
+ if (typeof compiler.createProgram !== "function") {
96
+ throw new Error(
97
+ `sentinel build(nest): the typescript resolved from ${root} (${compiler.version ?? "unknown"}) has no createProgram API. TypeScript 7 is the native port and exposes none; the emit API lives under @typescript/typescript6. Point this module's \`typescript\` at a compiler with an emit API, which is the one its \`typecheck\` target already uses.`
98
+ );
99
+ }
100
+ return compiler;
101
+ }
102
+ function resolveTsconfig(options) {
18
103
  if (options.tsconfig) {
19
104
  const explicit = path.resolve(options.root, options.tsconfig);
20
- if (!existsSync(explicit)) {
105
+ if (!existsSync2(explicit)) {
21
106
  throw new Error(`sentinel build(nest): tsconfig not found at ${explicit}`);
22
107
  }
23
108
  return explicit;
24
109
  }
25
110
  for (const candidate of TSCONFIG_CANDIDATES) {
26
111
  const found = path.join(options.root, candidate);
27
- if (existsSync(found)) return found;
112
+ if (existsSync2(found)) return found;
28
113
  }
29
114
  throw new Error(
30
115
  `sentinel build(nest): no ${TSCONFIG_CANDIDATES.join(" or ")} in ${options.root}. Pass \`tsconfig\` if this service keeps it elsewhere.`
31
116
  );
32
- };
33
- var readCompilerOptions = (options) => {
34
- assertCompilerApi();
117
+ }
118
+ function readConfig(ts, options) {
35
119
  const configPath = resolveTsconfig(options);
36
120
  const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, {
37
121
  ...ts.sys,
@@ -42,52 +126,120 @@ var readCompilerOptions = (options) => {
42
126
  }
43
127
  });
44
128
  return {
45
- ...parsed?.options,
46
- // ESM out, so Rollup sees imports and exports rather than an opaque `require` it cannot
47
- // follow. The service still SHIPS as CJS: that conversion is the bundler's, further down.
48
- module: ts.ModuleKind.ESNext,
49
- // Vite consumes the map; the tsconfig's own answer is about a different pipeline.
50
- sourceMap: true,
51
- inlineSourceMap: false,
52
- inlineSources: false,
53
- // Types are another target's job, and `transpileModule` could not emit them anyway.
54
- declaration: false,
55
- declarationMap: false,
56
- emitDeclarationOnly: false,
57
- noEmit: false,
58
- // `composite` projects refuse to emit without a `tsBuildInfoFile`, and there is no
59
- // incremental build here to inform.
60
- composite: false,
61
- incremental: false
129
+ fileNames: parsed?.fileNames ?? [],
130
+ compilerOptions: {
131
+ ...parsed?.options,
132
+ // ESM out, so Rollup sees imports and exports rather than an opaque `require` it cannot
133
+ // follow. The service still SHIPS as CJS: that conversion is the bundler's, further down.
134
+ module: ts.ModuleKind.ESNext,
135
+ // Vite consumes the map; the tsconfig's own answer is about a different pipeline.
136
+ sourceMap: true,
137
+ inlineSourceMap: false,
138
+ inlineSources: false,
139
+ // Types are another target's job, and emitting them here would write into the source tree.
140
+ declaration: false,
141
+ declarationMap: false,
142
+ emitDeclarationOnly: false,
143
+ noEmit: false,
144
+ // `composite` projects refuse to emit without a `tsBuildInfoFile`, and there is no
145
+ // incremental build here to inform.
146
+ composite: false,
147
+ incremental: false
148
+ }
62
149
  };
63
- };
150
+ }
151
+ function loadTransformers(root, program, declared) {
152
+ const require2 = createRequire(path.join(root, "noop.js"));
153
+ return declared.map(({ name, options }) => {
154
+ let loaded;
155
+ try {
156
+ loaded = require2(name);
157
+ } catch (error) {
158
+ throw new Error(
159
+ `sentinel build(nest): the build target declares the transformer \`${name}\`, which cannot be loaded from ${root}: ${error instanceof Error ? error.message : String(error)}`,
160
+ { cause: error }
161
+ );
162
+ }
163
+ if (typeof loaded.before !== "function") {
164
+ throw new Error(
165
+ `sentinel build(nest): \`${name}\` exports no \`before\` factory, so it cannot run as a TypeScript transformer. Building without it would drop whatever it contributes.`
166
+ );
167
+ }
168
+ const factory = loaded.before;
169
+ return factory(options, program);
170
+ });
171
+ }
64
172
  var decoratorMetadata = (options) => {
173
+ const typeAware = (options.transformers ?? []).length > 0;
174
+ let ts;
175
+ let program;
65
176
  let compilerOptions;
177
+ let before = [];
178
+ const withoutTypes = [];
66
179
  return {
67
180
  name: "sentinel:decorator-metadata",
68
- // Before Vite's own transform, so esbuild never sees the decorators it cannot handle.
181
+ // Before Vite's own transform, so esbuild never sees the decorators it cannot handle, and so
182
+ // the source this plugin reads from the Program is still the source on disk.
69
183
  enforce: "pre",
184
+ buildStart() {
185
+ ts = loadCompiler(options.root);
186
+ const config = readConfig(ts, options);
187
+ compilerOptions = config.compilerOptions;
188
+ if (!typeAware) return;
189
+ program = ts.createProgram(config.fileNames, config.compilerOptions);
190
+ before = loadTransformers(options.root, program, options.transformers ?? []);
191
+ },
70
192
  transform(code, id) {
71
- if (!TYPESCRIPT_SOURCE.test(id) || id.includes("node_modules")) {
72
- return null;
193
+ if (!TYPESCRIPT_SOURCE.test(id) || id.includes("node_modules")) return null;
194
+ if (ts === void 0 || compilerOptions === void 0) {
195
+ throw new Error(
196
+ `sentinel build(nest): the compiler was never prepared, so ${id} would be emitted with defaults rather than this module's tsconfig.`
197
+ );
198
+ }
199
+ const sourceFile = program?.getSourceFile(id);
200
+ if (program === void 0 || sourceFile === void 0 || sourceFile.text !== code) {
201
+ if (typeAware) withoutTypes.push(id);
202
+ const output = ts.transpileModule(code, { fileName: id, compilerOptions });
203
+ return { code: output.outputText, map: output.sourceMapText ?? null };
204
+ }
205
+ let emitted;
206
+ let map;
207
+ program.emit(
208
+ sourceFile,
209
+ (fileName, text) => {
210
+ if (fileName.endsWith(".map")) map = text;
211
+ else emitted = text;
212
+ },
213
+ void 0,
214
+ false,
215
+ { before }
216
+ );
217
+ if (emitted === void 0) {
218
+ throw new Error(`sentinel build(nest): TypeScript emitted nothing for ${id}.`);
73
219
  }
74
- compilerOptions ??= readCompilerOptions(options);
75
- const output = ts.transpileModule(code, { fileName: id, compilerOptions });
76
- return { code: output.outputText, map: output.sourceMapText ?? null };
220
+ return { code: emitted, map: map ?? null };
221
+ },
222
+ closeBundle() {
223
+ if (withoutTypes.length === 0) return;
224
+ const shown = withoutTypes.slice(0, 5).map((file) => path.relative(options.root, file));
225
+ this.warn(
226
+ `sentinel build(nest): ${withoutTypes.length} file(s) were emitted without type information, so their decorator metadata may differ from what ts-loader produced (${shown.join(", ")}${withoutTypes.length > shown.length ? ", \u2026" : ""}). They are outside this module's tsconfig, or another plugin rewrote them first.`
227
+ );
77
228
  }
78
229
  };
79
230
  };
80
231
 
81
232
  // src/roles/build/nest/node-manifest.ts
82
- import { existsSync as existsSync2, writeFileSync } from "fs";
233
+ import { existsSync as existsSync3, writeFileSync } from "fs";
83
234
  import { isBuiltin } from "module";
84
- import { join } from "path";
235
+ import { join as join2 } from "path";
85
236
  var DEFAULT_PROVIDED_BY_IMAGE = ["@prisma"];
86
237
  var externalPackages = (bundle) => {
87
238
  const emitted = new Set(Object.keys(bundle));
88
239
  const packages = /* @__PURE__ */ new Set();
89
- for (const chunk of Object.values(bundle)) {
90
- for (const imported of chunk.imports ?? []) {
240
+ for (const emittedFile of Object.values(bundle)) {
241
+ if (emittedFile.type !== "chunk") continue;
242
+ for (const imported of emittedFile.imports) {
91
243
  if (emitted.has(imported)) continue;
92
244
  if (imported.startsWith(".") || imported.startsWith("/")) continue;
93
245
  if (isBuiltin(imported)) continue;
@@ -99,7 +251,7 @@ var externalPackages = (bundle) => {
99
251
  return packages;
100
252
  };
101
253
  var nodeManifest = (options) => {
102
- let required = /* @__PURE__ */ new Set();
254
+ let required;
103
255
  return {
104
256
  name: "sentinel:node-manifest",
105
257
  apply: "build",
@@ -107,7 +259,8 @@ var nodeManifest = (options) => {
107
259
  required = externalPackages(bundle);
108
260
  },
109
261
  async closeBundle() {
110
- if (!existsSync2(options.outDir)) return;
262
+ const entry = options.entry ?? "main.js";
263
+ if (!existsSync3(join2(options.outDir, entry))) return;
111
264
  const { createProjectGraphAsync } = await import("nx/src/devkit-exports.js");
112
265
  const { createPackageJson, createLockFile, getLockFileName } = await import("@nx/js");
113
266
  const graph = await createProjectGraphAsync({ exitOnError: false });
@@ -115,12 +268,17 @@ var nodeManifest = (options) => {
115
268
  root: options.workspaceRoot,
116
269
  isProduction: true
117
270
  });
118
- manifest.main = options.entry ?? "main.js";
271
+ manifest.main = entry;
272
+ if (required === void 0) {
273
+ throw new Error(
274
+ `sentinel build(nest): the bundle was written but never inspected, so the manifest could not be checked against what it requires. Refusing to write one that might not install.`
275
+ );
276
+ }
119
277
  assertManifestCovers(required, manifest, options);
120
- writeFileSync(join(options.outDir, "package.json"), `${JSON.stringify(manifest, null, 2)}
278
+ writeFileSync(join2(options.outDir, "package.json"), `${JSON.stringify(manifest, null, 2)}
121
279
  `);
122
280
  writeFileSync(
123
- join(options.outDir, getLockFileName("pnpm")),
281
+ join2(options.outDir, getLockFileName("pnpm")),
124
282
  createLockFile(manifest, graph, "pnpm")
125
283
  );
126
284
  }
@@ -138,17 +296,17 @@ var assertManifestCovers = (required, manifest, options) => {
138
296
 
139
297
  // src/roles/build/nest/tsconfig-aliases.ts
140
298
  import { readFileSync } from "fs";
141
- import { join as join2 } from "path";
299
+ import { join as join3 } from "path";
142
300
  var stripLineComments = (json) => json.replace(/^\s*\/\/.*$/gm, "");
143
301
  var toAlias = (workspaceRoot, pattern, target) => {
144
302
  const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, "(.*)");
145
303
  return {
146
304
  find: new RegExp(`^${escaped}$`),
147
- replacement: join2(workspaceRoot, target.replace(/\*/g, "$1"))
305
+ replacement: join3(workspaceRoot, target.replace(/\*/g, "$1"))
148
306
  };
149
307
  };
150
308
  var tsconfigAliases = (workspaceRoot, file = "tsconfig.base.json") => {
151
- const raw = readFileSync(join2(workspaceRoot, file), "utf8");
309
+ const raw = readFileSync(join3(workspaceRoot, file), "utf8");
152
310
  const { compilerOptions } = JSON.parse(stripLineComments(raw));
153
311
  return Object.entries(compilerOptions?.paths ?? {}).flatMap(([pattern, targets]) => {
154
312
  const [target] = targets;
@@ -158,11 +316,16 @@ var tsconfigAliases = (workspaceRoot, file = "tsconfig.base.json") => {
158
316
 
159
317
  // src/roles/build/nest/nest-service.ts
160
318
  var baseConfig = (options) => {
161
- const outDir = join3(options.workspaceRoot, options.outDir ?? `dist/${options.project}`);
162
- const entry = join3(options.root, options.entry ?? "src/main.ts");
319
+ const outDir = join4(options.workspaceRoot, options.outDir ?? `dist/${options.project}`);
320
+ const entry = join4(options.root, options.entry ?? "src/main.ts");
163
321
  return {
164
322
  plugins: [
165
- decoratorMetadata({ root: options.root, tsconfig: options.tsconfig }),
323
+ decoratorMetadata({
324
+ root: options.root,
325
+ tsconfig: options.tsconfig,
326
+ transformers: options.transformers
327
+ }),
328
+ ...options.assets?.length ? [copyAssets({ workspaceRoot: options.workspaceRoot, outDir, assets: options.assets })] : [],
166
329
  nodeManifest({
167
330
  project: options.project,
168
331
  workspaceRoot: options.workspaceRoot,
@@ -0,0 +1,2 @@
1
+ export { ConfigEnv, TestUserConfig, ViteUserConfig, defineConfig, mergeConfig } from 'vitest/config';
2
+ export { loadEnv } from 'vite';
@@ -0,0 +1,9 @@
1
+ // src/roles/test/react/toolchain.ts
2
+ import { defineConfig, mergeConfig } from "vitest/config";
3
+ import { loadEnv } from "vite";
4
+ export {
5
+ defineConfig,
6
+ loadEnv,
7
+ mergeConfig
8
+ };
9
+ //# sourceMappingURL=toolchain.js.map
@@ -58,6 +58,7 @@
58
58
  "all"
59
59
  ],
60
60
  "for-direction": "error",
61
+ "hublo/no-regex-lookbehind": "error",
61
62
  "jest-dom/prefer-checked": "error",
62
63
  "jest-dom/prefer-empty": "error",
63
64
  "jest-dom/prefer-enabled-disabled": "error",
@@ -348,7 +349,8 @@
348
349
  {
349
350
  "name": "storybook",
350
351
  "specifier": "eslint-plugin-storybook"
351
- }
352
+ },
353
+ "../plugins/hublo.js"
352
354
  ],
353
355
  "overrides": [
354
356
  {
package/lint/react.json CHANGED
@@ -58,6 +58,8 @@
58
58
  "all"
59
59
  ],
60
60
  "for-direction": "error",
61
+ "hublo/no-palette-common": "off",
62
+ "hublo/no-regex-lookbehind": "error",
61
63
  "jest/expect-expect": "off",
62
64
  "jest/no-alias-methods": "off",
63
65
  "jest/no-commented-out-tests": "off",
@@ -469,6 +471,7 @@
469
471
  {
470
472
  "name": "react-refresh",
471
473
  "specifier": "eslint-plugin-react-refresh"
472
- }
474
+ },
475
+ "../plugins/hublo.js"
473
476
  ]
474
477
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hublo/sentinel",
3
- "version": "1.2.0-alpha.9",
3
+ "version": "1.2.0",
4
4
  "description": "One CLI that guards code health across Hublo repos: shared lint/typescript/build/test presets, static & dynamic analysis, and architecture checks.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,6 +45,10 @@
45
45
  "./build/nest": {
46
46
  "types": "./dist/roles/build/nest/toolchain.d.ts",
47
47
  "import": "./dist/roles/build/nest/toolchain.js"
48
+ },
49
+ "./test/react": {
50
+ "types": "./dist/roles/test/react/toolchain.d.ts",
51
+ "import": "./dist/roles/test/react/toolchain.js"
48
52
  }
49
53
  },
50
54
  "files": [
@@ -71,8 +75,9 @@
71
75
  "oxlint": "1.77.0",
72
76
  "oxlint-tsgolint": "7.0.2001",
73
77
  "typescript": "5.9.3",
74
- "vite": "8.0.8",
75
- "vite-plugin-svgr": "5.2.0"
78
+ "vite": "8.2.2",
79
+ "vite-plugin-svgr": "5.2.0",
80
+ "vitest": "4.1.4"
76
81
  },
77
82
  "devDependencies": {
78
83
  "@hublo/sentinel": "link:.",
@@ -80,8 +85,7 @@
80
85
  "nx": "^23.1.0",
81
86
  "tsup": "^8.3.0",
82
87
  "tsx": "^4.19.0",
83
- "typescript": "^5.9.0",
84
- "vitest": "^2.1.0"
88
+ "typescript": "^5.9.0"
85
89
  },
86
90
  "publishConfig": {
87
91
  "registry": "https://registry.npmjs.org",
@@ -102,8 +106,8 @@
102
106
  }
103
107
  },
104
108
  "peerDependencies": {
105
- "nx": ">= 21",
106
- "@nx/js": ">= 21"
109
+ "@nx/js": ">= 21",
110
+ "nx": ">= 21"
107
111
  },
108
112
  "peerDependenciesMeta": {
109
113
  "nx": {
package/plugins/hublo.js CHANGED
@@ -10,6 +10,12 @@
10
10
  * sentinel exists to remove. The cost is that `@hublo/sentinel` carries a rule about Hublo's
11
11
  * colour palette, which is honest for a package named after the company.
12
12
  *
13
+ * It now hosts a SECOND, unrelated rule. `no-regex-lookbehind` is not a carried-over selector
14
+ * but a browser-support rule: regex lookbehind is Safari 16.4+, and unlike everything else a
15
+ * preset controls it can be neither transpiled nor polyfilled, because no bundler rewrites the
16
+ * contents of a regex literal. It is here for the same reason as the palette rule — a house
17
+ * rule is code, not configuration — and because lint is the only layer left that can catch it.
18
+ *
13
19
  * The original selectors, and where each is handled below:
14
20
  * 1-2 MemberExpression[object.property.name='palette'][property.name='common'] theme.palette.common
15
21
  * …[computed=true][property.value='common'] theme.palette['common']
@@ -25,6 +31,15 @@ const MESSAGE =
25
31
  const COLOUR_PROPS =
26
32
  /^(color|bgcolor|background|backgroundColor|borderColor|borderTopColor|borderRightColor|borderBottomColor|borderLeftColor|outlineColor|fill|stroke|textDecorationColor|caretColor|accentColor)$/
27
33
 
34
+ /**
35
+ * Lookbehind, positive or negative. NOT named groups `(?<name>)`, which Safari has had since
36
+ * 11.3: the class is exactly `(?<` followed by `=` or `!`.
37
+ */
38
+ const LOOKBEHIND = /\(\?<[=!]/
39
+
40
+ const LOOKBEHIND_MESSAGE =
41
+ 'Regex lookbehind ((?<=) / (?<!)) is Safari 16.4+ and can be neither transpiled nor polyfilled: on older iOS the literal is a parse-time SyntaxError that takes the whole chunk with it. Rewrite the pattern. Named groups (?<name>) are fine.'
42
+
28
43
  /** `common.something` as a string value, e.g. `{ color: 'common.white' }`. */
29
44
  const isCommonPath = (node) =>
30
45
  node?.type === 'Literal' && typeof node.value === 'string' && /^common\./.test(node.value)
@@ -82,6 +97,59 @@ const plugin = {
82
97
  }
83
98
  },
84
99
  },
100
+
101
+ 'no-regex-lookbehind': {
102
+ meta: {
103
+ docs: {
104
+ description: 'Ban regex lookbehind, unsupported by Safari below 16.4.',
105
+ },
106
+ },
107
+ create(context) {
108
+ const report = (node) => context.report({ node, message: LOOKBEHIND_MESSAGE })
109
+
110
+ /**
111
+ * `new RegExp(...)` and `RegExp(...)`, when the pattern is readable statically: a
112
+ * string, or a template whose fixed parts carry the lookbehind.
113
+ *
114
+ * Each template chunk is tested on its own rather than joined. Joining invents a
115
+ * `(?<=` that the runtime string never has, across an interpolation boundary:
116
+ * `` `(?<${op}=x)` `` would read as one.
117
+ */
118
+ const isStaticLookbehind = (pattern) => {
119
+ if (pattern?.type === 'Literal') {
120
+ return typeof pattern.value === 'string' && LOOKBEHIND.test(pattern.value)
121
+ }
122
+ if (pattern?.type === 'TemplateLiteral') {
123
+ return (pattern.quasis ?? []).some((quasi) =>
124
+ LOOKBEHIND.test(quasi.value?.cooked ?? quasi.value?.raw ?? ''),
125
+ )
126
+ }
127
+ return false
128
+ }
129
+
130
+ const checkRegExpCall = (node) => {
131
+ if (node.callee?.name !== 'RegExp') {
132
+ return
133
+ }
134
+ const pattern = node.arguments?.[0]
135
+ if (isStaticLookbehind(pattern)) {
136
+ report(pattern)
137
+ }
138
+ }
139
+
140
+ return {
141
+ // The case that actually breaks a page: a literal is an EARLY error, so the whole
142
+ // chunk fails to parse rather than the one call site failing.
143
+ Literal(node) {
144
+ if (node.regex && LOOKBEHIND.test(node.regex.pattern)) {
145
+ report(node)
146
+ }
147
+ },
148
+ NewExpression: checkRegExpCall,
149
+ CallExpression: checkRegExpCall,
150
+ }
151
+ },
152
+ },
85
153
  },
86
154
  }
87
155