@hublo/sentinel 1.2.0-alpha.21 → 1.2.0-alpha.22

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.
@@ -22,7 +22,7 @@ import {
22
22
  registerAdapters,
23
23
  resolve,
24
24
  resolveBin
25
- } from "../chunk-RKVGZNJ7.js";
25
+ } from "../chunk-YBJLWNTQ.js";
26
26
 
27
27
  // bin/sentinel.ts
28
28
  import { program } from "commander";
@@ -267,7 +267,8 @@ var toolchain_default = {
267
267
  "UserConfig",
268
268
  "UserConfigExport"
269
269
  ],
270
- why: "the bundler itself. `defineConfig`, `loadEnv` and `mergeConfig` are what every config in this repo imports from it, and the types travel with them: a config importing PluginOption from a package it no longer declares builds but does not typecheck."
270
+ why: "the bundler itself. `defineConfig`, `loadEnv` and `mergeConfig` are what every config in this repo imports from it, and the types travel with them: a config importing PluginOption from a package it no longer declares builds but does not typecheck.",
271
+ floor: "Never below 8.2.2. Vite 8.0.8 resolves rolldown 1.0.0-rc.15, a release candidate whose deferred-module-initialisation codegen is broken: it wrapped a plain re-export barrel in an init helper, then required the module without binding its exports before calling the very function that binding provides. Measured, that crashed 7 of the 8 Nest services with a transformer at their first require (`init_<module> is not defined`), and quietly moved the published contract of 4 others. 8.2.2 resolves rolldown 1.2.6 and all 35 services regenerate their committed OpenAPI document identically. The version is not a preference, it is the floor at which those measurements hold."
271
272
  },
272
273
  {
273
274
  package: "@vitejs/plugin-react",
@@ -956,6 +957,9 @@ function planReactApp(context, configFile) {
956
957
  `removed ${owned.map(([, name]) => name).join(", ")} from this module: sentinel owns them now, and two copies of Vite in one build give the plugins a different Vite than the one running them.`
957
958
  );
958
959
  }
960
+ notes.push(
961
+ `this config is code, and it runs before the build does: any environment variable it reads has to be present wherever you build, including a fresh clone, another CI job or an image. Adoption does not change what it reads, but a failure there arrives early and names this role rather than the variable.`
962
+ );
959
963
  return { operations, notes };
960
964
  }
961
965
  function planNestService(context, webpackTarget) {
@@ -1180,7 +1184,7 @@ var ViteDevAdapter = class extends ViteRoleAdapter {
1180
1184
  };
1181
1185
 
1182
1186
  // src/roles/build/declared-outputs.ts
1183
- import { existsSync as existsSync11, statSync } from "fs";
1187
+ import { existsSync as existsSync11, rmSync, statSync, writeFileSync as writeFileSync2 } from "fs";
1184
1188
  import { join as join11 } from "path";
1185
1189
 
1186
1190
  // src/core/workspace-prep.ts
@@ -1350,6 +1354,17 @@ function resolveOutputToken(token, cwd) {
1350
1354
  }
1351
1355
  return join11(cwd, token);
1352
1356
  }
1357
+ function buildStamp(cwd) {
1358
+ const marker = join11(cwd, `.sentinel-build-stamp-${process.pid}`);
1359
+ try {
1360
+ writeFileSync2(marker, "");
1361
+ const stamp = statSync(marker).mtimeMs;
1362
+ rmSync(marker, { force: true });
1363
+ return stamp;
1364
+ } catch {
1365
+ return Date.now();
1366
+ }
1367
+ }
1353
1368
  function writtenSince(path, startedAt) {
1354
1369
  if (!existsSync11(path)) return false;
1355
1370
  try {
@@ -1420,7 +1435,7 @@ var ViteAdapter = class extends ViteRoleAdapter {
1420
1435
  );
1421
1436
  return { ok: false, code: prerequisite.code };
1422
1437
  }
1423
- const startedAt = Date.now();
1438
+ const startedAt = buildStamp(ctx.cwd);
1424
1439
  const result = await this.spawnVite(ctx, ready.vite, ["build"], 1);
1425
1440
  if (!result.ok) return result;
1426
1441
  const problem = outputsProblem(ctx.cwd, startedAt);
@@ -2348,7 +2363,7 @@ function registerFormat() {
2348
2363
 
2349
2364
  // src/roles/lint/adapters/oxlint/oxlint.adapter.ts
2350
2365
  import { spawnSync as spawnSync4 } from "child_process";
2351
- import { existsSync as existsSync23, readFileSync as readFileSync21, rmSync, writeFileSync as writeFileSync2 } from "fs";
2366
+ import { existsSync as existsSync23, readFileSync as readFileSync21, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
2352
2367
  import { join as join27 } from "path";
2353
2368
 
2354
2369
  // src/core/config/deferred-rules.ts
@@ -2885,7 +2900,8 @@ var react_lib_default = {
2885
2900
  {
2886
2901
  name: "storybook",
2887
2902
  specifier: "eslint-plugin-storybook"
2888
- }
2903
+ },
2904
+ "../plugins/hublo.js"
2889
2905
  ],
2890
2906
  settings: null,
2891
2907
  renames: {
@@ -2894,6 +2910,7 @@ var react_lib_default = {
2894
2910
  rules: {
2895
2911
  curly: ["error", "all"],
2896
2912
  "for-direction": "error",
2913
+ "hublo/no-regex-lookbehind": "error",
2897
2914
  "jest-dom/prefer-checked": "error",
2898
2915
  "jest-dom/prefer-empty": "error",
2899
2916
  "jest-dom/prefer-enabled-disabled": "error",
@@ -3245,7 +3262,7 @@ var react_lib_default = {
3245
3262
  ],
3246
3263
  uncovered: {
3247
3264
  "no-octal": "no oxlint equivalent",
3248
- "no-restricted-syntax": "no oxlint equivalent; the house rules it encoded ship as the local `hublo` plugin instead, which is why the app tier hosts `../plugins/hublo.js`. The library tier enforces none of those rules today, so it hosts nothing.",
3265
+ "no-restricted-syntax": "no oxlint equivalent; the house rules it encoded ship as the local `hublo` plugin instead, so this tier hosts `../plugins/hublo.js` too. It enforces exactly one of them: `hublo/no-regex-lookbehind`, because a library is where that bug is WRITTEN and it reaches an app tier only by being imported (HUB-43431 shipped from libs/front/components into the worker app). The palette selectors stay app-tier only.",
3249
3266
  semi: "formatting, and the formatter owns it. It moves to oxfmt, which is deliberately the LAST step of the migration, so nothing enforces it in between."
3250
3267
  }
3251
3268
  };
@@ -3309,6 +3326,7 @@ var react_default = {
3309
3326
  severity: "off",
3310
3327
  reason: "the house rule finds 3 real violations; they are fixed as their own change rather than inside a linter swap, so the rule ships visible-but-off and can be switched on once they are"
3311
3328
  },
3329
+ "hublo/no-regex-lookbehind": "error",
3312
3330
  "jest/expect-expect": {
3313
3331
  severity: "off",
3314
3332
  reason: "pre-existing test-suite debt; enabling it on adoption would surface a wall of findings and make a lateral move look like a regression"
@@ -3795,7 +3813,7 @@ var react_default = {
3795
3813
  uncovered: {
3796
3814
  "import/order": "import ordering is FORMATTING, and the format role owns it: oxfmt's `sortImports` reproduces exactly these groups (builtin, external, internal, parent/index, sibling, alphabetical, blank line between) and sorts a file completely in ONE pass, because it rewrites the whole import block. A lint fixer cannot: it emits text edits over overlapping ranges, so this rule needed several runs to finish (five imports took four passes, measured) and sentinel deliberately runs autofix once. Enforced by `pnpm run format`, not by the linter, which is also why `eslint-plugin-import` is no longer hosted at all: its only other rule, no-duplicates, is native in oxlint.",
3797
3815
  "no-octal": "no oxlint equivalent",
3798
- "no-restricted-syntax": "no oxlint equivalent as configuration, so the eight AST selectors this repo used are shipped as real rules in the local `hublo` plugin instead, which this tier hosts (`../plugins/hublo.js`). Any selector NOT in that plugin is not enforced here; propose it and it joins the plugin rather than a per-module copy.",
3816
+ "no-restricted-syntax": "no oxlint equivalent as configuration, so the AST selectors this repo used are shipped as real rules in the local `hublo` plugin instead, which this tier hosts (`../plugins/hublo.js`). Two are hosted today: the eight palette selectors as `hublo/no-palette-common`, and the lookbehind selector as `hublo/no-regex-lookbehind`. Any selector NOT in that plugin is not enforced here; propose it and it joins the plugin rather than a per-module copy.",
3799
3817
  semi: "formatting, and the formatter owns it. It moves to oxfmt, which is deliberately the LAST step of the migration, so nothing enforces it in between."
3800
3818
  }
3801
3819
  };
@@ -5105,7 +5123,7 @@ ${result.stderr ?? ""}`);
5105
5123
  const measurePath = join27(ctx.cwd, LINT_MEASURE_FILE);
5106
5124
  let measured;
5107
5125
  try {
5108
- writeFileSync2(
5126
+ writeFileSync3(
5109
5127
  measurePath,
5110
5128
  `${JSON.stringify({ ...config, extends: extendsWithBaseline(current, false) }, null, 2)}
5111
5129
  `
@@ -5123,7 +5141,7 @@ ${result.stderr ?? ""}`);
5123
5141
  { cwd: ctx.cwd, encoding: "utf8", env, maxBuffer: CAPTURE_MAX_BUFFER }
5124
5142
  );
5125
5143
  } finally {
5126
- rmSync(measurePath, { force: true });
5144
+ rmSync2(measurePath, { force: true });
5127
5145
  }
5128
5146
  if (measured.error || !(measured.stdout ?? "").includes('"diagnostics"')) {
5129
5147
  process.stderr.write(
@@ -5137,15 +5155,15 @@ ${result.stderr ?? ""}`);
5137
5155
  const holds = holdsFrom(errorCountsByConfigRule(measured.stdout ?? ""));
5138
5156
  const next = extendsWithBaseline(current, holds.length > 0);
5139
5157
  if (holds.length > 0) {
5140
- writeFileSync2(baselinePath, renderBaseline(holds, readOwnPackage().version));
5158
+ writeFileSync3(baselinePath, renderBaseline(holds, readOwnPackage().version));
5141
5159
  process.stderr.write(` ${palette(process.stderr).warn(describeHolds(holds))}
5142
5160
  `);
5143
5161
  } else {
5144
- rmSync(baselinePath, { force: true });
5162
+ rmSync2(baselinePath, { force: true });
5145
5163
  }
5146
5164
  if (next.length !== current.length || next.some((entry, at) => entry !== current[at])) {
5147
5165
  config.extends = next;
5148
- writeFileSync2(configPath, `${JSON.stringify(config, null, 2)}
5166
+ writeFileSync3(configPath, `${JSON.stringify(config, null, 2)}
5149
5167
  `);
5150
5168
  }
5151
5169
  }
@@ -6161,7 +6179,7 @@ function replaceLines(current, replacements) {
6161
6179
  }
6162
6180
 
6163
6181
  // src/core/apply-plan.ts
6164
- import { existsSync as existsSync25, readFileSync as readFileSync24, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
6182
+ import { existsSync as existsSync25, readFileSync as readFileSync24, renameSync, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "fs";
6165
6183
  import { resolve as resolve4, sep } from "path";
6166
6184
  import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser";
6167
6185
 
@@ -6282,14 +6300,14 @@ function preparePlan(cwd, plan3) {
6282
6300
  }
6283
6301
  function writeFileAtomic(absolutePath, contents) {
6284
6302
  const tempPath = `${absolutePath}.sentinel-${process.pid}.tmp`;
6285
- writeFileSync3(tempPath, contents);
6303
+ writeFileSync4(tempPath, contents);
6286
6304
  renameSync(tempPath, absolutePath);
6287
6305
  }
6288
6306
  function applyPlan(cwd, plan3) {
6289
6307
  const changed = preparePlan(cwd, plan3).filter((file) => file.before !== file.after);
6290
6308
  for (const file of changed) {
6291
6309
  if (file.deleted) {
6292
- rmSync2(file.absolutePath, { force: true });
6310
+ rmSync3(file.absolutePath, { force: true });
6293
6311
  continue;
6294
6312
  }
6295
6313
  writeFileAtomic(file.absolutePath, file.after);
@@ -6520,4 +6538,4 @@ export {
6520
6538
  detectFramework,
6521
6539
  dispatch
6522
6540
  };
6523
- //# sourceMappingURL=chunk-RKVGZNJ7.js.map
6541
+ //# sourceMappingURL=chunk-YBJLWNTQ.js.map
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  registerAdapters,
8
8
  resolve,
9
9
  setDefaultRunner
10
- } from "./chunk-RKVGZNJ7.js";
10
+ } from "./chunk-YBJLWNTQ.js";
11
11
  export {
12
12
  BaseAdapter,
13
13
  all,
@@ -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.21",
3
+ "version": "1.2.0-alpha.22",
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",
@@ -85,8 +85,7 @@
85
85
  "nx": "^23.1.0",
86
86
  "tsup": "^8.3.0",
87
87
  "tsx": "^4.19.0",
88
- "typescript": "^5.9.0",
89
- "vitest": "^2.1.0"
88
+ "typescript": "^5.9.0"
90
89
  },
91
90
  "publishConfig": {
92
91
  "registry": "https://registry.npmjs.org",
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