@hublo/sentinel 1.0.2 → 1.1.0-alpha.2

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.
@@ -0,0 +1,2062 @@
1
+ // src/core/registry.ts
2
+ var adapters = [];
3
+ var defaultRunner = {
4
+ // Filled in by tool branches, e.g. lint: 'eslint', typescript: 'tsc'.
5
+ };
6
+ function register(adapter) {
7
+ adapters.push(adapter);
8
+ }
9
+ function setDefaultRunner(target, runner) {
10
+ defaultRunner[target] = runner;
11
+ }
12
+ function all() {
13
+ return adapters;
14
+ }
15
+ function availableTargets() {
16
+ return [...new Set(adapters.map((a) => a.target))];
17
+ }
18
+ function resolve(target, flavour, runner) {
19
+ const forTarget = adapters.filter((a) => a.target === target);
20
+ if (forTarget.length === 0) {
21
+ throw new Error(
22
+ `No adapter registered for target "${target}" yet (it ships in a later ticket).`
23
+ );
24
+ }
25
+ const candidates = flavour ? forTarget.filter((a) => a.appliesTo(flavour)) : forTarget;
26
+ if (candidates.length === 0) {
27
+ throw new Error(`No adapter for target "${target}" handles flavour "${flavour}".`);
28
+ }
29
+ const wanted = runner ?? defaultRunner[target];
30
+ const available = candidates.map((a) => a.runner).join(", ");
31
+ if (!wanted) {
32
+ const [first, ...rest] = candidates;
33
+ if (first && rest.length === 0) return first;
34
+ throw new Error(
35
+ `Multiple runners for target "${target}" (${available}); pass --runner or set a default.`
36
+ );
37
+ }
38
+ const matching = candidates.filter((a) => a.runner === wanted);
39
+ if (matching.length === 0) {
40
+ throw new Error(
41
+ `No runner "${wanted}" for target "${target}" (flavour "${flavour}"). Available: ${available}.`
42
+ );
43
+ }
44
+ if (matching.length > 1) {
45
+ throw new Error(
46
+ `Ambiguous: ${matching.length} adapters claim target "${target}", runner "${wanted}", flavour "${flavour}".`
47
+ );
48
+ }
49
+ return matching[0];
50
+ }
51
+
52
+ // src/core/base-adapter.ts
53
+ var BaseAdapter = class {
54
+ inspect(_ctx) {
55
+ throw new Error(`${this.runner}: --inspect not implemented yet`);
56
+ }
57
+ report(_ctx) {
58
+ throw new Error(`${this.runner}: --report not implemented yet`);
59
+ }
60
+ status(_ctx) {
61
+ throw new Error(`${this.runner}: --status not implemented yet`);
62
+ }
63
+ };
64
+
65
+ // src/shared/color.ts
66
+ import { styleText } from "util";
67
+ function palette(stream) {
68
+ const paint = (format, text) => styleText(format, text, { stream });
69
+ return {
70
+ ok: (text) => paint(["green", "bold"], text),
71
+ fail: (text) => paint(["red", "bold"], text),
72
+ warn: (text) => paint("yellow", text),
73
+ strong: (text) => paint("bold", text),
74
+ dim: (text) => paint("dim", text),
75
+ accent: (text) => paint("cyan", text)
76
+ };
77
+ }
78
+
79
+ // src/shared/package-json.ts
80
+ import { existsSync, readFileSync } from "fs";
81
+ import { dirname, join } from "path";
82
+ import { fileURLToPath } from "url";
83
+ function readOwnPackage() {
84
+ let dir = dirname(fileURLToPath(import.meta.url));
85
+ for (; ; ) {
86
+ const pkgPath = join(dir, "package.json");
87
+ if (existsSync(pkgPath)) {
88
+ try {
89
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
90
+ if (typeof pkg.version === "string") {
91
+ return { name: pkg.name ?? "@hublo/sentinel", version: pkg.version };
92
+ }
93
+ } catch {
94
+ }
95
+ }
96
+ const parent = dirname(dir);
97
+ if (parent === dir) return { name: "@hublo/sentinel", version: "0.0.0" };
98
+ dir = parent;
99
+ }
100
+ }
101
+ function readOwnVersion() {
102
+ return readOwnPackage().version;
103
+ }
104
+ function readProjectPackageJson(dir) {
105
+ const path = join(dir, "package.json");
106
+ if (!existsSync(path)) return {};
107
+ try {
108
+ return JSON.parse(readFileSync(path, "utf8"));
109
+ } catch {
110
+ process.stderr.write(`sentinel: could not parse ${path}; ignoring for detection.
111
+ `);
112
+ return {};
113
+ }
114
+ }
115
+ function readNxProjectName(dir) {
116
+ const path = join(dir, "project.json");
117
+ if (!existsSync(path)) return void 0;
118
+ try {
119
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
120
+ return typeof parsed.name === "string" ? parsed.name : void 0;
121
+ } catch {
122
+ return void 0;
123
+ }
124
+ }
125
+
126
+ // src/core/domain.ts
127
+ var VERBS = ["run", "inspect", "init", "migrate", "report", "status"];
128
+ var TARGETS = [
129
+ "lint",
130
+ "format",
131
+ "typescript",
132
+ "build",
133
+ "test",
134
+ "static-analysis",
135
+ "runtime-analysis",
136
+ "arch"
137
+ ];
138
+ var FLAVOURS = ["react", "nest", "svelte", "node"];
139
+
140
+ // src/roles/lint/adapters/oxlint/oxlint.adapter.ts
141
+ import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
142
+ import { basename, join as join7 } from "path";
143
+ import { spawnSync } from "child_process";
144
+
145
+ // src/core/config/deferred-rules.ts
146
+ function deferredRuleNames(rules) {
147
+ return rules.map((entry) => entry.rule);
148
+ }
149
+
150
+ // src/roles/lint/config-policy.ts
151
+ var LINT_CONFIG_FILE = ".oxlintrc.json";
152
+ var LINT_SCRIPT_NAME = "lint";
153
+ var PRESET_DIR = "./node_modules/@hublo/sentinel/oxlint";
154
+ function presetPath(flavour) {
155
+ return `${PRESET_DIR}/${flavour}.json`;
156
+ }
157
+ var SENTINEL_LINT_PRESET = /@hublo\/sentinel\/oxlint\/([a-z-]+)\.json$/;
158
+ function flavourFromPreset(preset) {
159
+ return preset ? SENTINEL_LINT_PRESET.exec(preset)?.[1] ?? void 0 : void 0;
160
+ }
161
+ var PERMITTED_LOCAL_KEYS = ["extends"];
162
+ var ESLINT_CONFIG_FILES = [
163
+ "eslint.config.js",
164
+ "eslint.config.mjs",
165
+ "eslint.config.cjs",
166
+ "eslint.config.ts"
167
+ ];
168
+ function lintTarget() {
169
+ return {
170
+ executor: "nx:run-commands",
171
+ cache: true,
172
+ // The config is an input so a preset bump invalidates the cache. Without it, nx would
173
+ // replay a stale result after the rules changed underneath it.
174
+ inputs: ["default", `{projectRoot}/${LINT_CONFIG_FILE}`],
175
+ options: { cwd: "{projectRoot}", command: `pnpm run ${LINT_SCRIPT_NAME}` }
176
+ };
177
+ }
178
+
179
+ // src/roles/lint/disabled-rules.ts
180
+ var NEST_DISABLED = [
181
+ {
182
+ rule: "no-barrel-files/no-barrel-files",
183
+ reason: "nest modules use barrel index.ts files as a deliberate structural pattern; the rule fired 127 times and every one was the architecture working as intended"
184
+ },
185
+ {
186
+ rule: "no-control-regex",
187
+ reason: "the single occurrence is a regex whose whole purpose is stripping ASCII control characters before XML output, which the rule cannot distinguish from an accident"
188
+ }
189
+ ];
190
+ var REACT_DISABLED = [
191
+ // The jest suite is the single largest group: enabling it turned 135 errors into a
192
+ // wall nobody would read on day one. Adoption has to be a lateral move first.
193
+ {
194
+ rule: "jest/expect-expect",
195
+ 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"
196
+ },
197
+ {
198
+ rule: "jest/no-alias-methods",
199
+ 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"
200
+ },
201
+ {
202
+ rule: "jest/no-commented-out-tests",
203
+ 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"
204
+ },
205
+ {
206
+ rule: "jest/no-conditional-expect",
207
+ 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"
208
+ },
209
+ {
210
+ rule: "jest/no-deprecated-functions",
211
+ 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"
212
+ },
213
+ {
214
+ rule: "jest/no-disabled-tests",
215
+ 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"
216
+ },
217
+ {
218
+ rule: "jest/no-done-callback",
219
+ 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"
220
+ },
221
+ {
222
+ rule: "jest/no-export",
223
+ 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"
224
+ },
225
+ {
226
+ rule: "jest/no-focused-tests",
227
+ 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"
228
+ },
229
+ {
230
+ rule: "jest/no-identical-title",
231
+ 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"
232
+ },
233
+ {
234
+ rule: "jest/no-interpolation-in-snapshots",
235
+ 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"
236
+ },
237
+ {
238
+ rule: "jest/no-jasmine-globals",
239
+ 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"
240
+ },
241
+ {
242
+ rule: "jest/no-mocks-import",
243
+ 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"
244
+ },
245
+ {
246
+ rule: "jest/no-standalone-expect",
247
+ 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"
248
+ },
249
+ {
250
+ rule: "jest/no-test-prefixes",
251
+ 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"
252
+ },
253
+ {
254
+ rule: "jest/prefer-to-be",
255
+ 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"
256
+ },
257
+ {
258
+ rule: "jest/prefer-to-contain",
259
+ 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"
260
+ },
261
+ {
262
+ rule: "jest/prefer-to-have-length",
263
+ 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"
264
+ },
265
+ {
266
+ rule: "jest/valid-describe-callback",
267
+ 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"
268
+ },
269
+ {
270
+ rule: "jest/valid-expect",
271
+ 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"
272
+ },
273
+ {
274
+ rule: "jest/valid-expect-in-promise",
275
+ 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"
276
+ },
277
+ {
278
+ rule: "jest/valid-title",
279
+ 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"
280
+ },
281
+ // TanStack Query rules the codebase has never satisfied.
282
+ {
283
+ rule: "@tanstack/query/exhaustive-deps",
284
+ reason: "the codebase predates these rules and has never satisfied them; enabling them is its own piece of work, not part of swapping linters"
285
+ },
286
+ {
287
+ rule: "@tanstack/query/infinite-query-property-order",
288
+ reason: "the codebase predates these rules and has never satisfied them; enabling them is its own piece of work, not part of swapping linters"
289
+ },
290
+ {
291
+ rule: "@tanstack/query/mutation-property-order",
292
+ reason: "the codebase predates these rules and has never satisfied them; enabling them is its own piece of work, not part of swapping linters"
293
+ },
294
+ {
295
+ rule: "@tanstack/query/no-rest-destructuring",
296
+ reason: "the codebase predates these rules and has never satisfied them; enabling them is its own piece of work, not part of swapping linters"
297
+ },
298
+ {
299
+ rule: "@tanstack/query/no-unstable-deps",
300
+ reason: "the codebase predates these rules and has never satisfied them; enabling them is its own piece of work, not part of swapping linters"
301
+ },
302
+ {
303
+ rule: "@tanstack/query/no-void-query-fn",
304
+ reason: "the codebase predates these rules and has never satisfied them; enabling them is its own piece of work, not part of swapping linters"
305
+ },
306
+ {
307
+ rule: "@tanstack/query/stable-query-client",
308
+ reason: "the codebase predates these rules and has never satisfied them; enabling them is its own piece of work, not part of swapping linters"
309
+ },
310
+ // typescript-eslint rules carrying real, pre-existing debt.
311
+ {
312
+ rule: "typescript/no-empty-function",
313
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
314
+ },
315
+ {
316
+ rule: "typescript/no-explicit-any",
317
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
318
+ },
319
+ {
320
+ rule: "typescript/no-unnecessary-type-assertion",
321
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
322
+ },
323
+ {
324
+ rule: "typescript/no-unsafe-argument",
325
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
326
+ },
327
+ {
328
+ rule: "typescript/no-unsafe-assignment",
329
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
330
+ },
331
+ {
332
+ rule: "typescript/no-unsafe-call",
333
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
334
+ },
335
+ {
336
+ rule: "typescript/no-unsafe-enum-comparison",
337
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
338
+ },
339
+ {
340
+ rule: "typescript/no-unsafe-member-access",
341
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
342
+ },
343
+ {
344
+ rule: "typescript/no-unsafe-return",
345
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
346
+ },
347
+ {
348
+ rule: "typescript/no-unused-vars",
349
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
350
+ },
351
+ {
352
+ rule: "typescript/only-throw-error",
353
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
354
+ },
355
+ {
356
+ rule: "typescript/require-await",
357
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
358
+ },
359
+ // One-offs, each for its own reason.
360
+ {
361
+ rule: "no-console",
362
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
363
+ },
364
+ {
365
+ rule: "no-unsafe-optional-chaining",
366
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
367
+ },
368
+ {
369
+ rule: "react/jsx-curly-brace-presence",
370
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
371
+ },
372
+ {
373
+ rule: "styled-components-a11y/label-has-for",
374
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
375
+ },
376
+ {
377
+ rule: "jsx-a11y/img-redundant-alt",
378
+ reason: 'one real finding, a decorative image whose title is already rendered as a heading beside it, so the fix is alt="". A genuine accessibility issue rather than a false positive: parked only so swapping linters stays a lateral move, and worth switching back on as soon as it is fixed'
379
+ },
380
+ {
381
+ rule: "hublo/no-palette-common",
382
+ 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"
383
+ }
384
+ ];
385
+ var BY_FLAVOUR = {
386
+ nest: NEST_DISABLED,
387
+ react: REACT_DISABLED
388
+ };
389
+ function disabledRulesFor(flavour) {
390
+ return BY_FLAVOUR[flavour] ?? [];
391
+ }
392
+
393
+ // src/roles/lint/flavours/base.ts
394
+ var lintBase = {
395
+ // typescript-eslint (17)
396
+ "typescript/ban-ts-comment": "warn",
397
+ "typescript/no-array-constructor": "warn",
398
+ "typescript/no-duplicate-enum-values": "warn",
399
+ "typescript/no-empty-object-type": "warn",
400
+ "typescript/no-extra-non-null-assertion": "warn",
401
+ "typescript/no-misused-new": "warn",
402
+ "typescript/no-namespace": "warn",
403
+ "typescript/no-non-null-asserted-optional-chain": "warn",
404
+ "typescript/no-this-alias": "warn",
405
+ "typescript/no-unnecessary-type-constraint": "warn",
406
+ "typescript/no-unsafe-declaration-merging": "warn",
407
+ "typescript/no-unsafe-function-type": "warn",
408
+ "typescript/no-unused-expressions": [
409
+ "warn",
410
+ { allowShortCircuit: false, allowTaggedTemplates: false, allowTernary: false }
411
+ ],
412
+ "typescript/no-wrapper-object-types": "warn",
413
+ "typescript/prefer-as-const": "warn",
414
+ "typescript/prefer-namespace-keyword": "warn",
415
+ "typescript/triple-slash-reference": "warn",
416
+ // ESLint core (4)
417
+ "no-var": "warn",
418
+ "prefer-const": ["warn", { destructuring: "any", ignoreReadBeforeAssign: false }],
419
+ "prefer-rest-params": "warn",
420
+ "prefer-spread": "warn"
421
+ };
422
+
423
+ // src/roles/lint/flavours/nest.ts
424
+ var nestLayer = {
425
+ // ESLint core (55)
426
+ "array-callback-return": [
427
+ "warn",
428
+ { allowImplicit: false, checkForEach: false, allowVoid: false }
429
+ ],
430
+ complexity: ["warn", 30],
431
+ curly: ["warn", "all"],
432
+ "dot-notation": ["warn", { allowKeywords: true, allowPattern: "" }],
433
+ eqeqeq: "warn",
434
+ "for-direction": "warn",
435
+ "func-style": [
436
+ "warn",
437
+ "expression",
438
+ { allowArrowFunctions: false, allowTypeAnnotation: false, overrides: {} }
439
+ ],
440
+ "no-async-promise-executor": "warn",
441
+ "no-case-declarations": "warn",
442
+ "no-compare-neg-zero": "warn",
443
+ "no-cond-assign": ["warn", "except-parens"],
444
+ "no-console": ["warn", { allow: ["warn", "error"] }],
445
+ "no-constant-binary-expression": "warn",
446
+ "no-constant-condition": ["warn", { checkLoops: "allExceptWhileTrue" }],
447
+ "no-debugger": "warn",
448
+ "no-delete-var": "warn",
449
+ "no-dupe-else-if": "warn",
450
+ "no-dupe-keys": "warn",
451
+ "no-duplicate-case": "warn",
452
+ "no-else-return": ["warn", { allowElseIf: true }],
453
+ "no-empty": ["warn", { allowEmptyCatch: false }],
454
+ "no-empty-character-class": "warn",
455
+ "no-empty-pattern": ["warn", { allowObjectPatternsAsParameters: false }],
456
+ "no-empty-static-block": "warn",
457
+ "no-ex-assign": "warn",
458
+ "no-extra-boolean-cast": ["warn", {}],
459
+ "no-fallthrough": ["warn", { allowEmptyCase: false, reportUnusedFallthroughComment: false }],
460
+ "no-global-assign": ["warn", { exceptions: [] }],
461
+ "no-implicit-coercion": [
462
+ "warn",
463
+ { allow: [], boolean: true, disallowTemplateShorthand: false, number: true, string: true }
464
+ ],
465
+ "no-invalid-regexp": ["warn", {}],
466
+ "no-irregular-whitespace": [
467
+ "warn",
468
+ {
469
+ skipComments: false,
470
+ skipJSXText: false,
471
+ skipRegExps: false,
472
+ skipStrings: true,
473
+ skipTemplates: false
474
+ }
475
+ ],
476
+ "no-lonely-if": "warn",
477
+ "no-loss-of-precision": "warn",
478
+ "no-misleading-character-class": "warn",
479
+ "no-nonoctal-decimal-escape": "warn",
480
+ "no-prototype-builtins": "warn",
481
+ "no-regex-spaces": "warn",
482
+ "no-restricted-imports": [
483
+ "warn",
484
+ {
485
+ paths: [
486
+ {
487
+ name: "@front/theme",
488
+ importNames: [
489
+ "hubloTheme",
490
+ "legacyHubloTheme",
491
+ "customCommonColors",
492
+ "customPalette",
493
+ "breakpointsOptions",
494
+ "breakpointsValues",
495
+ "spacingConstants"
496
+ ],
497
+ message: "Use the MUI theme context or the public providers exposed by @front/theme."
498
+ }
499
+ ],
500
+ patterns: [
501
+ {
502
+ group: ["@front/theme/*", "!@front/theme/testing"],
503
+ message: "Deep imports from @front/theme are forbidden outside the theme lib."
504
+ }
505
+ ]
506
+ }
507
+ ],
508
+ "no-self-assign": ["warn", { props: true }],
509
+ "no-shadow-restricted-names": ["warn", { reportGlobalThis: false }],
510
+ "no-sparse-arrays": "warn",
511
+ "no-unneeded-ternary": ["warn", { defaultAssignment: true }],
512
+ "no-unsafe-finally": "warn",
513
+ "no-unsafe-optional-chaining": ["warn", { disallowArithmeticOperators: false }],
514
+ "no-unused-labels": "warn",
515
+ "no-unused-private-class-members": "warn",
516
+ "no-useless-backreference": "warn",
517
+ "no-useless-catch": "warn",
518
+ "no-useless-escape": ["warn", { allowRegexCharacters: [] }],
519
+ "no-useless-return": "warn",
520
+ "prefer-arrow-callback": ["warn", { allowNamedFunctions: false, allowUnboundThis: true }],
521
+ "require-yield": "warn",
522
+ "use-isnan": ["warn", { enforceForIndexOf: false, enforceForSwitchCase: true }],
523
+ "valid-typeof": ["warn", { requireStringLiterals: false }],
524
+ // typescript-eslint (10)
525
+ "typescript/await-thenable": "warn",
526
+ "typescript/no-confusing-non-null-assertion": "warn",
527
+ "typescript/no-empty-function": ["warn", { allow: [] }],
528
+ "typescript/no-explicit-any": "warn",
529
+ "typescript/no-floating-promises": ["warn", { ignoreVoid: true }],
530
+ "typescript/no-import-type-side-effects": "warn",
531
+ "typescript/no-inferrable-types": "warn",
532
+ "typescript/no-non-null-assertion": "warn",
533
+ "typescript/no-unused-vars": ["warn", { argsIgnorePattern: "^_", caughtErrors: "none" }],
534
+ "typescript/switch-exhaustiveness-check": ["warn", { considerDefaultExhaustiveForUnions: true }],
535
+ // eslint-plugin-jest (21)
536
+ "jest/expect-expect": "warn",
537
+ "jest/no-alias-methods": "warn",
538
+ "jest/no-commented-out-tests": "warn",
539
+ "jest/no-deprecated-functions": "warn",
540
+ "jest/no-disabled-tests": "warn",
541
+ "jest/no-done-callback": "warn",
542
+ "jest/no-export": "warn",
543
+ "jest/no-focused-tests": "warn",
544
+ "jest/no-identical-title": "warn",
545
+ "jest/no-interpolation-in-snapshots": "warn",
546
+ "jest/no-jasmine-globals": "warn",
547
+ "jest/no-mocks-import": "warn",
548
+ "jest/no-standalone-expect": "warn",
549
+ "jest/no-test-prefixes": "warn",
550
+ "jest/prefer-to-be": "warn",
551
+ "jest/prefer-to-contain": "warn",
552
+ "jest/prefer-to-have-length": "warn",
553
+ "jest/valid-describe-callback": "warn",
554
+ "jest/valid-expect": "warn",
555
+ "jest/valid-expect-in-promise": "warn",
556
+ "jest/valid-title": "warn",
557
+ // eslint-plugin-import — aliased, oxlint reserves the name `import` (2)
558
+ "import-js/no-duplicates": ["warn", { considerQueryString: true, "prefer-inline": true }],
559
+ "import-js/order": [
560
+ "warn",
561
+ {
562
+ "newlines-between": "always",
563
+ groups: ["builtin", "external", "internal", ["parent", "index"], "sibling"],
564
+ alphabetize: { order: "asc", caseInsensitive: false, orderImportKind: "ignore" },
565
+ pathGroups: [
566
+ { pattern: "@nestjs/**", group: "external" },
567
+ {
568
+ pattern: "{@admin/**,@authentication/**,@featureToggles/**,@institution/**,@hublo/**,@network/**,@mission/**,@notification/**,@shared/**,@talent/**,@worker/**,@contract/**,@config/**,@front/**}",
569
+ group: "internal",
570
+ position: "before"
571
+ }
572
+ ],
573
+ pathGroupsExcludedImportTypes: ["builtin"],
574
+ distinctGroup: true,
575
+ sortTypesGroup: false,
576
+ named: false,
577
+ warnOnUnassignedImports: false
578
+ }
579
+ ],
580
+ // @nx/eslint-plugin (1)
581
+ "nx/enforce-module-boundaries": [
582
+ "warn",
583
+ {
584
+ enforceBuildableLibDependency: true,
585
+ allow: [],
586
+ depConstraints: [{ sourceTag: "*", onlyDependOnLibsWithTags: ["*"] }]
587
+ }
588
+ ]
589
+ };
590
+ var nestRules = { ...lintBase, ...nestLayer };
591
+
592
+ // src/roles/lint/flavours/react.ts
593
+ var reactLayer = {
594
+ // ESLint core (42)
595
+ curly: ["error", "all"],
596
+ "for-direction": "error",
597
+ "no-async-promise-executor": "error",
598
+ "no-case-declarations": "error",
599
+ "no-compare-neg-zero": "error",
600
+ "no-cond-assign": ["error", "except-parens"],
601
+ "no-constant-binary-expression": "error",
602
+ "no-constant-condition": ["error", { checkLoops: "allExceptWhileTrue" }],
603
+ "no-control-regex": "error",
604
+ "no-debugger": "error",
605
+ "no-delete-var": "error",
606
+ "no-dupe-else-if": "error",
607
+ "no-duplicate-case": "error",
608
+ "no-empty": ["error", { allowEmptyCatch: false }],
609
+ "no-empty-character-class": "error",
610
+ "no-empty-pattern": ["error", { allowObjectPatternsAsParameters: false }],
611
+ "no-empty-static-block": "error",
612
+ "no-ex-assign": "error",
613
+ "no-extra-boolean-cast": ["error", {}],
614
+ "no-fallthrough": ["error", { allowEmptyCase: false, reportUnusedFallthroughComment: false }],
615
+ "no-global-assign": ["error", { exceptions: [] }],
616
+ "no-invalid-regexp": ["error", {}],
617
+ "no-irregular-whitespace": [
618
+ "error",
619
+ {
620
+ skipComments: false,
621
+ skipJSXText: false,
622
+ skipRegExps: false,
623
+ skipStrings: true,
624
+ skipTemplates: false
625
+ }
626
+ ],
627
+ "no-loss-of-precision": "error",
628
+ "no-misleading-character-class": "error",
629
+ "no-nonoctal-decimal-escape": "error",
630
+ "no-prototype-builtins": "error",
631
+ "no-regex-spaces": "error",
632
+ "no-restricted-imports": [
633
+ "error",
634
+ {
635
+ patterns: [
636
+ {
637
+ group: ["next/*"],
638
+ message: "Importing from next/* is not allowed in the admin project."
639
+ },
640
+ {
641
+ group: ["storybook/*"],
642
+ message: "Importing from storybook/* is not allowed in the admin project."
643
+ },
644
+ {
645
+ group: ["@nestjs/*"],
646
+ message: "Importing from @nestjs/* is not allowed in the admin project."
647
+ },
648
+ {
649
+ group: ["next-i18next"],
650
+ message: "Importing from next-i18next is not allowed in the admin project."
651
+ },
652
+ {
653
+ group: ["@hublo/style/colors", "@hublo/style/colors/*"],
654
+ message: "Importing runtime colors directly is not allowed. Use the MUI theme, theme-derived CSS variables, or an explicit product-specific mapping."
655
+ }
656
+ ]
657
+ }
658
+ ],
659
+ "no-self-assign": ["error", { props: true }],
660
+ "no-shadow-restricted-names": ["error", { reportGlobalThis: false }],
661
+ "no-sparse-arrays": "error",
662
+ "no-unexpected-multiline": "error",
663
+ "no-unsafe-finally": "error",
664
+ "no-unused-labels": "error",
665
+ "no-unused-private-class-members": "error",
666
+ "no-useless-backreference": "error",
667
+ "no-useless-catch": "error",
668
+ "no-useless-escape": ["error", { allowRegexCharacters: [] }],
669
+ "require-yield": "error",
670
+ "use-isnan": ["error", { enforceForIndexOf: false, enforceForSwitchCase: true }],
671
+ "valid-typeof": ["error", { requireStringLiterals: false }],
672
+ // eslint-plugin-styled-components-a11y (hosted) (32)
673
+ "styled-components-a11y/alt-text": "error",
674
+ "styled-components-a11y/anchor-has-content": "error",
675
+ "styled-components-a11y/anchor-is-valid": "error",
676
+ "styled-components-a11y/aria-activedescendant-has-tabindex": "error",
677
+ "styled-components-a11y/aria-props": "error",
678
+ "styled-components-a11y/aria-proptypes": "error",
679
+ "styled-components-a11y/aria-role": "error",
680
+ "styled-components-a11y/aria-unsupported-elements": "error",
681
+ "styled-components-a11y/autocomplete-valid": "error",
682
+ "styled-components-a11y/click-events-have-key-events": "error",
683
+ "styled-components-a11y/control-has-associated-label": [
684
+ "off",
685
+ {
686
+ ignoreElements: ["audio", "canvas", "embed", "input", "textarea", "tr", "video"],
687
+ ignoreRoles: [
688
+ "grid",
689
+ "listbox",
690
+ "menu",
691
+ "menubar",
692
+ "radiogroup",
693
+ "row",
694
+ "tablist",
695
+ "toolbar",
696
+ "tree",
697
+ "treegrid"
698
+ ],
699
+ includeRoles: ["alert", "dialog"]
700
+ }
701
+ ],
702
+ "styled-components-a11y/heading-has-content": "error",
703
+ "styled-components-a11y/html-has-lang": "error",
704
+ "styled-components-a11y/iframe-has-title": "error",
705
+ "styled-components-a11y/img-redundant-alt": "error",
706
+ "styled-components-a11y/interactive-supports-focus": [
707
+ "error",
708
+ {
709
+ tabbable: [
710
+ "button",
711
+ "checkbox",
712
+ "link",
713
+ "progressbar",
714
+ "searchbox",
715
+ "slider",
716
+ "spinbutton",
717
+ "switch",
718
+ "textbox"
719
+ ]
720
+ }
721
+ ],
722
+ "styled-components-a11y/label-has-associated-control": "error",
723
+ "styled-components-a11y/media-has-caption": "error",
724
+ "styled-components-a11y/mouse-events-have-key-events": "error",
725
+ "styled-components-a11y/no-access-key": "error",
726
+ "styled-components-a11y/no-autofocus": "error",
727
+ "styled-components-a11y/no-distracting-elements": "error",
728
+ "styled-components-a11y/no-interactive-element-to-noninteractive-role": "error",
729
+ "styled-components-a11y/no-noninteractive-element-interactions": [
730
+ "error",
731
+ { body: ["onError", "onLoad"], iframe: ["onError", "onLoad"], img: ["onError", "onLoad"] }
732
+ ],
733
+ "styled-components-a11y/no-noninteractive-element-to-interactive-role": "error",
734
+ "styled-components-a11y/no-noninteractive-tabindex": "error",
735
+ "styled-components-a11y/no-redundant-roles": "error",
736
+ "styled-components-a11y/no-static-element-interactions": "error",
737
+ "styled-components-a11y/role-has-required-aria-props": "error",
738
+ "styled-components-a11y/role-supports-aria-props": "error",
739
+ "styled-components-a11y/scope": "error",
740
+ "styled-components-a11y/tabindex-no-positive": "error",
741
+ // eslint-plugin-jsx-a11y (31)
742
+ "jsx-a11y/alt-text": "error",
743
+ "jsx-a11y/anchor-has-content": "error",
744
+ "jsx-a11y/anchor-is-valid": "error",
745
+ "jsx-a11y/aria-activedescendant-has-tabindex": "error",
746
+ "jsx-a11y/aria-props": "error",
747
+ "jsx-a11y/aria-proptypes": "error",
748
+ "jsx-a11y/aria-role": "error",
749
+ "jsx-a11y/aria-unsupported-elements": "error",
750
+ "jsx-a11y/autocomplete-valid": "error",
751
+ "jsx-a11y/click-events-have-key-events": "error",
752
+ "jsx-a11y/heading-has-content": "error",
753
+ "jsx-a11y/html-has-lang": "error",
754
+ "jsx-a11y/iframe-has-title": "error",
755
+ "jsx-a11y/img-redundant-alt": "error",
756
+ "jsx-a11y/interactive-supports-focus": [
757
+ "error",
758
+ {
759
+ tabbable: [
760
+ "button",
761
+ "checkbox",
762
+ "link",
763
+ "progressbar",
764
+ "searchbox",
765
+ "slider",
766
+ "spinbutton",
767
+ "switch",
768
+ "textbox"
769
+ ]
770
+ }
771
+ ],
772
+ "jsx-a11y/label-has-associated-control": "error",
773
+ "jsx-a11y/media-has-caption": "error",
774
+ "jsx-a11y/mouse-events-have-key-events": "error",
775
+ "jsx-a11y/no-access-key": "error",
776
+ "jsx-a11y/no-autofocus": "error",
777
+ "jsx-a11y/no-distracting-elements": "error",
778
+ "jsx-a11y/no-interactive-element-to-noninteractive-role": "error",
779
+ "jsx-a11y/no-noninteractive-element-interactions": [
780
+ "error",
781
+ { body: ["onError", "onLoad"], iframe: ["onError", "onLoad"], img: ["onError", "onLoad"] }
782
+ ],
783
+ "jsx-a11y/no-noninteractive-element-to-interactive-role": "error",
784
+ "jsx-a11y/no-noninteractive-tabindex": "error",
785
+ "jsx-a11y/no-redundant-roles": "error",
786
+ "jsx-a11y/no-static-element-interactions": "error",
787
+ "jsx-a11y/role-has-required-aria-props": "error",
788
+ "jsx-a11y/role-supports-aria-props": "error",
789
+ "jsx-a11y/scope": "error",
790
+ "jsx-a11y/tabindex-no-positive": "error",
791
+ // typescript-eslint (18)
792
+ "typescript/await-thenable": "error",
793
+ "typescript/no-array-delete": "error",
794
+ "typescript/no-base-to-string": "error",
795
+ "typescript/no-duplicate-type-constituents": "error",
796
+ "typescript/no-floating-promises": ["error", { ignoreVoid: true }],
797
+ "typescript/no-for-in-array": "error",
798
+ "typescript/no-implied-eval": "error",
799
+ "typescript/no-inferrable-types": "error",
800
+ "typescript/no-misused-promises": ["error", { checksVoidReturn: { attributes: false } }],
801
+ "typescript/no-non-null-assertion": "warn",
802
+ "typescript/no-redundant-type-constituents": "error",
803
+ "typescript/no-require-imports": "error",
804
+ "typescript/no-unsafe-unary-minus": "error",
805
+ "typescript/prefer-promise-reject-errors": "error",
806
+ "typescript/restrict-plus-operands": "error",
807
+ "typescript/restrict-template-expressions": "error",
808
+ "typescript/switch-exhaustiveness-check": "warn",
809
+ "typescript/unbound-method": "error",
810
+ // eslint-plugin-react-hooks (2)
811
+ "react-hooks/exhaustive-deps": "warn",
812
+ "react-hooks/rules-of-hooks": "error",
813
+ // eslint-plugin-no-barrel-files (hosted) (1)
814
+ "no-barrel-files/no-barrel-files": "warn",
815
+ // @nx/eslint-plugin (hosted) (1)
816
+ "nx/enforce-module-boundaries": [
817
+ "error",
818
+ {
819
+ enforceBuildableLibDependency: true,
820
+ allow: [],
821
+ depConstraints: [{ sourceTag: "*", onlyDependOnLibsWithTags: ["*"] }]
822
+ }
823
+ ],
824
+ // eslint-plugin-import — aliased, oxlint reserves `import` (1)
825
+ "import-js/order": [
826
+ "error",
827
+ {
828
+ "newlines-between": "always",
829
+ groups: ["builtin", "external", "internal", ["parent", "index"], "sibling"],
830
+ alphabetize: { order: "asc" },
831
+ pathGroups: [
832
+ { pattern: "@nestjs/**", group: "external" },
833
+ {
834
+ pattern: "{@admin/**,@authentication/**,@featureToggles/**,@institution/**,@hublo/**,@network/**,@mission/**,@notification/**,@shared/**,@talent/**,@worker/**,@contract/**,@config/**,@front/**}",
835
+ group: "internal",
836
+ position: "before"
837
+ }
838
+ ],
839
+ pathGroupsExcludedImportTypes: ["builtin"]
840
+ }
841
+ ],
842
+ // eslint-plugin-react-refresh (hosted) (1)
843
+ "react-refresh/only-export-components": ["warn", { allowConstantExport: true }]
844
+ };
845
+ var reactRules = { ...lintBase, ...reactLayer };
846
+
847
+ // src/roles/lint/presets.ts
848
+ var TS_EXTENSIONS = [".ts", ".cts", ".mts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
849
+ var IMPORT_SETTINGS = {
850
+ "import/extensions": TS_EXTENSIONS,
851
+ "import/external-module-folders": ["node_modules", "node_modules/@types"],
852
+ "import/parsers": { "@typescript-eslint/parser": [".ts", ".cts", ".mts", ".tsx"] },
853
+ "import/resolver": { node: { extensions: TS_EXTENSIONS } }
854
+ };
855
+ var NEST_PRESET = {
856
+ plugins: ["typescript", "jest", "import", "unicorn", "oxc", "node"],
857
+ jsPlugins: [
858
+ "eslint-plugin-no-barrel-files",
859
+ { name: "nx", specifier: "@nx/eslint-plugin" },
860
+ { name: "import-js", specifier: "eslint-plugin-import" }
861
+ ],
862
+ env: { node: true, es2022: true, jest: true },
863
+ settings: IMPORT_SETTINGS,
864
+ rules: nestRules
865
+ };
866
+ var BASE_PRESET = {
867
+ plugins: ["typescript", "oxc"],
868
+ jsPlugins: [],
869
+ env: { es2022: true },
870
+ rules: lintBase
871
+ };
872
+ var REACT_PRESET = {
873
+ plugins: ["typescript", "react", "jsx-a11y", "jest", "import", "unicorn", "oxc"],
874
+ jsPlugins: [
875
+ "eslint-plugin-no-barrel-files",
876
+ { name: "nx", specifier: "@nx/eslint-plugin" },
877
+ { name: "import-js", specifier: "eslint-plugin-import" },
878
+ { name: "@tanstack/query", specifier: "@tanstack/eslint-plugin-query" },
879
+ { name: "styled-components-a11y", specifier: "eslint-plugin-styled-components-a11y" },
880
+ { name: "react-refresh", specifier: "eslint-plugin-react-refresh" },
881
+ // Ships inside sentinel, resolved relative to THIS preset file. Oxlint has no
882
+ // `no-restricted-syntax`, so the Hublo house rules are a real rule instead.
883
+ "../plugins/hublo.js"
884
+ ],
885
+ env: { browser: true, es2022: true, node: true, jest: true },
886
+ settings: IMPORT_SETTINGS,
887
+ rules: reactRules
888
+ };
889
+ var BY_FLAVOUR2 = {
890
+ nest: NEST_PRESET,
891
+ react: REACT_PRESET,
892
+ // `node` ships the base alone, deliberately. It is the honest answer for a module that
893
+ // belongs to no stack, and it keeps `--init` with no named target working everywhere
894
+ // rather than blocking a whole workspace sweep on one unshipped flavour. It is thin (21
895
+ // rules) and will grow when the tooling flavour is measured properly.
896
+ node: BASE_PRESET
897
+ };
898
+ var LINT_FLAVOURS = Object.keys(BY_FLAVOUR2);
899
+ function hasLintPreset(flavour) {
900
+ return flavour in BY_FLAVOUR2;
901
+ }
902
+ function lintPresetFor(flavour) {
903
+ return BY_FLAVOUR2[flavour] ?? BASE_PRESET;
904
+ }
905
+
906
+ // src/roles/lint/rename-suppressions.ts
907
+ import { readdirSync, readFileSync as readFileSync2, statSync } from "fs";
908
+ import { join as join2, relative } from "path";
909
+ var SOURCE_EXTENSIONS = [
910
+ ".ts",
911
+ ".tsx",
912
+ ".js",
913
+ ".jsx",
914
+ ".mjs",
915
+ ".cjs",
916
+ ".mts",
917
+ ".cts",
918
+ ".svelte",
919
+ ".vue"
920
+ ];
921
+ var SKIP_DIRECTORIES = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "coverage", ".git", ".nx"]);
922
+ var SUPPRESSION = /(eslint-disable(?:-next-line|-line)?)\s+([^\n*]+?)(?:\s*--.*)?(?:\s*\*\/)?\s*$/;
923
+ function renamedPrefixes(jsPlugins, declared = {}) {
924
+ const renames = /* @__PURE__ */ new Map();
925
+ for (const plugin of jsPlugins) {
926
+ if (typeof plugin === "string") continue;
927
+ const original = plugin.specifier.replace(/^eslint-plugin-/, "").replace(/\/eslint-plugin$/, "");
928
+ if (original !== plugin.name) renames.set(`${original}/`, `${plugin.name}/`);
929
+ }
930
+ const slash = (value) => value.endsWith("/") ? value : `${value}/`;
931
+ for (const [from, to] of Object.entries(declared)) renames.set(slash(from), slash(to));
932
+ return collapseChains(renames);
933
+ }
934
+ function collapseChains(renames) {
935
+ const collapsed = /* @__PURE__ */ new Map();
936
+ for (const [from, to] of renames) {
937
+ let target = to;
938
+ for (let hop = 0; hop < renames.size; hop += 1) {
939
+ const next = renames.get(target);
940
+ if (next === void 0 || next === target) break;
941
+ target = next;
942
+ }
943
+ collapsed.set(from, target);
944
+ }
945
+ return collapsed;
946
+ }
947
+ function* sourceFiles(dir) {
948
+ let entries;
949
+ try {
950
+ entries = readdirSync(dir);
951
+ } catch {
952
+ return;
953
+ }
954
+ for (const entry of entries) {
955
+ const full = join2(dir, entry);
956
+ let isDirectory;
957
+ try {
958
+ isDirectory = statSync(full).isDirectory();
959
+ } catch {
960
+ continue;
961
+ }
962
+ if (isDirectory) {
963
+ if (!SKIP_DIRECTORIES.has(entry)) yield* sourceFiles(full);
964
+ } else if (SOURCE_EXTENSIONS.some((extension) => entry.endsWith(extension))) {
965
+ yield full;
966
+ }
967
+ }
968
+ }
969
+ function findSuppressionRenames(cwd, renames) {
970
+ if (renames.size === 0) return [];
971
+ const found = [];
972
+ for (const file of sourceFiles(cwd)) {
973
+ let content;
974
+ try {
975
+ content = readFileSync2(file, "utf8");
976
+ } catch {
977
+ continue;
978
+ }
979
+ if (!content.includes("eslint-disable")) continue;
980
+ content.split("\n").forEach((line, index) => {
981
+ const match = SUPPRESSION.exec(line);
982
+ const rules = match?.[2];
983
+ if (!rules) return;
984
+ const names = rules.split(",").map((entry) => entry.trim());
985
+ const renamed = [];
986
+ const hits = [];
987
+ for (const name of names) {
988
+ const match2 = [...renames].find(([oldPrefix2]) => name.startsWith(oldPrefix2));
989
+ if (!match2) {
990
+ renamed.push(name);
991
+ continue;
992
+ }
993
+ const [oldPrefix, newPrefix] = match2;
994
+ hits.push(name);
995
+ renamed.push(newPrefix + name.slice(oldPrefix.length));
996
+ }
997
+ if (hits.length === 0) return;
998
+ found.push({
999
+ file: relative(cwd, file),
1000
+ line: index + 1,
1001
+ from: line,
1002
+ to: line.replace(rules, renamed.join(", ")),
1003
+ rule: hits.join(", ")
1004
+ });
1005
+ });
1006
+ }
1007
+ return found;
1008
+ }
1009
+
1010
+ // src/core/config/read-adoption.ts
1011
+ import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
1012
+ import { join as join4 } from "path";
1013
+
1014
+ // src/shared/jsonc.ts
1015
+ import { parse, printParseErrorCode } from "jsonc-parser";
1016
+ function parseJsonc(text, source = "config") {
1017
+ const errors = [];
1018
+ const value = parse(text, errors, { allowTrailingComma: true });
1019
+ if (errors.length > 0) {
1020
+ const details = errors.map((error) => printParseErrorCode(error.error)).join(", ");
1021
+ throw new Error(`${source}: malformed JSONC (${details}).`);
1022
+ }
1023
+ return value;
1024
+ }
1025
+
1026
+ // src/core/config/owned-keys.ts
1027
+ function presetOwnedKeys(config, permitted) {
1028
+ if (!config) return [];
1029
+ return Object.keys(config).filter((key) => !permitted.includes(key));
1030
+ }
1031
+
1032
+ // src/core/config/resolve-config-target.ts
1033
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
1034
+ import { join as join3 } from "path";
1035
+ function readExtends(absolutePath) {
1036
+ let parsed;
1037
+ try {
1038
+ parsed = parseJsonc(readFileSync3(absolutePath, "utf8"), absolutePath);
1039
+ } catch {
1040
+ return [];
1041
+ }
1042
+ if (typeof parsed.extends === "string") return [parsed.extends];
1043
+ if (Array.isArray(parsed.extends)) {
1044
+ return parsed.extends.filter((entry) => typeof entry === "string");
1045
+ }
1046
+ return [];
1047
+ }
1048
+ function resolveConfigTarget(moduleDir, { candidates, markers, fallback }) {
1049
+ let existing;
1050
+ for (const candidate of candidates) {
1051
+ const absolutePath = join3(moduleDir, candidate);
1052
+ if (!existsSync2(absolutePath)) continue;
1053
+ existing ??= candidate;
1054
+ const extendsBase = readExtends(absolutePath).some(
1055
+ (value) => markers.some((marker) => value.includes(marker))
1056
+ );
1057
+ if (extendsBase) return { path: candidate, reason: "extends-base" };
1058
+ }
1059
+ if (existing) return { path: existing, reason: "other-chain" };
1060
+ return { path: fallback, reason: "none" };
1061
+ }
1062
+
1063
+ // src/core/config/read-adoption.ts
1064
+ function normaliseExtends(value) {
1065
+ if (typeof value === "string") return [value];
1066
+ if (Array.isArray(value)) {
1067
+ return value.filter((entry) => typeof entry === "string");
1068
+ }
1069
+ return [];
1070
+ }
1071
+ var NOT_ADOPTED = (configFile) => ({
1072
+ configFile,
1073
+ preset: null,
1074
+ adopted: false,
1075
+ conformant: false,
1076
+ drift: []
1077
+ });
1078
+ function readAdoption(cwd, options) {
1079
+ const target = resolveConfigTarget(cwd, options);
1080
+ if (target.reason === "none" || !existsSync3(join4(cwd, target.path))) {
1081
+ return NOT_ADOPTED(target.reason === "none" ? null : target.path);
1082
+ }
1083
+ let parsed;
1084
+ try {
1085
+ parsed = parseJsonc(readFileSync4(join4(cwd, target.path), "utf8"), target.path);
1086
+ } catch {
1087
+ return NOT_ADOPTED(target.path);
1088
+ }
1089
+ const preset = normaliseExtends(parsed.extends).find((entry) => options.presetPattern.test(entry)) ?? null;
1090
+ if (preset === null) return NOT_ADOPTED(target.path);
1091
+ const settings = options.settingsKey === null ? parsed : parsed[options.settingsKey];
1092
+ const drift = presetOwnedKeys(settings, options.permitted);
1093
+ return { configFile: target.path, preset, adopted: true, conformant: drift.length === 0, drift };
1094
+ }
1095
+
1096
+ // src/roles/lint/read-adoption.ts
1097
+ function readLintAdoption(cwd) {
1098
+ return readAdoption(cwd, {
1099
+ candidates: [LINT_CONFIG_FILE],
1100
+ markers: ["@hublo/sentinel/oxlint/"],
1101
+ fallback: LINT_CONFIG_FILE,
1102
+ presetPattern: SENTINEL_LINT_PRESET,
1103
+ // Drift for lint is any TOP-LEVEL key beyond `extends`, so the settings the shared
1104
+ // reader inspects are the config's own root rather than a nested options object.
1105
+ settingsKey: null,
1106
+ permitted: PERMITTED_LOCAL_KEYS
1107
+ });
1108
+ }
1109
+
1110
+ // src/roles/lint/resolve-oxlint.ts
1111
+ import { createRequire } from "module";
1112
+ import { existsSync as existsSync5 } from "fs";
1113
+ import { delimiter, dirname as dirname3, join as join6 } from "path";
1114
+
1115
+ // src/shared/resolve-bin.ts
1116
+ import { existsSync as existsSync4 } from "fs";
1117
+ import { dirname as dirname2, join as join5 } from "path";
1118
+ function resolveBin(fromDir, name) {
1119
+ let dir = fromDir;
1120
+ for (; ; ) {
1121
+ const candidate = join5(dir, "node_modules", ".bin", name);
1122
+ if (existsSync4(candidate)) return candidate;
1123
+ const parent = dirname2(dir);
1124
+ if (parent === dir) return void 0;
1125
+ dir = parent;
1126
+ }
1127
+ }
1128
+
1129
+ // src/roles/lint/resolve-oxlint.ts
1130
+ var PACKAGE_OF = {
1131
+ oxlint: "oxlint",
1132
+ tsgolint: "oxlint-tsgolint"
1133
+ };
1134
+ var require2 = createRequire(import.meta.url);
1135
+ function fromOwnInstall(name) {
1136
+ try {
1137
+ const packageName = PACKAGE_OF[name];
1138
+ const manifest = require2.resolve(`${packageName}/package.json`);
1139
+ const bin = require2(manifest).bin;
1140
+ const relative2 = typeof bin === "string" ? bin : bin?.[name];
1141
+ if (!relative2) return void 0;
1142
+ const executable = join6(dirname3(manifest), relative2);
1143
+ return existsSync5(executable) ? executable : void 0;
1144
+ } catch {
1145
+ return void 0;
1146
+ }
1147
+ }
1148
+ function resolveOxlint(cwd, name = "oxlint") {
1149
+ return resolveBin(cwd, name) ?? fromOwnInstall(name);
1150
+ }
1151
+ function canRunTypeAware(cwd) {
1152
+ return tsgolintShim(cwd) !== void 0;
1153
+ }
1154
+ function oxlintPath(cwd, env) {
1155
+ const shim = tsgolintShim(cwd);
1156
+ if (!shim) return env.PATH;
1157
+ return [dirname3(shim), env.PATH].filter(Boolean).join(delimiter);
1158
+ }
1159
+ function tsgolintShim(cwd) {
1160
+ const fromModule = resolveBin(cwd, "tsgolint");
1161
+ if (fromModule) return fromModule;
1162
+ try {
1163
+ const manifest = require2.resolve("oxlint-tsgolint/package.json");
1164
+ const packageDir = dirname3(manifest);
1165
+ const candidates = [
1166
+ join6(packageDir, "node_modules", ".bin", "tsgolint"),
1167
+ join6(packageDir, "..", ".bin", "tsgolint")
1168
+ ];
1169
+ return candidates.find((candidate) => existsSync5(candidate));
1170
+ } catch {
1171
+ return void 0;
1172
+ }
1173
+ }
1174
+ function oxlintSearchPath(cwd) {
1175
+ return [`${cwd}${delimiter}node_modules/.bin (and parents)`, "sentinel's own install"].join(
1176
+ " then "
1177
+ );
1178
+ }
1179
+
1180
+ // src/roles/lint/adapters/oxlint/oxlint.adapter.ts
1181
+ var MAX_FIX_PASSES = 3;
1182
+ function summariseByPlugin(rules) {
1183
+ const counts = /* @__PURE__ */ new Map();
1184
+ for (const name of deferredRuleNames(rules)) {
1185
+ const plugin = name.includes("/") ? name.split("/")[0] ?? name : "core";
1186
+ counts.set(plugin, (counts.get(plugin) ?? 0) + 1);
1187
+ }
1188
+ return [...counts.entries()].sort((left, right) => right[1] - left[1]).map(([plugin, count]) => `${count} ${plugin}`).join(", ");
1189
+ }
1190
+ var OxlintAdapter = class extends BaseAdapter {
1191
+ target = "lint";
1192
+ runner = "oxlint";
1193
+ /**
1194
+ * Handles every flavour, deliberately. Adapter selection happens BEFORE the engine can
1195
+ * ask `declaredFlavour`, so filtering here would make an adopted module unreachable:
1196
+ * detection answers `node` for a hoisted-deps monorepo, no adapter would match, and the
1197
+ * module would report `unsupported` while its committed config says otherwise. Whether a
1198
+ * flavour actually ships a preset is gated inside `plan`, where it can say so clearly.
1199
+ */
1200
+ appliesTo(_flavour) {
1201
+ return true;
1202
+ }
1203
+ /**
1204
+ * The flavour this module committed to, read from the config sentinel wrote. Preferred
1205
+ * over detection because a monorepo hoists dependencies: a module declares no `react`,
1206
+ * so detection honestly answers `node` while the committed config says otherwise.
1207
+ */
1208
+ declaredFlavour(cwd) {
1209
+ return flavourFromPreset(readLintAdoption(cwd).preset);
1210
+ }
1211
+ plan(context) {
1212
+ if (!hasLintPreset(context.flavour)) {
1213
+ return {
1214
+ operations: [],
1215
+ blocked: `no lint preset for flavour "${context.flavour}" yet. Nothing was written; this module cannot adopt the lint preset until that flavour ships.`
1216
+ };
1217
+ }
1218
+ const disabled = disabledRulesFor(context.flavour);
1219
+ const own = readOwnPackage();
1220
+ const stub = { extends: [presetPath(context.flavour)] };
1221
+ const operations = [
1222
+ { kind: "write", path: LINT_CONFIG_FILE, contents: JSON.stringify(stub, null, 2) + "\n" },
1223
+ this.packageJsonOperation(context.cwd)
1224
+ ];
1225
+ const eslintConfigs = ESLINT_CONFIG_FILES.filter((name) => existsSync6(join7(context.cwd, name)));
1226
+ for (const name of eslintConfigs) operations.push({ kind: "delete", path: name });
1227
+ if (existsSync6(join7(context.cwd, "project.json"))) {
1228
+ operations.push({
1229
+ kind: "remove-json-keys",
1230
+ path: "project.json",
1231
+ keys: [["targets", "lint"]]
1232
+ });
1233
+ operations.push({
1234
+ kind: "merge-json",
1235
+ path: "project.json",
1236
+ value: { targets: { lint: lintTarget() } }
1237
+ });
1238
+ }
1239
+ const renames = findSuppressionRenames(
1240
+ context.cwd,
1241
+ renamedPrefixes(
1242
+ lintPresetFor(context.flavour).jsPlugins,
1243
+ lintPresetFor(context.flavour).renames
1244
+ )
1245
+ );
1246
+ const deleting = new Set(eslintConfigs);
1247
+ const rewritten = renames.filter((entry) => !deleting.has(entry.file));
1248
+ const byFile = /* @__PURE__ */ new Map();
1249
+ for (const entry of rewritten) {
1250
+ const list = byFile.get(entry.file) ?? [];
1251
+ list.push({ from: entry.from, to: entry.to });
1252
+ byFile.set(entry.file, list);
1253
+ }
1254
+ for (const [file, replacements] of byFile) {
1255
+ operations.push({ kind: "replace-lines", path: file, replacements });
1256
+ }
1257
+ const notes = [
1258
+ `wrote ${LINT_CONFIG_FILE} extending ${presetPath(context.flavour)} (${own.name}@${own.version})`
1259
+ ];
1260
+ if (disabled.length > 0) {
1261
+ notes.push(
1262
+ `${disabled.length} rule(s) not enforced by this preset (${summariseByPlugin(disabled)}). Run --inspect --lint for each rule and why.`
1263
+ );
1264
+ }
1265
+ if (eslintConfigs.length > 0) {
1266
+ notes.push(`removed ${eslintConfigs.join(", ")} and pointed the nx lint target at oxlint`);
1267
+ }
1268
+ if (rewritten.length > 0) {
1269
+ notes.push(
1270
+ `renamed ${rewritten.length} suppression comment(s) whose plugin oxlint registers under a different name (${[...new Set(rewritten.map((entry) => entry.rule))].join(", ")}); left alone they would stop suppressing anything, silently`
1271
+ );
1272
+ }
1273
+ notes.push("run `pnpm install`, then `pnpm run lint`");
1274
+ return { operations, notes };
1275
+ }
1276
+ /**
1277
+ * Write the `lint` script and the pinned `@hublo/sentinel` devDependency, creating a
1278
+ * `package.json` when the module has none. Real case: a BFF in the monorepo had no
1279
+ * `package.json` at all, so `pnpm exec` failed outright and there was nowhere to put a
1280
+ * script. `merge-json` preserves everything else and makes a re-run idempotent.
1281
+ */
1282
+ packageJsonOperation(cwd) {
1283
+ const own = readOwnPackage();
1284
+ const value = {
1285
+ scripts: { [LINT_SCRIPT_NAME]: `sentinel --run --lint` },
1286
+ devDependencies: { [own.name]: own.version }
1287
+ };
1288
+ if (existsSync6(join7(cwd, "package.json"))) {
1289
+ return { kind: "merge-json", path: "package.json", value };
1290
+ }
1291
+ return {
1292
+ kind: "merge-json",
1293
+ path: "package.json",
1294
+ value: { name: readNxProjectName(cwd) ?? basename(cwd), private: true, ...value }
1295
+ };
1296
+ }
1297
+ /**
1298
+ * Lint the module. Announces the rules that are deliberately not enforced, so the
1299
+ * reduced coverage is never a silent gap, and refuses to report a pass when the linter
1300
+ * or its type-aware sidecar is missing.
1301
+ */
1302
+ async run(ctx) {
1303
+ if (!existsSync6(join7(ctx.cwd, LINT_CONFIG_FILE))) {
1304
+ process.stderr.write(
1305
+ `sentinel lint(oxlint): no ${LINT_CONFIG_FILE} in this module; run \`sentinel --init --lint\` to adopt.
1306
+ `
1307
+ );
1308
+ return { ok: true, code: 0 };
1309
+ }
1310
+ const oxlint = resolveOxlint(ctx.cwd);
1311
+ if (!oxlint) {
1312
+ process.stderr.write(
1313
+ `sentinel lint(oxlint): could not find the oxlint binary (looked in ${oxlintSearchPath(ctx.cwd)}). Run \`pnpm install\` in the module.
1314
+ `
1315
+ );
1316
+ return { ok: false, code: 1 };
1317
+ }
1318
+ const missingPreset = this.unresolvedPreset(ctx.cwd);
1319
+ if (missingPreset) {
1320
+ process.stderr.write(
1321
+ `sentinel lint(oxlint): ${LINT_CONFIG_FILE} extends ${missingPreset}, which does not exist. That path assumes pnpm's isolated node_modules layout; if the workspace now uses a hoisted linker, or @hublo/sentinel is not installed in this module, run \`pnpm install\` here.
1322
+ `
1323
+ );
1324
+ return { ok: false, code: 1 };
1325
+ }
1326
+ this.announceDisabled(ctx.flavour);
1327
+ const typeAware = canRunTypeAware(ctx.cwd);
1328
+ if (!typeAware) {
1329
+ process.stderr.write(
1330
+ `${palette(process.stderr).warn("sentinel lint(oxlint): oxlint-tsgolint is not installed, so type-aware rules will NOT run")}
1331
+ `
1332
+ );
1333
+ }
1334
+ const env = { ...process.env, PATH: oxlintPath(ctx.cwd, process.env) };
1335
+ const lint = (extra) => {
1336
+ const result = spawnSync(oxlint, ["-c", LINT_CONFIG_FILE, ...extra, "."], {
1337
+ cwd: ctx.cwd,
1338
+ stdio: "inherit",
1339
+ env
1340
+ });
1341
+ if (result.error) {
1342
+ process.stderr.write(
1343
+ `sentinel lint(oxlint): could not run oxlint (${result.error.message})
1344
+ `
1345
+ );
1346
+ return 1;
1347
+ }
1348
+ return result.status ?? 1;
1349
+ };
1350
+ if (ctx.fix) {
1351
+ for (let pass = 0; pass < MAX_FIX_PASSES; pass += 1) {
1352
+ if (lint(["--fix"]) === 0) break;
1353
+ }
1354
+ }
1355
+ const code = lint(typeAware ? ["--type-aware"] : []);
1356
+ return { ok: code === 0, code };
1357
+ }
1358
+ /** The module's resolved lint configuration, and what it deliberately does not enforce. */
1359
+ async inspect(ctx) {
1360
+ const adoption = readLintAdoption(ctx.cwd);
1361
+ const disabled = disabledRulesFor(ctx.flavour);
1362
+ const rules = this.presetRuleCount(ctx.cwd);
1363
+ return {
1364
+ target: "lint",
1365
+ runner: "oxlint",
1366
+ adopted: adoption.adopted,
1367
+ preset: adoption.preset,
1368
+ configFile: adoption.configFile,
1369
+ rules,
1370
+ typeAware: canRunTypeAware(ctx.cwd),
1371
+ disabled: disabled.map((entry) => ({ rule: entry.rule, reason: entry.reason }))
1372
+ };
1373
+ }
1374
+ /** How many rules the extended preset enforces, read from the preset on disk. */
1375
+ presetRuleCount(cwd) {
1376
+ const target = this.unresolvedPreset(cwd);
1377
+ if (target) return 0;
1378
+ let total = 0;
1379
+ try {
1380
+ const stub = JSON.parse(readFileSync5(join7(cwd, LINT_CONFIG_FILE), "utf8"));
1381
+ for (const entry of stub.extends ?? []) {
1382
+ const preset = JSON.parse(readFileSync5(join7(cwd, entry), "utf8"));
1383
+ total += Object.keys(preset.rules ?? {}).length;
1384
+ }
1385
+ } catch {
1386
+ return 0;
1387
+ }
1388
+ return total;
1389
+ }
1390
+ /** Lint health for `--report`. Deepened in a later step; today it is the run outcome. */
1391
+ async report(ctx) {
1392
+ const result = await this.run(ctx);
1393
+ return { ...result, metrics: { adopted: readLintAdoption(ctx.cwd).adopted } };
1394
+ }
1395
+ /** Adoption + conformity, read from the committed config with no tool run. */
1396
+ async status(ctx) {
1397
+ const adoption = readLintAdoption(ctx.cwd);
1398
+ return {
1399
+ adopted: adoption.adopted,
1400
+ preset: adoption.preset,
1401
+ conformant: adoption.conformant,
1402
+ drift: adoption.drift
1403
+ };
1404
+ }
1405
+ /**
1406
+ * The `extends` target this module points at, when it cannot be found on disk. Returns
1407
+ * undefined when everything resolves. Read from the committed config rather than
1408
+ * recomputed, so it checks what the module actually says.
1409
+ */
1410
+ unresolvedPreset(cwd) {
1411
+ let parsed;
1412
+ try {
1413
+ parsed = JSON.parse(readFileSync5(join7(cwd, LINT_CONFIG_FILE), "utf8"));
1414
+ } catch {
1415
+ return void 0;
1416
+ }
1417
+ const targets = Array.isArray(parsed.extends) ? parsed.extends.filter((entry) => typeof entry === "string") : typeof parsed.extends === "string" ? [parsed.extends] : [];
1418
+ return targets.find((target) => !existsSync6(join7(cwd, target)));
1419
+ }
1420
+ /** Announce what is not enforced, so reduced coverage is never silent. */
1421
+ announceDisabled(flavour) {
1422
+ const disabled = disabledRulesFor(flavour);
1423
+ if (disabled.length === 0) return;
1424
+ process.stderr.write(
1425
+ `${palette(process.stderr).warn(
1426
+ `sentinel lint(oxlint): ${disabled.length} rule(s) not enforced by default (${summariseByPlugin(disabled)}); run --inspect --lint for each rule and why`
1427
+ )}
1428
+ `
1429
+ );
1430
+ }
1431
+ };
1432
+
1433
+ // src/roles/lint/register.ts
1434
+ function registerLint() {
1435
+ register(new OxlintAdapter());
1436
+ setDefaultRunner("lint", "oxlint");
1437
+ }
1438
+
1439
+ // src/roles/typescript/adapters/tsc/tsc.adapter.ts
1440
+ import { spawnSync as spawnSync2 } from "child_process";
1441
+ import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
1442
+ import { basename as basename2, join as join8 } from "path";
1443
+
1444
+ // src/roles/typescript/config-policy.ts
1445
+ var PERMITTED_COMPILER_OPTIONS = [
1446
+ "paths",
1447
+ "baseUrl",
1448
+ "rootDir",
1449
+ "outDir",
1450
+ "tsBuildInfoFile"
1451
+ ];
1452
+ function presetOwnedKeys2(compilerOptions) {
1453
+ return presetOwnedKeys(compilerOptions, PERMITTED_COMPILER_OPTIONS);
1454
+ }
1455
+
1456
+ // src/roles/typescript/phased-rules.ts
1457
+ var DEFERRED_RULES = [
1458
+ { rule: "noImplicitAny", phase: 2, reason: "the implicit-any migration (TS70xx)" },
1459
+ { rule: "noUnusedLocals", phase: 2, reason: "unused-local cleanup (TS6133)" },
1460
+ { rule: "noUnusedParameters", phase: 2, reason: "unused-parameter cleanup (TS6133)" }
1461
+ ];
1462
+ function deferredRuleNames2() {
1463
+ return deferredRuleNames(DEFERRED_RULES);
1464
+ }
1465
+
1466
+ // src/roles/typescript/presets.ts
1467
+ var SHIPPED_FLAVOURS = ["react", "nest", "node"];
1468
+ function hasShippedPreset(flavour) {
1469
+ return SHIPPED_FLAVOURS.includes(flavour);
1470
+ }
1471
+
1472
+ // src/roles/typescript/resolve-tsconfig-target.ts
1473
+ var TSCONFIG_CANDIDATES = ["tsconfig.app.json", "tsconfig.json"];
1474
+ var TSCONFIG_MARKERS = ["tsconfig.base.json", "@hublo/sentinel/tsconfig/"];
1475
+ var TSCONFIG_FALLBACK = "tsconfig.json";
1476
+ function resolveTsconfigTarget(moduleDir) {
1477
+ return resolveConfigTarget(moduleDir, {
1478
+ candidates: TSCONFIG_CANDIDATES,
1479
+ markers: TSCONFIG_MARKERS,
1480
+ fallback: TSCONFIG_FALLBACK
1481
+ });
1482
+ }
1483
+
1484
+ // src/roles/typescript/read-adoption.ts
1485
+ var SENTINEL_PRESET = /^@hublo\/sentinel\/tsconfig\/[a-z-]+$/;
1486
+ function readTsconfigAdoption(cwd) {
1487
+ return readAdoption(cwd, {
1488
+ candidates: TSCONFIG_CANDIDATES,
1489
+ markers: TSCONFIG_MARKERS,
1490
+ fallback: TSCONFIG_FALLBACK,
1491
+ presetPattern: SENTINEL_PRESET,
1492
+ settingsKey: "compilerOptions",
1493
+ permitted: PERMITTED_COMPILER_OPTIONS
1494
+ });
1495
+ }
1496
+
1497
+ // src/roles/typescript/adapters/tsc/tsc.adapter.ts
1498
+ var TYPECHECK_SCRIPT = { typecheck: "sentinel --run --typescript" };
1499
+ var INSTALL_NOTE = "run `pnpm install` to fetch @hublo/sentinel (added to the module devDependencies) so `extends` and the typecheck script resolve";
1500
+ var DEFAULT_MAX_DIAGNOSTICS = 100;
1501
+ var DIAGNOSTIC_RE = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.+)$/;
1502
+ function parseDiagnostics(output) {
1503
+ const diagnostics = [];
1504
+ for (const raw of output.split("\n")) {
1505
+ const m = DIAGNOSTIC_RE.exec(raw.trim());
1506
+ if (m) {
1507
+ diagnostics.push({
1508
+ file: m[1],
1509
+ line: Number(m[2]),
1510
+ col: Number(m[3]),
1511
+ code: m[4],
1512
+ message: m[5]
1513
+ });
1514
+ }
1515
+ }
1516
+ return diagnostics;
1517
+ }
1518
+ var PHASED_STRICTNESS_WARNING = `sentinel typescript: phase 1 (non-breaking) \u2014 deferred: ${deferredRuleNames2().join(", ")}. Enabled centrally in a later wave; run \`sentinel --inspect --typescript\` for the list.`;
1519
+ function composeExtends(current, preset) {
1520
+ const chain = typeof current === "string" ? [current] : Array.isArray(current) ? current.filter((entry) => typeof entry === "string") : [];
1521
+ return chain.includes(preset) ? chain : [...chain, preset];
1522
+ }
1523
+ var TscAdapter = class extends BaseAdapter {
1524
+ target = "typescript";
1525
+ runner = "tsc";
1526
+ /**
1527
+ * The tsc adapter drives type-checking for any flavour: `--run`/`--report`/
1528
+ * `--inspect` just execute tsc against the module's existing config, which is
1529
+ * meaningful regardless of flavour. `--init` is the exception, it only WRITES a
1530
+ * preset for flavours that ship one (gated inside `plan`), so svelte is not
1531
+ * clobbered with a non-existent preset.
1532
+ */
1533
+ appliesTo(_flavour) {
1534
+ return true;
1535
+ }
1536
+ /**
1537
+ * The flavour read from the committed `extends` chain
1538
+ * (`@hublo/sentinel/tsconfig/nest` -> `nest`), or undefined when the module has not
1539
+ * adopted a preset, so the engine falls back to dependency detection.
1540
+ *
1541
+ * This is the same detection-free read `--status` uses, and it is why an adopted React app
1542
+ * reports `react` even in a monorepo that hoists `react` to the root.
1543
+ */
1544
+ declaredFlavour(cwd) {
1545
+ const { preset } = readTsconfigAdoption(cwd);
1546
+ if (!preset) return void 0;
1547
+ const name = preset.slice(preset.lastIndexOf("/") + 1);
1548
+ return FLAVOURS.includes(name) ? name : void 0;
1549
+ }
1550
+ /**
1551
+ * Plan `--init`: make the module extend the sentinel preset with a THIN,
1552
+ * conformant stub, route type-checking through the CLI, and pin the
1553
+ * `@hublo/sentinel` devDependency into the module. The engine applies the ops, so
1554
+ * adoption is `--init` then `pnpm install`, with nothing to add by hand.
1555
+ *
1556
+ * Per resolved case:
1557
+ * - extends-base: append the preset to the `extends` chain (keep the base for
1558
+ * the monorepo's paths/structure) + strip preset-owned `compilerOptions`
1559
+ * (drift), keeping the project's own paths/include (the allowlist).
1560
+ * - none: create a fresh thin `tsconfig.json`.
1561
+ * - other-chain (svelte): skip, its config extends a different base.
1562
+ */
1563
+ plan(context) {
1564
+ if (!hasShippedPreset(context.flavour)) {
1565
+ return {
1566
+ operations: [],
1567
+ blocked: `no TypeScript preset for flavour "${context.flavour}" yet (shipped: ${SHIPPED_FLAVOURS.join(", ")}). Nothing was written; this module cannot adopt the TypeScript preset until that flavour ships.`
1568
+ };
1569
+ }
1570
+ const target = resolveTsconfigTarget(context.cwd);
1571
+ const preset = `@hublo/sentinel/tsconfig/${context.flavour}`;
1572
+ const addScript = this.packageJsonOperation(context.cwd);
1573
+ if (target.reason === "other-chain") {
1574
+ return {
1575
+ operations: [],
1576
+ blocked: `${target.path} extends a config chain sentinel does not handle, so nothing was written. Point it at the workspace base (or a plain config) and re-run.`
1577
+ };
1578
+ }
1579
+ if (target.reason === "none") {
1580
+ const contents = JSON.stringify({ extends: preset, include: ["src"] }, null, 2) + "\n";
1581
+ return {
1582
+ operations: [{ kind: "write", path: target.path, contents }, addScript],
1583
+ notes: [`created ${target.path} (no tsconfig found)`, INSTALL_NOTE]
1584
+ };
1585
+ }
1586
+ const existing = parseJsonc(
1587
+ readFileSync6(join8(context.cwd, target.path), "utf8"),
1588
+ target.path
1589
+ );
1590
+ const extendsChain = composeExtends(existing.extends, preset);
1591
+ const drift = presetOwnedKeys2(existing.compilerOptions);
1592
+ const operations = [
1593
+ { kind: "merge-json", path: target.path, value: { extends: extendsChain } }
1594
+ ];
1595
+ const notes = [];
1596
+ if (drift.length > 0) {
1597
+ const localKeys = Object.keys(existing.compilerOptions ?? {});
1598
+ const stripsAll = localKeys.length > 0 && localKeys.every((key) => drift.includes(key));
1599
+ operations.push({
1600
+ kind: "remove-json-keys",
1601
+ path: target.path,
1602
+ keys: stripsAll ? [["compilerOptions"]] : drift.map((key) => ["compilerOptions", key])
1603
+ });
1604
+ notes.push(`stripped preset-owned compilerOptions: ${drift.join(", ")}`);
1605
+ }
1606
+ operations.push(addScript);
1607
+ notes.push(INSTALL_NOTE);
1608
+ return { operations, notes };
1609
+ }
1610
+ /**
1611
+ * The op that routes type-checking through the CLI. If the module already has a
1612
+ * `package.json`, merge the script in and leave the rest untouched. If it does
1613
+ * NOT (common for nx apps/services that carry only a `project.json`), scaffold a
1614
+ * minimal, workspace-valid one, its nx name + `private: true`, so pnpm accepts it
1615
+ * and it can carry the pinned `@hublo/sentinel` devDep this op also writes. The
1616
+ * exact version is written (never a range), so `pnpm install` resolves the same
1617
+ * build the stub's `extends` points at.
1618
+ */
1619
+ packageJsonOperation(cwd) {
1620
+ const own = readOwnPackage();
1621
+ const devDependencies = { [own.name]: own.version };
1622
+ if (existsSync7(join8(cwd, "package.json"))) {
1623
+ return {
1624
+ kind: "merge-json",
1625
+ path: "package.json",
1626
+ value: { scripts: TYPECHECK_SCRIPT, devDependencies }
1627
+ };
1628
+ }
1629
+ const name = readNxProjectName(cwd) ?? basename2(cwd);
1630
+ return {
1631
+ kind: "merge-json",
1632
+ path: "package.json",
1633
+ value: { name, private: true, scripts: TYPECHECK_SCRIPT, devDependencies }
1634
+ };
1635
+ }
1636
+ /**
1637
+ * Type-check the module with `tsc -b` (build mode) on its solution config, the
1638
+ * way the monorepo itself does. Build mode walks the config's `references`, so a
1639
+ * references-only solution (Pattern A: app + spec) is actually checked instead of
1640
+ * passing vacuously; it also only caches SUCCESSFUL builds, so errors are always
1641
+ * re-reported. Uses the module's own tsc. Nothing to check is a pass.
1642
+ */
1643
+ async run(ctx) {
1644
+ const config = this.typecheckTarget(ctx.cwd);
1645
+ if (!config) {
1646
+ process.stderr.write("sentinel typescript(tsc): no tsconfig to check\n");
1647
+ return { ok: true, code: 0 };
1648
+ }
1649
+ process.stderr.write(`${palette(process.stderr).warn(PHASED_STRICTNESS_WARNING)}
1650
+ `);
1651
+ const tsc = resolveBin(ctx.cwd, "tsc") ?? "tsc";
1652
+ const result = spawnSync2(tsc, ["-b", config], { cwd: ctx.cwd, stdio: "inherit" });
1653
+ if (result.error) {
1654
+ process.stderr.write(
1655
+ `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
1656
+ `
1657
+ );
1658
+ return { ok: false, code: 1 };
1659
+ }
1660
+ const code = result.status ?? 1;
1661
+ return { ok: code === 0, code };
1662
+ }
1663
+ /**
1664
+ * The config to type-check with `tsc -b`. Prefer the module's root `tsconfig.json`
1665
+ * (the solution the monorepo builds; `tsc -b` follows its `references` to cover
1666
+ * app + spec), else the base-extending file, else null when there is nothing to
1667
+ * check.
1668
+ */
1669
+ typecheckTarget(cwd) {
1670
+ if (existsSync7(join8(cwd, "tsconfig.json"))) return "tsconfig.json";
1671
+ const target = resolveTsconfigTarget(cwd);
1672
+ return target.reason === "none" ? null : target.path;
1673
+ }
1674
+ /**
1675
+ * The module's resolved TypeScript config: which preset, which file, how, and the
1676
+ * phased-strictness state (`deferred` rules that are off in phase 1). This is the
1677
+ * "list what's deferred" query, `sentinel --inspect --typescript`.
1678
+ */
1679
+ async inspect(ctx) {
1680
+ const target = resolveTsconfigTarget(ctx.cwd);
1681
+ const { preset, adopted } = readTsconfigAdoption(ctx.cwd);
1682
+ return {
1683
+ module: ctx.module,
1684
+ target: "typescript",
1685
+ flavour: ctx.flavour,
1686
+ configFile: target.path,
1687
+ configState: target.reason,
1688
+ preset,
1689
+ adopted,
1690
+ phase: 1,
1691
+ deferred: DEFERRED_RULES
1692
+ };
1693
+ }
1694
+ /**
1695
+ * Adoption + conformity from the committed tsconfig, for `--status`. No tsc run and
1696
+ * no flavour guessing: reads the actual `extends` chain, so a workspace-wide scan is
1697
+ * a cheap coverage + drift dashboard (adopted? which preset? drifted?).
1698
+ */
1699
+ async status(ctx) {
1700
+ const { adopted, preset, conformant, drift } = readTsconfigAdoption(ctx.cwd);
1701
+ return { adopted, preset, conformant, drift };
1702
+ }
1703
+ /**
1704
+ * Report conformance for the module: `tsc -b` (build mode, so app + spec are
1705
+ * covered) and count total type errors plus the implicit-`any` family (TS70xx:
1706
+ * 7006/7031/7053/… ), the signal that drives the noImplicitAny migration. No
1707
+ * tsconfig is a clean, empty report.
1708
+ */
1709
+ async report(ctx) {
1710
+ const config = this.typecheckTarget(ctx.cwd);
1711
+ if (!config) {
1712
+ return { ok: true, code: 0, metrics: { errors: 0, implicitAny: "deferred", diagnostics: [] } };
1713
+ }
1714
+ const tsc = resolveBin(ctx.cwd, "tsc") ?? "tsc";
1715
+ const result = spawnSync2(tsc, ["-b", config], { cwd: ctx.cwd, encoding: "utf8" });
1716
+ if (result.error) {
1717
+ process.stderr.write(
1718
+ `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
1719
+ `
1720
+ );
1721
+ return { ok: false, code: 1, metrics: { error: "tsc not available" } };
1722
+ }
1723
+ const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
1724
+ const errors = (output.match(/error TS\d+/g) ?? []).length;
1725
+ const parsed = parseDiagnostics(output);
1726
+ const cap = ctx.maxDiagnostics === 0 ? Infinity : ctx.maxDiagnostics ?? DEFAULT_MAX_DIAGNOSTICS;
1727
+ const diagnostics = Number.isFinite(cap) ? parsed.slice(0, cap) : parsed;
1728
+ return {
1729
+ ok: errors === 0,
1730
+ code: result.status ?? 0,
1731
+ metrics: {
1732
+ errors,
1733
+ implicitAny: this.implicitAnyMetric(ctx.cwd, tsc, config, output),
1734
+ diagnostics,
1735
+ diagnosticsTruncated: diagnostics.length < parsed.length
1736
+ }
1737
+ };
1738
+ }
1739
+ /**
1740
+ * The `implicitAny` report metric, honestly. Implicit-`any` violations (TS70xx) are
1741
+ * only *visible* to tsc when `noImplicitAny` is ON. In phase 1 the rule is DEFERRED
1742
+ * (off), so counting TS70xx from the committed-config run is structurally always 0 —
1743
+ * a misleading "no implicit-any" when the rule simply was not applied. So: report the
1744
+ * real count only when the rule is on, otherwise `'deferred'` (never a fake `0`). The
1745
+ * remaining debt while deferred is a separate, opt-in probe (a later `--migration`).
1746
+ */
1747
+ implicitAnyMetric(cwd, tsc, config, mainOutput) {
1748
+ return this.noImplicitAnyEnabled(cwd, tsc, config, mainOutput) ? (mainOutput.match(/error TS70\d\d/g) ?? []).length : "deferred";
1749
+ }
1750
+ /**
1751
+ * Whether `noImplicitAny` is effectively ON in the module's resolved config. Read from
1752
+ * `tsc --showConfig` (an explicit value wins; otherwise `strict` implies it). If
1753
+ * `--showConfig` is unavailable, fall back to the run's own evidence: implicit-`any`
1754
+ * errors in the output mean the rule must be on.
1755
+ */
1756
+ noImplicitAnyEnabled(cwd, tsc, config, mainOutput) {
1757
+ const shown = spawnSync2(tsc, ["-p", config, "--showConfig"], { cwd, encoding: "utf8" });
1758
+ if (shown.status === 0 && shown.stdout) {
1759
+ try {
1760
+ const co = parseJsonc(
1761
+ shown.stdout,
1762
+ "tsconfig(--showConfig)"
1763
+ ).compilerOptions;
1764
+ if (co) return co.noImplicitAny ?? co.strict === true;
1765
+ } catch {
1766
+ }
1767
+ }
1768
+ return /error TS70\d\d/.test(mainOutput);
1769
+ }
1770
+ };
1771
+
1772
+ // src/roles/typescript/register.ts
1773
+ function registerTypescript() {
1774
+ register(new TscAdapter());
1775
+ setDefaultRunner("typescript", "tsc");
1776
+ }
1777
+
1778
+ // src/adapters.ts
1779
+ function registerAdapters() {
1780
+ registerTypescript();
1781
+ registerLint();
1782
+ }
1783
+
1784
+ // src/core/detect-framework.ts
1785
+ var FRAMEWORK_SIGNALS = [
1786
+ { flavour: "nest", dependency: "@nestjs/core" },
1787
+ { flavour: "react", dependency: "react" },
1788
+ { flavour: "svelte", dependency: "svelte" }
1789
+ ];
1790
+ var BACKEND_HINTS = ["sails", "express", "koa", "fastify", "@hapi/hapi"];
1791
+ function describeFramework(packageJson) {
1792
+ const dependencies = { ...packageJson.dependencies, ...packageJson.devDependencies };
1793
+ const matched = FRAMEWORK_SIGNALS.filter((s) => s.dependency in dependencies);
1794
+ const chosen = matched[0];
1795
+ const flavour = chosen?.flavour ?? "node";
1796
+ const source = chosen?.dependency ?? "(no framework dependency)";
1797
+ const otherFlavourSignals = matched.slice(1).map((s) => s.dependency);
1798
+ const backendHints = flavour === "react" || flavour === "svelte" ? BACKEND_HINTS.filter((hint) => hint in dependencies) : [];
1799
+ const conflicts = [...otherFlavourSignals, ...backendHints];
1800
+ return { flavour, source, ambiguous: conflicts.length > 0, conflicts };
1801
+ }
1802
+ function detectFramework(packageJson) {
1803
+ return describeFramework(packageJson).flavour;
1804
+ }
1805
+
1806
+ // src/shared/text.ts
1807
+ function ensureLines(current, lines) {
1808
+ const present = new Set(current.split("\n").map((line) => line.trim()));
1809
+ const missing = lines.filter((line) => !present.has(line.trim()));
1810
+ if (missing.length === 0) return current;
1811
+ const prefix = current.length === 0 || current.endsWith("\n") ? current : current + "\n";
1812
+ return prefix + missing.join("\n") + "\n";
1813
+ }
1814
+ function toLines(text) {
1815
+ if (text.length === 0) return [];
1816
+ return text.replace(/\n$/, "").split("\n");
1817
+ }
1818
+ function diffLines(before, after) {
1819
+ const from = toLines(before);
1820
+ const to = toLines(after);
1821
+ const lcs = Array.from(
1822
+ { length: from.length + 1 },
1823
+ () => new Array(to.length + 1).fill(0)
1824
+ );
1825
+ const cell = (i2, j2) => lcs[i2]?.[j2] ?? 0;
1826
+ for (let i2 = from.length - 1; i2 >= 0; i2--) {
1827
+ const row = lcs[i2];
1828
+ if (!row) continue;
1829
+ for (let j2 = to.length - 1; j2 >= 0; j2--) {
1830
+ row[j2] = from[i2] === to[j2] ? cell(i2 + 1, j2 + 1) + 1 : Math.max(cell(i2 + 1, j2), cell(i2, j2 + 1));
1831
+ }
1832
+ }
1833
+ const out = [];
1834
+ let i = 0;
1835
+ let j = 0;
1836
+ while (i < from.length && j < to.length) {
1837
+ if (from[i] === to[j]) {
1838
+ out.push(` ${from[i] ?? ""}`);
1839
+ i++;
1840
+ j++;
1841
+ } else if (cell(i + 1, j) >= cell(i, j + 1)) {
1842
+ out.push(`- ${from[i] ?? ""}`);
1843
+ i++;
1844
+ } else {
1845
+ out.push(`+ ${to[j] ?? ""}`);
1846
+ j++;
1847
+ }
1848
+ }
1849
+ while (i < from.length) out.push(`- ${from[i++] ?? ""}`);
1850
+ while (j < to.length) out.push(`+ ${to[j++] ?? ""}`);
1851
+ return out;
1852
+ }
1853
+ function replaceLines(current, replacements) {
1854
+ if (replacements.length === 0) return current;
1855
+ const byLine = new Map(replacements.map((entry) => [entry.from, entry.to]));
1856
+ return current.split("\n").map((line) => byLine.get(line) ?? line).join("\n");
1857
+ }
1858
+
1859
+ // src/core/apply-plan.ts
1860
+ import { existsSync as existsSync8, readFileSync as readFileSync7, renameSync, rmSync, writeFileSync } from "fs";
1861
+ import { resolve as resolve2, sep } from "path";
1862
+ import { applyEdits, modify } from "jsonc-parser";
1863
+
1864
+ // src/shared/deep-merge.ts
1865
+ function isPlainObject(value) {
1866
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1867
+ }
1868
+
1869
+ // src/core/apply-plan.ts
1870
+ function resolveWithinRoot(cwd, relativePath) {
1871
+ const root = resolve2(cwd);
1872
+ const absolutePath = resolve2(root, relativePath);
1873
+ if (absolutePath !== root && !absolutePath.startsWith(root + sep)) {
1874
+ throw new Error(`Refusing to write outside the module root: "${relativePath}".`);
1875
+ }
1876
+ return absolutePath;
1877
+ }
1878
+ function readIfExists(absolutePath) {
1879
+ return existsSync8(absolutePath) ? readFileSync7(absolutePath, "utf8") : void 0;
1880
+ }
1881
+ function* leaves(value, prefix = []) {
1882
+ for (const [key, keyValue] of Object.entries(value)) {
1883
+ const path = [...prefix, key];
1884
+ if (isPlainObject(keyValue)) yield* leaves(keyValue, path);
1885
+ else yield [path, keyValue];
1886
+ }
1887
+ }
1888
+ function mergeJsonc(current, value) {
1889
+ let text = current.trim().length > 0 ? current : "{}\n";
1890
+ for (const [path, leaf] of leaves(value)) {
1891
+ const edits = modify(text, path, leaf, {
1892
+ formattingOptions: { insertSpaces: true, tabSize: 2 }
1893
+ });
1894
+ text = applyEdits(text, edits);
1895
+ }
1896
+ return text.endsWith("\n") ? text : text + "\n";
1897
+ }
1898
+ function removeJsoncKeys(current, keys) {
1899
+ let text = current.trim().length > 0 ? current : "{}\n";
1900
+ for (const path of keys) {
1901
+ const edits = modify(text, path, void 0, {
1902
+ formattingOptions: { insertSpaces: true, tabSize: 2 }
1903
+ });
1904
+ text = applyEdits(text, edits);
1905
+ }
1906
+ return text.endsWith("\n") ? text : text + "\n";
1907
+ }
1908
+ function applyOperationTo(current, operation) {
1909
+ switch (operation.kind) {
1910
+ case "write":
1911
+ return operation.contents;
1912
+ case "merge-json":
1913
+ return mergeJsonc(current, operation.value);
1914
+ case "ensure-lines":
1915
+ return ensureLines(current, operation.lines);
1916
+ case "remove-json-keys":
1917
+ return removeJsoncKeys(current, operation.keys);
1918
+ case "replace-lines":
1919
+ return replaceLines(current, operation.replacements);
1920
+ case "delete":
1921
+ return "";
1922
+ default: {
1923
+ const unreachable = operation;
1924
+ throw new Error(`Unknown file operation: ${JSON.stringify(unreachable)}`);
1925
+ }
1926
+ }
1927
+ }
1928
+ function preparePlan(cwd, plan) {
1929
+ const prepared = /* @__PURE__ */ new Map();
1930
+ for (const operation of plan.operations) {
1931
+ const absolutePath = resolveWithinRoot(cwd, operation.path);
1932
+ const existing = prepared.get(operation.path);
1933
+ const before = existing?.before ?? readIfExists(absolutePath) ?? "";
1934
+ const current = existing?.after ?? before;
1935
+ prepared.set(operation.path, {
1936
+ path: operation.path,
1937
+ absolutePath,
1938
+ before,
1939
+ after: applyOperationTo(current, operation),
1940
+ // A later operation on the same path un-deletes it: the last word wins, the same
1941
+ // way content operations chain.
1942
+ deleted: operation.kind === "delete"
1943
+ });
1944
+ }
1945
+ return [...prepared.values()];
1946
+ }
1947
+ function writeFileAtomic(absolutePath, contents) {
1948
+ const tempPath = `${absolutePath}.sentinel-${process.pid}.tmp`;
1949
+ writeFileSync(tempPath, contents);
1950
+ renameSync(tempPath, absolutePath);
1951
+ }
1952
+ function applyPlan(cwd, plan) {
1953
+ const changed = preparePlan(cwd, plan).filter((file) => file.before !== file.after);
1954
+ for (const file of changed) {
1955
+ if (file.deleted) {
1956
+ rmSync(file.absolutePath, { force: true });
1957
+ continue;
1958
+ }
1959
+ writeFileAtomic(file.absolutePath, file.after);
1960
+ }
1961
+ return changed.map((file) => file.path);
1962
+ }
1963
+
1964
+ // src/core/dispatch.ts
1965
+ function resolveFlavour(opts) {
1966
+ if (opts.flavour) return opts.flavour;
1967
+ const detection = describeFramework(readProjectPackageJson(opts.cwd));
1968
+ if (detection.ambiguous) {
1969
+ const warn = palette(process.stderr);
1970
+ process.stderr.write(
1971
+ warn.warn(
1972
+ `sentinel: flavour is ambiguous, detected "${detection.flavour}" (from ${detection.source}), also found ${detection.conflicts.join(", ")}. Pass --flavour to write the intended preset.`
1973
+ ) + "\n"
1974
+ );
1975
+ }
1976
+ return detection.flavour;
1977
+ }
1978
+ function previewPlan(opts, plan) {
1979
+ const changed = preparePlan(opts.cwd, plan).filter((file) => file.before !== file.after);
1980
+ if (opts.json) {
1981
+ process.stdout.write(
1982
+ JSON.stringify(
1983
+ {
1984
+ dryRun: true,
1985
+ notes: plan.notes ?? [],
1986
+ files: changed.map(({ path, before, after, deleted }) => ({
1987
+ path,
1988
+ action: deleted ? "delete" : before.length === 0 ? "create" : "update",
1989
+ before,
1990
+ after
1991
+ }))
1992
+ },
1993
+ null,
1994
+ 2
1995
+ ) + "\n"
1996
+ );
1997
+ return 0;
1998
+ }
1999
+ process.stderr.write(" dry run: no files written\n");
2000
+ for (const note of plan.notes ?? []) process.stderr.write(` ${note}
2001
+ `);
2002
+ if (changed.length === 0) {
2003
+ process.stderr.write(" nothing to change\n");
2004
+ return 0;
2005
+ }
2006
+ for (const { path, before, after, deleted } of changed) {
2007
+ const action = deleted ? "delete" : before.length === 0 ? "create" : "update";
2008
+ process.stdout.write(`
2009
+ ${action} ${path}
2010
+ `);
2011
+ for (const line of diffLines(before, after)) process.stdout.write(` ${line}
2012
+ `);
2013
+ }
2014
+ return 0;
2015
+ }
2016
+ async function dispatch(opts) {
2017
+ if (opts.verb !== "init") {
2018
+ throw new Error(`dispatch handles --init only; --${opts.verb} routes through analyse()`);
2019
+ }
2020
+ const detected = resolveFlavour(opts);
2021
+ const adapter = resolve(opts.target, detected, opts.runner);
2022
+ const flavour = opts.flavour ?? adapter.declaredFlavour?.(opts.cwd) ?? detected;
2023
+ const context = { cwd: opts.cwd, flavour };
2024
+ const plan = await adapter.plan(context);
2025
+ if (plan.blocked) {
2026
+ process.stderr.write(`sentinel (${opts.target}): ${plan.blocked}
2027
+ `);
2028
+ return 1;
2029
+ }
2030
+ if (opts.dryRun) {
2031
+ return previewPlan(opts, plan);
2032
+ }
2033
+ const written = applyPlan(opts.cwd, plan);
2034
+ for (const path of written) process.stderr.write(` wrote ${path}
2035
+ `);
2036
+ for (const note of plan.notes ?? []) process.stderr.write(` ${note}
2037
+ `);
2038
+ return 0;
2039
+ }
2040
+
2041
+ export {
2042
+ register,
2043
+ setDefaultRunner,
2044
+ all,
2045
+ availableTargets,
2046
+ resolve,
2047
+ BaseAdapter,
2048
+ palette,
2049
+ readOwnPackage,
2050
+ readOwnVersion,
2051
+ readProjectPackageJson,
2052
+ readNxProjectName,
2053
+ resolveBin,
2054
+ VERBS,
2055
+ TARGETS,
2056
+ FLAVOURS,
2057
+ registerAdapters,
2058
+ describeFramework,
2059
+ detectFramework,
2060
+ dispatch
2061
+ };
2062
+ //# sourceMappingURL=chunk-EZCRU6XE.js.map