@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/README.md +17 -2
- package/dist/bin/sentinel.d.ts +1 -0
- package/dist/bin/sentinel.js +4 -4
- package/dist/{chunk-6RH3LZQZ.js → chunk-Z5I6SEKF.js} +1191 -911
- package/dist/index.js +1 -1
- package/dist/roles/build/nest/toolchain.d.ts +40 -0
- package/dist/roles/build/nest/toolchain.js +217 -54
- package/dist/roles/test/react/toolchain.d.ts +2 -0
- package/dist/roles/test/react/toolchain.js +9 -0
- package/lint/react-lib.json +3 -1
- package/lint/react.json +4 -1
- package/package.json +11 -7
- package/plugins/hublo.js +68 -0
package/dist/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
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 (!
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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
|
|
90
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
278
|
+
writeFileSync(join2(options.outDir, "package.json"), `${JSON.stringify(manifest, null, 2)}
|
|
121
279
|
`);
|
|
122
280
|
writeFileSync(
|
|
123
|
-
|
|
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
|
|
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:
|
|
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(
|
|
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 =
|
|
162
|
-
const entry =
|
|
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({
|
|
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,
|
package/lint/react-lib.json
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
"
|
|
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
|
|