@hublo/sentinel 1.1.0-alpha.2 → 1.1.0-alpha.4

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,3155 @@
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, preset, 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 = preset ? forTarget.filter((a) => a.appliesTo(preset)) : forTarget;
26
+ if (candidates.length === 0) {
27
+ throw new Error(`No adapter for target "${target}" handles preset "${preset}".`);
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}" (preset "${preset}"). Available: ${available}.`
42
+ );
43
+ }
44
+ if (matching.length > 1) {
45
+ throw new Error(
46
+ `Ambiguous: ${matching.length} adapters claim target "${target}", runner "${wanted}", preset "${preset}".`
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 PRESET_NAMES = ["react", "nest", "svelte", "node"];
139
+
140
+ // src/roles/lint/adapters/oxlint/oxlint.adapter.ts
141
+ import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
142
+ import { basename, join as join8 } 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(preset) {
155
+ return `${PRESET_DIR}/${preset}.json`;
156
+ }
157
+ var SENTINEL_LINT_PRESET = /@hublo\/sentinel\/oxlint\/([a-z-]+)\.json$/;
158
+ function presetNameFromPath(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/presets/base.json
180
+ var base_default = {
181
+ rules: {
182
+ "no-var": "error",
183
+ "prefer-const": [
184
+ "error",
185
+ {
186
+ destructuring: "any",
187
+ ignoreReadBeforeAssign: false
188
+ }
189
+ ],
190
+ "prefer-rest-params": "error",
191
+ "prefer-spread": "error",
192
+ "typescript/ban-ts-comment": "error",
193
+ "typescript/no-array-constructor": "error",
194
+ "typescript/no-duplicate-enum-values": "error",
195
+ "typescript/no-empty-object-type": "error",
196
+ "typescript/no-extra-non-null-assertion": "error",
197
+ "typescript/no-misused-new": "error",
198
+ "typescript/no-namespace": "error",
199
+ "typescript/no-non-null-asserted-optional-chain": "error",
200
+ "typescript/no-this-alias": "error",
201
+ "typescript/no-unnecessary-type-constraint": "error",
202
+ "typescript/no-unsafe-declaration-merging": "error",
203
+ "typescript/no-unsafe-function-type": "error",
204
+ "typescript/no-unused-expressions": [
205
+ "error",
206
+ {
207
+ allowShortCircuit: false,
208
+ allowTaggedTemplates: false,
209
+ allowTernary: false
210
+ }
211
+ ],
212
+ "typescript/no-wrapper-object-types": "error",
213
+ "typescript/prefer-as-const": "error",
214
+ "typescript/prefer-namespace-keyword": "error",
215
+ "typescript/triple-slash-reference": "error"
216
+ }
217
+ };
218
+
219
+ // src/roles/lint/presets/nest.json
220
+ var nest_default = {
221
+ plugins: ["typescript", "jest", "import", "unicorn", "oxc", "node"],
222
+ jsPlugins: [
223
+ "eslint-plugin-no-barrel-files",
224
+ {
225
+ name: "nx",
226
+ specifier: "@nx/eslint-plugin"
227
+ },
228
+ {
229
+ name: "import-js",
230
+ specifier: "eslint-plugin-import"
231
+ }
232
+ ],
233
+ settings: {
234
+ "import/extensions": [".ts", ".cts", ".mts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
235
+ "import/external-module-folders": ["node_modules", "node_modules/@types"],
236
+ "import/parsers": {
237
+ "@typescript-eslint/parser": [".ts", ".cts", ".mts", ".tsx"]
238
+ },
239
+ "import/resolver": {
240
+ node: {
241
+ extensions: [".ts", ".cts", ".mts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]
242
+ }
243
+ }
244
+ },
245
+ rules: {
246
+ "array-callback-return": [
247
+ "error",
248
+ {
249
+ allowImplicit: false,
250
+ checkForEach: false,
251
+ allowVoid: false
252
+ }
253
+ ],
254
+ complexity: ["error", 30],
255
+ curly: ["error", "all"],
256
+ "dot-notation": [
257
+ "error",
258
+ {
259
+ allowKeywords: true,
260
+ allowPattern: ""
261
+ }
262
+ ],
263
+ eqeqeq: "error",
264
+ "for-direction": "error",
265
+ "func-style": [
266
+ "error",
267
+ "expression",
268
+ {
269
+ allowArrowFunctions: false,
270
+ allowTypeAnnotation: false,
271
+ overrides: {}
272
+ }
273
+ ],
274
+ "import-js/no-duplicates": [
275
+ "error",
276
+ {
277
+ considerQueryString: true,
278
+ "prefer-inline": true
279
+ }
280
+ ],
281
+ "import-js/order": {
282
+ severity: "warn",
283
+ reason: "was error in the module config, but the lint target only ever checked 2 files of 2888, so the 23 violations were never reported; held at warn so adoption does not turn a green module red, and restored once they are fixed",
284
+ options: [
285
+ {
286
+ "newlines-between": "always",
287
+ groups: ["builtin", "external", "internal", ["parent", "index"], "sibling"],
288
+ alphabetize: {
289
+ order: "asc",
290
+ caseInsensitive: false,
291
+ orderImportKind: "ignore"
292
+ },
293
+ pathGroups: [
294
+ {
295
+ pattern: "@nestjs/**",
296
+ group: "external"
297
+ },
298
+ {
299
+ pattern: "{@admin/**,@authentication/**,@featureToggles/**,@institution/**,@hublo/**,@network/**,@mission/**,@notification/**,@shared/**,@talent/**,@worker/**,@contract/**,@config/**,@front/**}",
300
+ group: "internal",
301
+ position: "before"
302
+ }
303
+ ],
304
+ pathGroupsExcludedImportTypes: ["builtin"],
305
+ distinctGroup: true,
306
+ sortTypesGroup: false,
307
+ named: false,
308
+ warnOnUnassignedImports: false
309
+ }
310
+ ]
311
+ },
312
+ "jest/expect-expect": {
313
+ severity: "warn",
314
+ reason: "the modules already ran this at warn (confirmed in the bff-admin proof-of-concept config); adoption is like-for-like, so raising it to error is a separate decision"
315
+ },
316
+ "jest/no-alias-methods": "error",
317
+ "jest/no-commented-out-tests": {
318
+ severity: "warn",
319
+ reason: "the modules already ran this at warn (confirmed in the bff-admin proof-of-concept config); adoption is like-for-like, so raising it to error is a separate decision"
320
+ },
321
+ "jest/no-deprecated-functions": "error",
322
+ "jest/no-disabled-tests": {
323
+ severity: "warn",
324
+ reason: "the modules already ran this at warn (confirmed in the bff-admin proof-of-concept config); a skipped test is a deliberate act often paired with a ticket, so it reports rather than blocks"
325
+ },
326
+ "jest/no-done-callback": "error",
327
+ "jest/no-export": "error",
328
+ "jest/no-focused-tests": "error",
329
+ "jest/no-identical-title": "error",
330
+ "jest/no-interpolation-in-snapshots": "error",
331
+ "jest/no-jasmine-globals": "error",
332
+ "jest/no-mocks-import": "error",
333
+ "jest/no-standalone-expect": "error",
334
+ "jest/no-test-prefixes": "error",
335
+ "jest/prefer-to-be": "error",
336
+ "jest/prefer-to-contain": "error",
337
+ "jest/prefer-to-have-length": "error",
338
+ "jest/valid-describe-callback": "error",
339
+ "jest/valid-expect": "error",
340
+ "jest/valid-expect-in-promise": "error",
341
+ "jest/valid-title": "error",
342
+ "no-async-promise-executor": "error",
343
+ "no-barrel-files/no-barrel-files": {
344
+ severity: "off",
345
+ 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"
346
+ },
347
+ "no-case-declarations": "error",
348
+ "no-compare-neg-zero": "error",
349
+ "no-cond-assign": ["error", "except-parens"],
350
+ "no-console": [
351
+ "error",
352
+ {
353
+ allow: ["warn", "error"]
354
+ }
355
+ ],
356
+ "no-constant-binary-expression": "error",
357
+ "no-constant-condition": [
358
+ "error",
359
+ {
360
+ checkLoops: "allExceptWhileTrue"
361
+ }
362
+ ],
363
+ "no-control-regex": {
364
+ severity: "off",
365
+ 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"
366
+ },
367
+ "no-debugger": "error",
368
+ "no-delete-var": "error",
369
+ "no-dupe-else-if": "error",
370
+ "no-dupe-keys": "error",
371
+ "no-duplicate-case": "error",
372
+ "no-else-return": [
373
+ "error",
374
+ {
375
+ allowElseIf: true
376
+ }
377
+ ],
378
+ "no-empty": [
379
+ "error",
380
+ {
381
+ allowEmptyCatch: false
382
+ }
383
+ ],
384
+ "no-empty-character-class": "error",
385
+ "no-empty-pattern": [
386
+ "error",
387
+ {
388
+ allowObjectPatternsAsParameters: false
389
+ }
390
+ ],
391
+ "no-empty-static-block": "error",
392
+ "no-ex-assign": "error",
393
+ "no-extra-boolean-cast": ["error", {}],
394
+ "no-fallthrough": [
395
+ "error",
396
+ {
397
+ allowEmptyCase: false,
398
+ reportUnusedFallthroughComment: false
399
+ }
400
+ ],
401
+ "no-global-assign": [
402
+ "error",
403
+ {
404
+ exceptions: []
405
+ }
406
+ ],
407
+ "no-implicit-coercion": [
408
+ "error",
409
+ {
410
+ allow: [],
411
+ boolean: true,
412
+ disallowTemplateShorthand: false,
413
+ number: true,
414
+ string: true
415
+ }
416
+ ],
417
+ "no-invalid-regexp": ["error", {}],
418
+ "no-irregular-whitespace": [
419
+ "error",
420
+ {
421
+ skipComments: false,
422
+ skipJSXText: false,
423
+ skipRegExps: false,
424
+ skipStrings: true,
425
+ skipTemplates: false
426
+ }
427
+ ],
428
+ "no-lonely-if": "error",
429
+ "no-loss-of-precision": "error",
430
+ "no-misleading-character-class": "error",
431
+ "no-nonoctal-decimal-escape": "error",
432
+ "no-prototype-builtins": "error",
433
+ "no-regex-spaces": "error",
434
+ "no-restricted-imports": [
435
+ "error",
436
+ {
437
+ paths: [
438
+ {
439
+ name: "@front/theme",
440
+ importNames: [
441
+ "hubloTheme",
442
+ "legacyHubloTheme",
443
+ "customCommonColors",
444
+ "customPalette",
445
+ "breakpointsOptions",
446
+ "breakpointsValues",
447
+ "spacingConstants"
448
+ ],
449
+ message: "Use the MUI theme context or the public providers exposed by @front/theme."
450
+ }
451
+ ],
452
+ patterns: [
453
+ {
454
+ group: ["@front/theme/*", "!@front/theme/testing"],
455
+ message: "Deep imports from @front/theme are forbidden outside the theme lib."
456
+ }
457
+ ]
458
+ }
459
+ ],
460
+ "no-self-assign": [
461
+ "error",
462
+ {
463
+ props: true
464
+ }
465
+ ],
466
+ "no-shadow-restricted-names": [
467
+ "error",
468
+ {
469
+ reportGlobalThis: false
470
+ }
471
+ ],
472
+ "no-sparse-arrays": "error",
473
+ "no-unneeded-ternary": [
474
+ "error",
475
+ {
476
+ defaultAssignment: true
477
+ }
478
+ ],
479
+ "no-unsafe-finally": "error",
480
+ "no-unsafe-optional-chaining": [
481
+ "error",
482
+ {
483
+ disallowArithmeticOperators: false
484
+ }
485
+ ],
486
+ "no-unused-labels": "error",
487
+ "no-unused-private-class-members": "error",
488
+ "no-useless-backreference": "error",
489
+ "no-useless-catch": "error",
490
+ "no-useless-escape": [
491
+ "error",
492
+ {
493
+ allowRegexCharacters: []
494
+ }
495
+ ],
496
+ "no-useless-return": "error",
497
+ "nx/enforce-module-boundaries": [
498
+ "error",
499
+ {
500
+ enforceBuildableLibDependency: true,
501
+ allow: [],
502
+ depConstraints: [
503
+ {
504
+ sourceTag: "*",
505
+ onlyDependOnLibsWithTags: ["*"]
506
+ }
507
+ ]
508
+ }
509
+ ],
510
+ "prefer-arrow-callback": [
511
+ "error",
512
+ {
513
+ allowNamedFunctions: false,
514
+ allowUnboundThis: true
515
+ }
516
+ ],
517
+ "require-yield": "error",
518
+ "typescript/await-thenable": "error",
519
+ "typescript/no-confusing-non-null-assertion": "error",
520
+ "typescript/no-empty-function": [
521
+ "error",
522
+ {
523
+ allow: []
524
+ }
525
+ ],
526
+ "typescript/no-explicit-any": "error",
527
+ "typescript/no-floating-promises": [
528
+ "error",
529
+ {
530
+ ignoreVoid: true
531
+ }
532
+ ],
533
+ "typescript/no-import-type-side-effects": "error",
534
+ "typescript/no-inferrable-types": "error",
535
+ "typescript/no-non-null-assertion": "error",
536
+ "typescript/no-unused-vars": [
537
+ "error",
538
+ {
539
+ argsIgnorePattern: "^_",
540
+ caughtErrors: "none"
541
+ }
542
+ ],
543
+ "typescript/return-await": {
544
+ severity: "warn",
545
+ reason: "replaces the deprecated core `no-return-await`, which both modules set to error but which was never actually reporting: the nx lint target expanded to 2 files of 2888. The `never` option matches what no-return-await meant, so this is like-for-like, but it now runs over every file and finds real violations (9 on bff-admin, 5 on host-admin, measured with --type-aware). Held at warn so adoption does not turn a green module red, and raised to error once they are fixed. Switching to the stricter `in-try-catch`, which typescript-eslint recommends because `return await` inside try IS needed for correct error handling, is a separate decision with its own count.",
546
+ options: ["never"]
547
+ },
548
+ "typescript/switch-exhaustiveness-check": [
549
+ "error",
550
+ {
551
+ considerDefaultExhaustiveForUnions: true
552
+ }
553
+ ],
554
+ "use-isnan": [
555
+ "error",
556
+ {
557
+ enforceForIndexOf: false,
558
+ enforceForSwitchCase: true
559
+ }
560
+ ],
561
+ "valid-typeof": [
562
+ "error",
563
+ {
564
+ requireStringLiterals: false
565
+ }
566
+ ]
567
+ },
568
+ uncovered: {
569
+ "array-bracket-newline": "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.",
570
+ "array-bracket-spacing": "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.",
571
+ "block-spacing": "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.",
572
+ "computed-property-spacing": "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.",
573
+ "dot-location": "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.",
574
+ "eol-last": "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.",
575
+ "func-call-spacing": "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.",
576
+ "key-spacing": "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.",
577
+ "keyword-spacing": "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.",
578
+ "lines-between-class-members": "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.",
579
+ "no-invalid-this": "no oxlint equivalent, and hosting does not help: the rule crashes on every file under oxlint's plugin API (sourceCode.getJSDocComment is not implemented). Its coverage moves to TypeScript's noImplicitThis, which the sentinel TypeScript role manages.",
580
+ "no-multi-spaces": "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.",
581
+ "no-octal": "no oxlint equivalent",
582
+ "no-trailing-spaces": "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.",
583
+ 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.",
584
+ "template-curly-spacing": "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.",
585
+ "typescript/member-ordering": "oxlint has no native rule for it, and the preset deliberately does NOT host @typescript-eslint/eslint-plugin: hosting a whole plugin for one rule costs a dependency, a load step and a pass over every file. Accepted as a loss rather than paid for, the same call as no-octal."
586
+ }
587
+ };
588
+
589
+ // src/roles/lint/presets/node.json
590
+ var node_default = {
591
+ plugins: ["typescript", "oxc"],
592
+ jsPlugins: [],
593
+ settings: null,
594
+ rules: {}
595
+ };
596
+
597
+ // src/roles/lint/presets/react.json
598
+ var react_default = {
599
+ plugins: ["typescript", "react", "jsx-a11y", "jest", "import", "unicorn", "oxc"],
600
+ jsPlugins: [
601
+ "eslint-plugin-no-barrel-files",
602
+ {
603
+ name: "nx",
604
+ specifier: "@nx/eslint-plugin"
605
+ },
606
+ {
607
+ name: "import-js",
608
+ specifier: "eslint-plugin-import"
609
+ },
610
+ {
611
+ name: "@tanstack/query",
612
+ specifier: "@tanstack/eslint-plugin-query"
613
+ },
614
+ {
615
+ name: "styled-components-a11y",
616
+ specifier: "eslint-plugin-styled-components-a11y"
617
+ },
618
+ {
619
+ name: "react-refresh",
620
+ specifier: "eslint-plugin-react-refresh"
621
+ },
622
+ "../plugins/hublo.js"
623
+ ],
624
+ settings: {
625
+ "import/extensions": [".ts", ".cts", ".mts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
626
+ "import/external-module-folders": ["node_modules", "node_modules/@types"],
627
+ "import/parsers": {
628
+ "@typescript-eslint/parser": [".ts", ".cts", ".mts", ".tsx"]
629
+ },
630
+ "import/resolver": {
631
+ node: {
632
+ extensions: [".ts", ".cts", ".mts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]
633
+ }
634
+ }
635
+ },
636
+ rules: {
637
+ "@tanstack/query/exhaustive-deps": {
638
+ severity: "off",
639
+ reason: "the codebase predates these rules and has never satisfied them; enabling them is its own piece of work, not part of swapping linters"
640
+ },
641
+ "@tanstack/query/infinite-query-property-order": {
642
+ severity: "off",
643
+ reason: "the codebase predates these rules and has never satisfied them; enabling them is its own piece of work, not part of swapping linters"
644
+ },
645
+ "@tanstack/query/mutation-property-order": {
646
+ severity: "off",
647
+ reason: "the codebase predates these rules and has never satisfied them; enabling them is its own piece of work, not part of swapping linters"
648
+ },
649
+ "@tanstack/query/no-rest-destructuring": {
650
+ severity: "off",
651
+ reason: "the codebase predates these rules and has never satisfied them; enabling them is its own piece of work, not part of swapping linters"
652
+ },
653
+ "@tanstack/query/no-unstable-deps": {
654
+ severity: "off",
655
+ reason: "the codebase predates these rules and has never satisfied them; enabling them is its own piece of work, not part of swapping linters"
656
+ },
657
+ "@tanstack/query/no-void-query-fn": {
658
+ severity: "off",
659
+ reason: "the codebase predates these rules and has never satisfied them; enabling them is its own piece of work, not part of swapping linters"
660
+ },
661
+ "@tanstack/query/stable-query-client": {
662
+ severity: "off",
663
+ reason: "the codebase predates these rules and has never satisfied them; enabling them is its own piece of work, not part of swapping linters"
664
+ },
665
+ curly: ["error", "all"],
666
+ "for-direction": "error",
667
+ "hublo/no-palette-common": {
668
+ severity: "off",
669
+ 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"
670
+ },
671
+ "import-js/order": [
672
+ "error",
673
+ {
674
+ "newlines-between": "always",
675
+ groups: ["builtin", "external", "internal", ["parent", "index"], "sibling"],
676
+ alphabetize: {
677
+ order: "asc"
678
+ },
679
+ pathGroups: [
680
+ {
681
+ pattern: "@nestjs/**",
682
+ group: "external"
683
+ },
684
+ {
685
+ pattern: "{@admin/**,@authentication/**,@featureToggles/**,@institution/**,@hublo/**,@network/**,@mission/**,@notification/**,@shared/**,@talent/**,@worker/**,@contract/**,@config/**,@front/**}",
686
+ group: "internal",
687
+ position: "before"
688
+ }
689
+ ],
690
+ pathGroupsExcludedImportTypes: ["builtin"]
691
+ }
692
+ ],
693
+ "jest/expect-expect": {
694
+ severity: "off",
695
+ 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"
696
+ },
697
+ "jest/no-alias-methods": {
698
+ severity: "off",
699
+ 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"
700
+ },
701
+ "jest/no-commented-out-tests": {
702
+ severity: "off",
703
+ 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"
704
+ },
705
+ "jest/no-conditional-expect": {
706
+ severity: "off",
707
+ 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"
708
+ },
709
+ "jest/no-deprecated-functions": {
710
+ severity: "off",
711
+ 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"
712
+ },
713
+ "jest/no-disabled-tests": {
714
+ severity: "off",
715
+ 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"
716
+ },
717
+ "jest/no-done-callback": {
718
+ severity: "off",
719
+ 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"
720
+ },
721
+ "jest/no-export": {
722
+ severity: "off",
723
+ 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"
724
+ },
725
+ "jest/no-focused-tests": {
726
+ severity: "off",
727
+ 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"
728
+ },
729
+ "jest/no-identical-title": {
730
+ severity: "off",
731
+ 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"
732
+ },
733
+ "jest/no-interpolation-in-snapshots": {
734
+ severity: "off",
735
+ 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"
736
+ },
737
+ "jest/no-jasmine-globals": {
738
+ severity: "off",
739
+ 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"
740
+ },
741
+ "jest/no-mocks-import": {
742
+ severity: "off",
743
+ 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"
744
+ },
745
+ "jest/no-standalone-expect": {
746
+ severity: "off",
747
+ 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"
748
+ },
749
+ "jest/no-test-prefixes": {
750
+ severity: "off",
751
+ 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"
752
+ },
753
+ "jest/prefer-to-be": {
754
+ severity: "off",
755
+ 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"
756
+ },
757
+ "jest/prefer-to-contain": {
758
+ severity: "off",
759
+ 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"
760
+ },
761
+ "jest/prefer-to-have-length": {
762
+ severity: "off",
763
+ 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"
764
+ },
765
+ "jest/valid-describe-callback": {
766
+ severity: "off",
767
+ 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"
768
+ },
769
+ "jest/valid-expect": {
770
+ severity: "off",
771
+ 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"
772
+ },
773
+ "jest/valid-expect-in-promise": {
774
+ severity: "off",
775
+ 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"
776
+ },
777
+ "jest/valid-title": {
778
+ severity: "off",
779
+ 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"
780
+ },
781
+ "jsx-a11y/alt-text": "error",
782
+ "jsx-a11y/anchor-has-content": "error",
783
+ "jsx-a11y/anchor-is-valid": "error",
784
+ "jsx-a11y/aria-activedescendant-has-tabindex": "error",
785
+ "jsx-a11y/aria-props": "error",
786
+ "jsx-a11y/aria-proptypes": "error",
787
+ "jsx-a11y/aria-role": "error",
788
+ "jsx-a11y/aria-unsupported-elements": "error",
789
+ "jsx-a11y/autocomplete-valid": "error",
790
+ "jsx-a11y/click-events-have-key-events": "error",
791
+ "jsx-a11y/heading-has-content": "error",
792
+ "jsx-a11y/html-has-lang": "error",
793
+ "jsx-a11y/iframe-has-title": "error",
794
+ "jsx-a11y/img-redundant-alt": {
795
+ severity: "off",
796
+ 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'
797
+ },
798
+ "jsx-a11y/interactive-supports-focus": [
799
+ "error",
800
+ {
801
+ tabbable: [
802
+ "button",
803
+ "checkbox",
804
+ "link",
805
+ "progressbar",
806
+ "searchbox",
807
+ "slider",
808
+ "spinbutton",
809
+ "switch",
810
+ "textbox"
811
+ ]
812
+ }
813
+ ],
814
+ "jsx-a11y/label-has-associated-control": "error",
815
+ "jsx-a11y/media-has-caption": "error",
816
+ "jsx-a11y/mouse-events-have-key-events": "error",
817
+ "jsx-a11y/no-access-key": "error",
818
+ "jsx-a11y/no-autofocus": "error",
819
+ "jsx-a11y/no-distracting-elements": "error",
820
+ "jsx-a11y/no-interactive-element-to-noninteractive-role": "error",
821
+ "jsx-a11y/no-noninteractive-element-interactions": [
822
+ "error",
823
+ {
824
+ body: ["onError", "onLoad"],
825
+ iframe: ["onError", "onLoad"],
826
+ img: ["onError", "onLoad"]
827
+ }
828
+ ],
829
+ "jsx-a11y/no-noninteractive-element-to-interactive-role": "error",
830
+ "jsx-a11y/no-noninteractive-tabindex": "error",
831
+ "jsx-a11y/no-redundant-roles": "error",
832
+ "jsx-a11y/no-static-element-interactions": "error",
833
+ "jsx-a11y/role-has-required-aria-props": "error",
834
+ "jsx-a11y/role-supports-aria-props": "error",
835
+ "jsx-a11y/scope": "error",
836
+ "jsx-a11y/tabindex-no-positive": "error",
837
+ "no-async-promise-executor": "error",
838
+ "no-barrel-files/no-barrel-files": {
839
+ severity: "warn",
840
+ reason: "the modules already ran this at warn (confirmed in the host-admin proof-of-concept config); nest disables it outright as deliberate architecture, react keeps it visible"
841
+ },
842
+ "no-case-declarations": "error",
843
+ "no-compare-neg-zero": "error",
844
+ "no-cond-assign": ["error", "except-parens"],
845
+ "no-console": {
846
+ severity: "off",
847
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
848
+ },
849
+ "no-constant-binary-expression": "error",
850
+ "no-constant-condition": [
851
+ "error",
852
+ {
853
+ checkLoops: "allExceptWhileTrue"
854
+ }
855
+ ],
856
+ "no-control-regex": "error",
857
+ "no-debugger": "error",
858
+ "no-delete-var": "error",
859
+ "no-dupe-else-if": "error",
860
+ "no-duplicate-case": "error",
861
+ "no-empty": [
862
+ "error",
863
+ {
864
+ allowEmptyCatch: false
865
+ }
866
+ ],
867
+ "no-empty-character-class": "error",
868
+ "no-empty-pattern": [
869
+ "error",
870
+ {
871
+ allowObjectPatternsAsParameters: false
872
+ }
873
+ ],
874
+ "no-empty-static-block": "error",
875
+ "no-ex-assign": "error",
876
+ "no-extra-boolean-cast": ["error", {}],
877
+ "no-fallthrough": [
878
+ "error",
879
+ {
880
+ allowEmptyCase: false,
881
+ reportUnusedFallthroughComment: false
882
+ }
883
+ ],
884
+ "no-global-assign": [
885
+ "error",
886
+ {
887
+ exceptions: []
888
+ }
889
+ ],
890
+ "no-invalid-regexp": ["error", {}],
891
+ "no-irregular-whitespace": [
892
+ "error",
893
+ {
894
+ skipComments: false,
895
+ skipJSXText: false,
896
+ skipRegExps: false,
897
+ skipStrings: true,
898
+ skipTemplates: false
899
+ }
900
+ ],
901
+ "no-loss-of-precision": "error",
902
+ "no-misleading-character-class": "error",
903
+ "no-nonoctal-decimal-escape": "error",
904
+ "no-prototype-builtins": "error",
905
+ "no-regex-spaces": "error",
906
+ "no-restricted-imports": [
907
+ "error",
908
+ {
909
+ patterns: [
910
+ {
911
+ group: ["next/*"],
912
+ message: "Importing from next/* is not allowed in the admin project."
913
+ },
914
+ {
915
+ group: ["storybook/*"],
916
+ message: "Importing from storybook/* is not allowed in the admin project."
917
+ },
918
+ {
919
+ group: ["@nestjs/*"],
920
+ message: "Importing from @nestjs/* is not allowed in the admin project."
921
+ },
922
+ {
923
+ group: ["next-i18next"],
924
+ message: "Importing from next-i18next is not allowed in the admin project."
925
+ },
926
+ {
927
+ group: ["@hublo/style/colors", "@hublo/style/colors/*"],
928
+ message: "Importing runtime colors directly is not allowed. Use the MUI theme, theme-derived CSS variables, or an explicit product-specific mapping."
929
+ }
930
+ ]
931
+ }
932
+ ],
933
+ "no-self-assign": [
934
+ "error",
935
+ {
936
+ props: true
937
+ }
938
+ ],
939
+ "no-shadow-restricted-names": [
940
+ "error",
941
+ {
942
+ reportGlobalThis: false
943
+ }
944
+ ],
945
+ "no-sparse-arrays": "error",
946
+ "no-unexpected-multiline": "error",
947
+ "no-unsafe-finally": "error",
948
+ "no-unsafe-optional-chaining": {
949
+ severity: "off",
950
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
951
+ },
952
+ "no-unused-labels": "error",
953
+ "no-unused-private-class-members": "error",
954
+ "no-useless-backreference": "error",
955
+ "no-useless-catch": "error",
956
+ "no-useless-escape": [
957
+ "error",
958
+ {
959
+ allowRegexCharacters: []
960
+ }
961
+ ],
962
+ "nx/enforce-module-boundaries": [
963
+ "error",
964
+ {
965
+ enforceBuildableLibDependency: true,
966
+ allow: [],
967
+ depConstraints: [
968
+ {
969
+ sourceTag: "*",
970
+ onlyDependOnLibsWithTags: ["*"]
971
+ }
972
+ ]
973
+ }
974
+ ],
975
+ "react-hooks/exhaustive-deps": {
976
+ severity: "warn",
977
+ reason: "the modules already ran this at warn (confirmed in the host-admin proof-of-concept config); its suggested fix can change behaviour, so it reports and a human decides"
978
+ },
979
+ "react-hooks/rules-of-hooks": "error",
980
+ "react-refresh/only-export-components": {
981
+ severity: "warn",
982
+ reason: "the modules already ran this at warn (confirmed in the host-admin proof-of-concept config); it guards fast-refresh ergonomics, not correctness",
983
+ options: [
984
+ {
985
+ allowConstantExport: true
986
+ }
987
+ ]
988
+ },
989
+ "react/jsx-curly-brace-presence": {
990
+ severity: "off",
991
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
992
+ },
993
+ "require-yield": "error",
994
+ "styled-components-a11y/alt-text": "error",
995
+ "styled-components-a11y/anchor-has-content": "error",
996
+ "styled-components-a11y/anchor-is-valid": "error",
997
+ "styled-components-a11y/aria-activedescendant-has-tabindex": "error",
998
+ "styled-components-a11y/aria-props": "error",
999
+ "styled-components-a11y/aria-proptypes": "error",
1000
+ "styled-components-a11y/aria-role": "error",
1001
+ "styled-components-a11y/aria-unsupported-elements": "error",
1002
+ "styled-components-a11y/autocomplete-valid": "error",
1003
+ "styled-components-a11y/click-events-have-key-events": "error",
1004
+ "styled-components-a11y/control-has-associated-label": {
1005
+ severity: "off",
1006
+ reason: "off in the host-admin proof-of-concept config, with its element and role ignore lists; it fires on composed styled components where the label lives in a wrapper the rule cannot see",
1007
+ options: [
1008
+ {
1009
+ ignoreElements: ["audio", "canvas", "embed", "input", "textarea", "tr", "video"],
1010
+ ignoreRoles: [
1011
+ "grid",
1012
+ "listbox",
1013
+ "menu",
1014
+ "menubar",
1015
+ "radiogroup",
1016
+ "row",
1017
+ "tablist",
1018
+ "toolbar",
1019
+ "tree",
1020
+ "treegrid"
1021
+ ],
1022
+ includeRoles: ["alert", "dialog"]
1023
+ }
1024
+ ]
1025
+ },
1026
+ "styled-components-a11y/heading-has-content": "error",
1027
+ "styled-components-a11y/html-has-lang": "error",
1028
+ "styled-components-a11y/iframe-has-title": "error",
1029
+ "styled-components-a11y/img-redundant-alt": "error",
1030
+ "styled-components-a11y/interactive-supports-focus": [
1031
+ "error",
1032
+ {
1033
+ tabbable: [
1034
+ "button",
1035
+ "checkbox",
1036
+ "link",
1037
+ "progressbar",
1038
+ "searchbox",
1039
+ "slider",
1040
+ "spinbutton",
1041
+ "switch",
1042
+ "textbox"
1043
+ ]
1044
+ }
1045
+ ],
1046
+ "styled-components-a11y/label-has-associated-control": "error",
1047
+ "styled-components-a11y/label-has-for": {
1048
+ severity: "off",
1049
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
1050
+ },
1051
+ "styled-components-a11y/media-has-caption": "error",
1052
+ "styled-components-a11y/mouse-events-have-key-events": "error",
1053
+ "styled-components-a11y/no-access-key": "error",
1054
+ "styled-components-a11y/no-autofocus": "error",
1055
+ "styled-components-a11y/no-distracting-elements": "error",
1056
+ "styled-components-a11y/no-interactive-element-to-noninteractive-role": "error",
1057
+ "styled-components-a11y/no-noninteractive-element-interactions": [
1058
+ "error",
1059
+ {
1060
+ body: ["onError", "onLoad"],
1061
+ iframe: ["onError", "onLoad"],
1062
+ img: ["onError", "onLoad"]
1063
+ }
1064
+ ],
1065
+ "styled-components-a11y/no-noninteractive-element-to-interactive-role": "error",
1066
+ "styled-components-a11y/no-noninteractive-tabindex": "error",
1067
+ "styled-components-a11y/no-redundant-roles": "error",
1068
+ "styled-components-a11y/no-static-element-interactions": "error",
1069
+ "styled-components-a11y/role-has-required-aria-props": "error",
1070
+ "styled-components-a11y/role-supports-aria-props": "error",
1071
+ "styled-components-a11y/scope": "error",
1072
+ "styled-components-a11y/tabindex-no-positive": "error",
1073
+ "typescript/await-thenable": "error",
1074
+ "typescript/no-array-delete": "error",
1075
+ "typescript/no-base-to-string": "error",
1076
+ "typescript/no-duplicate-type-constituents": "error",
1077
+ "typescript/no-empty-function": {
1078
+ severity: "off",
1079
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
1080
+ },
1081
+ "typescript/no-explicit-any": {
1082
+ severity: "off",
1083
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
1084
+ },
1085
+ "typescript/no-floating-promises": [
1086
+ "error",
1087
+ {
1088
+ ignoreVoid: true
1089
+ }
1090
+ ],
1091
+ "typescript/no-for-in-array": "error",
1092
+ "typescript/no-implied-eval": "error",
1093
+ "typescript/no-inferrable-types": "error",
1094
+ "typescript/no-misused-promises": [
1095
+ "error",
1096
+ {
1097
+ checksVoidReturn: {
1098
+ attributes: false
1099
+ }
1100
+ }
1101
+ ],
1102
+ "typescript/no-non-null-assertion": {
1103
+ severity: "warn",
1104
+ reason: "kept at the severity the proof of concept measured; no module config set it, so raising it to error would be a NEW enforcement decision rather than a like-for-like migration, and it needs its own count of violations first"
1105
+ },
1106
+ "typescript/no-redundant-type-constituents": "error",
1107
+ "typescript/no-require-imports": "error",
1108
+ "typescript/no-unnecessary-type-assertion": {
1109
+ severity: "off",
1110
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
1111
+ },
1112
+ "typescript/no-unsafe-argument": {
1113
+ severity: "off",
1114
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
1115
+ },
1116
+ "typescript/no-unsafe-assignment": {
1117
+ severity: "off",
1118
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
1119
+ },
1120
+ "typescript/no-unsafe-call": {
1121
+ severity: "off",
1122
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
1123
+ },
1124
+ "typescript/no-unsafe-enum-comparison": {
1125
+ severity: "off",
1126
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
1127
+ },
1128
+ "typescript/no-unsafe-member-access": {
1129
+ severity: "off",
1130
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
1131
+ },
1132
+ "typescript/no-unsafe-return": {
1133
+ severity: "off",
1134
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
1135
+ },
1136
+ "typescript/no-unsafe-unary-minus": "error",
1137
+ "typescript/no-unused-vars": {
1138
+ severity: "off",
1139
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
1140
+ },
1141
+ "typescript/only-throw-error": {
1142
+ severity: "off",
1143
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
1144
+ },
1145
+ "typescript/prefer-promise-reject-errors": "error",
1146
+ "typescript/require-await": {
1147
+ severity: "off",
1148
+ reason: "pre-existing debt measured during the proof of concept; parked so adoption stays non-breaking"
1149
+ },
1150
+ "typescript/restrict-plus-operands": "error",
1151
+ "typescript/restrict-template-expressions": "error",
1152
+ "typescript/return-await": {
1153
+ severity: "warn",
1154
+ reason: "replaces the deprecated core `no-return-await`, which both modules set to error but which was never actually reporting: the nx lint target expanded to 2 files of 2888. The `never` option matches what no-return-await meant, so this is like-for-like, but it now runs over every file and finds real violations (9 on bff-admin, 5 on host-admin, measured with --type-aware). Held at warn so adoption does not turn a green module red, and raised to error once they are fixed. Switching to the stricter `in-try-catch`, which typescript-eslint recommends because `return await` inside try IS needed for correct error handling, is a separate decision with its own count.",
1155
+ options: ["never"]
1156
+ },
1157
+ "typescript/switch-exhaustiveness-check": {
1158
+ severity: "warn",
1159
+ reason: "kept at the severity the proof of concept measured; no module config set it, so raising it to error would be a NEW enforcement decision rather than a like-for-like migration, and it needs its own count of violations first"
1160
+ },
1161
+ "typescript/unbound-method": "error",
1162
+ "use-isnan": [
1163
+ "error",
1164
+ {
1165
+ enforceForIndexOf: false,
1166
+ enforceForSwitchCase: true
1167
+ }
1168
+ ],
1169
+ "valid-typeof": [
1170
+ "error",
1171
+ {
1172
+ requireStringLiterals: false
1173
+ }
1174
+ ]
1175
+ },
1176
+ uncovered: {
1177
+ "no-octal": "no oxlint equivalent",
1178
+ 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."
1179
+ }
1180
+ };
1181
+
1182
+ // src/roles/lint/presets/svelte.json
1183
+ var svelte_default = {
1184
+ plugins: ["typescript", "oxc"],
1185
+ jsPlugins: [],
1186
+ settings: null,
1187
+ rules: {
1188
+ "constructor-super": "error",
1189
+ "for-direction": "error",
1190
+ "getter-return": [
1191
+ "error",
1192
+ {
1193
+ allowImplicit: false
1194
+ }
1195
+ ],
1196
+ "no-async-promise-executor": "error",
1197
+ "no-case-declarations": "error",
1198
+ "no-class-assign": "error",
1199
+ "no-compare-neg-zero": "error",
1200
+ "no-cond-assign": ["error", "except-parens"],
1201
+ "no-const-assign": "error",
1202
+ "no-constant-binary-expression": "error",
1203
+ "no-constant-condition": [
1204
+ "error",
1205
+ {
1206
+ checkLoops: "allExceptWhileTrue"
1207
+ }
1208
+ ],
1209
+ "no-control-regex": "error",
1210
+ "no-debugger": "error",
1211
+ "no-delete-var": "error",
1212
+ "no-dupe-class-members": "error",
1213
+ "no-dupe-else-if": "error",
1214
+ "no-dupe-keys": "error",
1215
+ "no-duplicate-case": "error",
1216
+ "no-empty": [
1217
+ "error",
1218
+ {
1219
+ allowEmptyCatch: false
1220
+ }
1221
+ ],
1222
+ "no-empty-character-class": "error",
1223
+ "no-empty-pattern": [
1224
+ "error",
1225
+ {
1226
+ allowObjectPatternsAsParameters: false
1227
+ }
1228
+ ],
1229
+ "no-empty-static-block": "error",
1230
+ "no-ex-assign": "error",
1231
+ "no-extra-boolean-cast": ["error", {}],
1232
+ "no-fallthrough": [
1233
+ "error",
1234
+ {
1235
+ allowEmptyCase: false,
1236
+ reportUnusedFallthroughComment: false
1237
+ }
1238
+ ],
1239
+ "no-func-assign": "error",
1240
+ "no-global-assign": [
1241
+ "error",
1242
+ {
1243
+ exceptions: []
1244
+ }
1245
+ ],
1246
+ "no-import-assign": "error",
1247
+ "no-invalid-regexp": ["error", {}],
1248
+ "no-irregular-whitespace": [
1249
+ "error",
1250
+ {
1251
+ skipComments: false,
1252
+ skipJSXText: false,
1253
+ skipRegExps: false,
1254
+ skipStrings: true,
1255
+ skipTemplates: false
1256
+ }
1257
+ ],
1258
+ "no-loss-of-precision": "error",
1259
+ "no-misleading-character-class": "error",
1260
+ "no-new-native-nonconstructor": "error",
1261
+ "no-nonoctal-decimal-escape": "error",
1262
+ "no-obj-calls": "error",
1263
+ "no-prototype-builtins": "error",
1264
+ "no-redeclare": [
1265
+ "error",
1266
+ {
1267
+ builtinGlobals: true
1268
+ }
1269
+ ],
1270
+ "no-regex-spaces": "error",
1271
+ "no-self-assign": [
1272
+ "error",
1273
+ {
1274
+ props: true
1275
+ }
1276
+ ],
1277
+ "no-setter-return": "error",
1278
+ "no-shadow-restricted-names": [
1279
+ "error",
1280
+ {
1281
+ reportGlobalThis: false
1282
+ }
1283
+ ],
1284
+ "no-sparse-arrays": "error",
1285
+ "no-this-before-super": "error",
1286
+ "no-undef": {
1287
+ severity: "off",
1288
+ reason: "unusable under oxlint for Svelte, and not for want of configuration. Svelte 5's runes ($state, $props, $derived, $effect) are compiler globals, and `$store` auto-subscription invents an identifier per store ($_, $page, $currentAdmin, \u2026), so the names cannot be enumerated. eslint-plugin-svelte's PARSER supplies both; oxlint has no Svelte parser, so it reported 1641 undefined identifiers on hr-management, none of them real. TypeScript and svelte-check already report genuinely undefined identifiers, which is why react and nest never enabled this rule either."
1289
+ },
1290
+ "no-unreachable": "error",
1291
+ "no-unsafe-finally": "error",
1292
+ "no-unsafe-negation": [
1293
+ "error",
1294
+ {
1295
+ enforceForOrderingRelations: false
1296
+ }
1297
+ ],
1298
+ "no-unsafe-optional-chaining": [
1299
+ "error",
1300
+ {
1301
+ disallowArithmeticOperators: false
1302
+ }
1303
+ ],
1304
+ "no-unused-labels": "error",
1305
+ "no-unused-private-class-members": "error",
1306
+ "no-useless-backreference": "error",
1307
+ "no-useless-catch": "error",
1308
+ "no-useless-escape": [
1309
+ "error",
1310
+ {
1311
+ allowRegexCharacters: []
1312
+ }
1313
+ ],
1314
+ "no-with": "error",
1315
+ "prefer-rest-params": {
1316
+ severity: "warn",
1317
+ reason: "error in the module's eslint config, but never actually reported: `eslint src/lib/analytics/useAnalytics.js` exits clean today while oxlint finds 3 real violations in it. Held at warn so adoption does not turn a green module red, and restored to error once they are fixed. Not auto-fixable, verified with --fix."
1318
+ },
1319
+ "prefer-spread": {
1320
+ severity: "warn",
1321
+ reason: "error in the module's eslint config, but never actually reported: `eslint src/lib/analytics/useAnalytics.js` exits clean today while oxlint finds 3 real violations in it. Held at warn so adoption does not turn a green module red, and restored to error once they are fixed. Not auto-fixable, verified with --fix."
1322
+ },
1323
+ "require-yield": "error",
1324
+ "typescript/no-explicit-any": {
1325
+ severity: "warn",
1326
+ reason: "libs/front/ui enforces it and apps/front/hr-management turns it off; enforcing it would newly fail hr-management, so it reports for both instead of breaking one"
1327
+ },
1328
+ "typescript/no-require-imports": "error",
1329
+ "typescript/no-unused-vars": {
1330
+ severity: "warn",
1331
+ reason: "the two modules disagree: libs/front/ui runs it at error with no ignore patterns, apps/front/hr-management at warn with the ^_ patterns kept here (both read from eslint --print-config). Holding it at error would newly fail hr-management, so it reports for both and keeps the patterns hr-management already relies on",
1332
+ options: [
1333
+ {
1334
+ varsIgnorePattern: "^_",
1335
+ argsIgnorePattern: "^_",
1336
+ destructuredArrayIgnorePattern: "^_"
1337
+ }
1338
+ ]
1339
+ },
1340
+ "use-isnan": [
1341
+ "error",
1342
+ {
1343
+ enforceForIndexOf: false,
1344
+ enforceForSwitchCase: true
1345
+ }
1346
+ ],
1347
+ "valid-typeof": [
1348
+ "error",
1349
+ {
1350
+ requireStringLiterals: false
1351
+ }
1352
+ ]
1353
+ },
1354
+ uncovered: {
1355
+ "svelte/comment-directive": "oxlint cannot parse Svelte templates, so the eslint-disable comments inside markup are neither read nor honoured",
1356
+ "svelte/system": "the plugin internal rule that reports Svelte parse errors; it needs the Svelte parser",
1357
+ "svelte/no-at-debug-tags": "reads {@debug} in the template",
1358
+ "svelte/no-at-html-tags": "reads {@html} in the template, the XSS guard; the highest-value loss in this list",
1359
+ "svelte/no-dupe-else-if-blocks": "reads {#if}/{:else if} chains in the template",
1360
+ "svelte/no-dupe-style-properties": "reads the style block",
1361
+ "svelte/no-dynamic-slot-name": "reads <slot> in the template",
1362
+ "svelte/no-inner-declarations": "the Svelte-aware variant; the core rule it shadows is not a substitute inside markup",
1363
+ "svelte/no-not-function-handler": "reads on:event handlers in the template",
1364
+ "svelte/no-object-in-text-mustaches": "reads {expression} interpolation in the template",
1365
+ "svelte/no-shorthand-style-property-overrides": "reads the style block",
1366
+ "svelte/no-unknown-style-directive-property": "reads style: directives in the template",
1367
+ "svelte/no-unused-svelte-ignore": "reads svelte-ignore comments in the template",
1368
+ "svelte/valid-compile": "runs the Svelte compiler and reports its warnings",
1369
+ "no-dupe-args": "no oxlint equivalent (TypeScript rejects duplicate parameter names anyway)",
1370
+ "no-octal": "no oxlint equivalent"
1371
+ }
1372
+ };
1373
+
1374
+ // src/roles/lint/preset-data.ts
1375
+ var BASE_RULES = base_default.rules;
1376
+ var PRESET_FILES = {
1377
+ nest: nest_default,
1378
+ node: node_default,
1379
+ react: react_default,
1380
+ svelte: svelte_default
1381
+ };
1382
+ var LINT_PRESETS = Object.keys(PRESET_FILES);
1383
+ function hasLintPreset(preset) {
1384
+ return preset in PRESET_FILES;
1385
+ }
1386
+ function presetFile(preset) {
1387
+ return PRESET_FILES[preset] ?? node_default;
1388
+ }
1389
+ function baseRules() {
1390
+ return BASE_RULES;
1391
+ }
1392
+ function presetRules(preset) {
1393
+ return presetFile(preset).rules;
1394
+ }
1395
+
1396
+ // src/roles/lint/rule-data.ts
1397
+ var baseRules2 = baseRules();
1398
+ function isDecision(value) {
1399
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1400
+ }
1401
+ function severityOf(value) {
1402
+ if (isDecision(value)) return value.severity;
1403
+ if (Array.isArray(value)) return String(value[0]);
1404
+ return value;
1405
+ }
1406
+ function validate(preset, rules) {
1407
+ for (const [rule, value] of Object.entries(rules)) {
1408
+ const severity = severityOf(value);
1409
+ if (!["off", "warn", "error"].includes(severity)) {
1410
+ throw new Error(
1411
+ `${preset}: rule "${rule}" has severity "${severity}"; expected off, warn or error`
1412
+ );
1413
+ }
1414
+ if (severity === "error") continue;
1415
+ const reason = isDecision(value) ? value.reason.trim() : "";
1416
+ if (reason === "") {
1417
+ throw new Error(
1418
+ `${preset}: rule "${rule}" is set to "${severity}" without a reason. Every rule that is not enforced must say why, so --inspect can tell a developer what is not running. Use { "severity": "${severity}", "reason": "..." }.`
1419
+ );
1420
+ }
1421
+ }
1422
+ }
1423
+ function layered(preset) {
1424
+ const base = baseRules();
1425
+ const layer = presetRules(preset);
1426
+ validate("base", base);
1427
+ validate(preset, layer);
1428
+ return { ...base, ...layer };
1429
+ }
1430
+ function rulesFor(preset) {
1431
+ const entries = Object.entries(layered(preset)).map(([rule, value]) => {
1432
+ if (!isDecision(value)) return [rule, value];
1433
+ const options = value.options ?? [];
1434
+ return [rule, options.length > 0 ? [value.severity, ...options] : value.severity];
1435
+ });
1436
+ return Object.fromEntries(entries);
1437
+ }
1438
+ function disabledFor(preset) {
1439
+ return decisionsAt(preset, "off");
1440
+ }
1441
+ function downgradedFor(preset) {
1442
+ return decisionsAt(preset, "warn");
1443
+ }
1444
+ function decisionsAt(preset, severity) {
1445
+ return Object.entries(layered(preset)).filter(([, value]) => isDecision(value) && value.severity === severity).map(([rule, value]) => ({ rule, reason: value.reason })).sort((left, right) => left.rule.localeCompare(right.rule));
1446
+ }
1447
+
1448
+ // src/roles/lint/disabled-rules.ts
1449
+ function disabledRulesFor(preset) {
1450
+ return disabledFor(preset);
1451
+ }
1452
+
1453
+ // src/roles/lint/downgraded-rules.ts
1454
+ function downgradedRulesFor(preset) {
1455
+ return downgradedFor(preset);
1456
+ }
1457
+
1458
+ // src/roles/lint/presets.ts
1459
+ function lintPresetFor(preset) {
1460
+ const file = presetFile(preset);
1461
+ const resolved = LINT_PRESETS.includes(preset) ? preset : "node";
1462
+ return {
1463
+ plugins: file.plugins,
1464
+ jsPlugins: file.jsPlugins,
1465
+ ...file.settings === null ? {} : { settings: file.settings },
1466
+ rules: rulesFor(resolved)
1467
+ };
1468
+ }
1469
+
1470
+ // src/roles/lint/rename-suppressions.ts
1471
+ import { readdirSync, readFileSync as readFileSync2, statSync } from "fs";
1472
+ import { join as join2, relative } from "path";
1473
+ var SOURCE_EXTENSIONS = [
1474
+ ".ts",
1475
+ ".tsx",
1476
+ ".js",
1477
+ ".jsx",
1478
+ ".mjs",
1479
+ ".cjs",
1480
+ ".mts",
1481
+ ".cts",
1482
+ ".svelte",
1483
+ ".vue"
1484
+ ];
1485
+ var SKIP_DIRECTORIES = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "coverage", ".git", ".nx"]);
1486
+ var SUPPRESSION = /(eslint-disable(?:-next-line|-line)?)\s+([^\n*]+?)(?:\s*--.*)?(?:\s*\*\/)?\s*$/;
1487
+ function renamedPrefixes(jsPlugins, declared = {}) {
1488
+ const renames = /* @__PURE__ */ new Map();
1489
+ for (const plugin of jsPlugins) {
1490
+ if (typeof plugin === "string") continue;
1491
+ const original = plugin.specifier.replace(/^eslint-plugin-/, "").replace(/\/eslint-plugin$/, "");
1492
+ if (original !== plugin.name) renames.set(`${original}/`, `${plugin.name}/`);
1493
+ }
1494
+ const slash = (value) => value.endsWith("/") ? value : `${value}/`;
1495
+ for (const [from, to] of Object.entries(declared)) renames.set(slash(from), slash(to));
1496
+ return collapseChains(renames);
1497
+ }
1498
+ function collapseChains(renames) {
1499
+ const collapsed = /* @__PURE__ */ new Map();
1500
+ for (const [from, to] of renames) {
1501
+ let target = to;
1502
+ for (let hop = 0; hop < renames.size; hop += 1) {
1503
+ const next = renames.get(target);
1504
+ if (next === void 0 || next === target) break;
1505
+ target = next;
1506
+ }
1507
+ collapsed.set(from, target);
1508
+ }
1509
+ return collapsed;
1510
+ }
1511
+ function* sourceFiles(dir) {
1512
+ let entries;
1513
+ try {
1514
+ entries = readdirSync(dir);
1515
+ } catch {
1516
+ return;
1517
+ }
1518
+ for (const entry of entries) {
1519
+ const full = join2(dir, entry);
1520
+ let isDirectory;
1521
+ try {
1522
+ isDirectory = statSync(full).isDirectory();
1523
+ } catch {
1524
+ continue;
1525
+ }
1526
+ if (isDirectory) {
1527
+ if (!SKIP_DIRECTORIES.has(entry)) yield* sourceFiles(full);
1528
+ } else if (SOURCE_EXTENSIONS.some((extension) => entry.endsWith(extension))) {
1529
+ yield full;
1530
+ }
1531
+ }
1532
+ }
1533
+ function findSuppressionRenames(cwd, renames) {
1534
+ if (renames.size === 0) return [];
1535
+ const found = [];
1536
+ for (const file of sourceFiles(cwd)) {
1537
+ let content;
1538
+ try {
1539
+ content = readFileSync2(file, "utf8");
1540
+ } catch {
1541
+ continue;
1542
+ }
1543
+ if (!content.includes("eslint-disable")) continue;
1544
+ content.split("\n").forEach((line, index) => {
1545
+ const match = SUPPRESSION.exec(line);
1546
+ const rules = match?.[2];
1547
+ if (!rules) return;
1548
+ const names = rules.split(",").map((entry) => entry.trim());
1549
+ const renamed = [];
1550
+ const hits = [];
1551
+ for (const name of names) {
1552
+ const match2 = [...renames].find(([oldPrefix2]) => name.startsWith(oldPrefix2));
1553
+ if (!match2) {
1554
+ renamed.push(name);
1555
+ continue;
1556
+ }
1557
+ const [oldPrefix, newPrefix] = match2;
1558
+ hits.push(name);
1559
+ renamed.push(newPrefix + name.slice(oldPrefix.length));
1560
+ }
1561
+ if (hits.length === 0) return;
1562
+ found.push({
1563
+ file: relative(cwd, file),
1564
+ line: index + 1,
1565
+ from: line,
1566
+ to: line.replace(rules, renamed.join(", ")),
1567
+ rule: hits.join(", ")
1568
+ });
1569
+ });
1570
+ }
1571
+ return found;
1572
+ }
1573
+
1574
+ // src/roles/lint/lint-script.ts
1575
+ var SENTINEL_LINT_COMMAND = "sentinel --run --lint";
1576
+ var SEGMENT_SEPARATOR = /(\s*(?:&&|\|\||;|&|\|)\s*)/;
1577
+ function isSeparatorAt(index) {
1578
+ return index % 2 === 1;
1579
+ }
1580
+ function isEslintSegment(segment) {
1581
+ return /(^|[\s/])eslint(\s|$)/.test(segment.trim());
1582
+ }
1583
+ function composeLintScript(existing) {
1584
+ if (!existing || existing.trim() === "") return SENTINEL_LINT_COMMAND;
1585
+ const parts = existing.split(SEGMENT_SEPARATOR);
1586
+ const commands = parts.filter((_, index) => !isSeparatorAt(index));
1587
+ if (!commands.some(isEslintSegment)) return existing;
1588
+ let replacedOnce = false;
1589
+ const rebuilt = parts.map((part, index) => {
1590
+ if (isSeparatorAt(index) || !isEslintSegment(part)) return part;
1591
+ if (replacedOnce) return null;
1592
+ replacedOnce = true;
1593
+ return SENTINEL_LINT_COMMAND;
1594
+ });
1595
+ const kept = [];
1596
+ for (let index = 0; index < rebuilt.length; index += 1) {
1597
+ const part = rebuilt[index];
1598
+ if (part === null) {
1599
+ if (kept.length > 0) kept.pop();
1600
+ continue;
1601
+ }
1602
+ kept.push(part);
1603
+ }
1604
+ return kept.join("").trim();
1605
+ }
1606
+ function keepsOtherCommands(script) {
1607
+ return script.split(SEGMENT_SEPARATOR).filter((_, index) => !isSeparatorAt(index)).some((segment) => segment.trim() !== "" && segment.trim() !== SENTINEL_LINT_COMMAND);
1608
+ }
1609
+
1610
+ // src/roles/lint/eslint-ignores.ts
1611
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
1612
+ import { join as join3 } from "path";
1613
+ var IGNORE_BLOCKS = [/\bignores\s*:\s*\[([^\]]*)\]/g, /\bglobalIgnores\s*\(\s*\[([^\]]*)\]/g];
1614
+ var STRING_LITERAL = /['"`]([^'"`]+)['"`]/g;
1615
+ var OPAQUE_SOURCE = /\b(includeIgnoreFile)\s*\([^)]*\)/g;
1616
+ function toOxlintPattern(pattern) {
1617
+ return pattern.replace(/^\.\//, "");
1618
+ }
1619
+ function readEslintIgnores(cwd) {
1620
+ const config = ESLINT_CONFIG_FILES.map((name) => join3(cwd, name)).find((path) => existsSync2(path));
1621
+ if (!config) return { patterns: [], unresolved: [] };
1622
+ let source;
1623
+ try {
1624
+ source = readFileSync3(config, "utf8");
1625
+ } catch {
1626
+ return { patterns: [], unresolved: [] };
1627
+ }
1628
+ const patterns = /* @__PURE__ */ new Set();
1629
+ for (const block of IGNORE_BLOCKS) {
1630
+ for (const [, body] of source.matchAll(block)) {
1631
+ for (const [, literal] of (body ?? "").matchAll(STRING_LITERAL)) {
1632
+ if (literal) patterns.add(toOxlintPattern(literal));
1633
+ }
1634
+ }
1635
+ }
1636
+ const unresolved = [...source.matchAll(OPAQUE_SOURCE)].map((match) => match[0]);
1637
+ return { patterns: [...patterns], unresolved: [...new Set(unresolved)] };
1638
+ }
1639
+
1640
+ // src/core/config/read-adoption.ts
1641
+ import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
1642
+ import { join as join5 } from "path";
1643
+
1644
+ // src/shared/jsonc.ts
1645
+ import { parse, printParseErrorCode } from "jsonc-parser";
1646
+ function parseJsonc(text, source = "config") {
1647
+ const errors = [];
1648
+ const value = parse(text, errors, { allowTrailingComma: true });
1649
+ if (errors.length > 0) {
1650
+ const details = errors.map((error) => printParseErrorCode(error.error)).join(", ");
1651
+ throw new Error(`${source}: malformed JSONC (${details}).`);
1652
+ }
1653
+ return value;
1654
+ }
1655
+
1656
+ // src/core/config/owned-keys.ts
1657
+ function presetOwnedKeys(config, permitted) {
1658
+ if (!config) return [];
1659
+ return Object.keys(config).filter((key) => !permitted.includes(key));
1660
+ }
1661
+
1662
+ // src/core/config/resolve-config-target.ts
1663
+ import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
1664
+ import { join as join4 } from "path";
1665
+ function readExtends(absolutePath) {
1666
+ let parsed;
1667
+ try {
1668
+ parsed = parseJsonc(readFileSync4(absolutePath, "utf8"), absolutePath);
1669
+ } catch {
1670
+ return [];
1671
+ }
1672
+ if (typeof parsed.extends === "string") return [parsed.extends];
1673
+ if (Array.isArray(parsed.extends)) {
1674
+ return parsed.extends.filter((entry) => typeof entry === "string");
1675
+ }
1676
+ return [];
1677
+ }
1678
+ function resolveConfigTarget(moduleDir, { candidates, markers, fallback }) {
1679
+ let existing;
1680
+ let existingExtendsSomething = false;
1681
+ for (const candidate of candidates) {
1682
+ const absolutePath = join4(moduleDir, candidate);
1683
+ if (!existsSync3(absolutePath)) continue;
1684
+ const chain = readExtends(absolutePath);
1685
+ if (existing === void 0) {
1686
+ existing = candidate;
1687
+ existingExtendsSomething = chain.length > 0;
1688
+ }
1689
+ const extendsBase = chain.some((value) => markers.some((marker) => value.includes(marker)));
1690
+ if (extendsBase) return { path: candidate, reason: "extends-base" };
1691
+ }
1692
+ if (existing) {
1693
+ return { path: existing, reason: existingExtendsSomething ? "other-chain" : "no-extends" };
1694
+ }
1695
+ return { path: fallback, reason: "none" };
1696
+ }
1697
+
1698
+ // src/core/config/read-adoption.ts
1699
+ function normaliseExtends(value) {
1700
+ if (typeof value === "string") return [value];
1701
+ if (Array.isArray(value)) {
1702
+ return value.filter((entry) => typeof entry === "string");
1703
+ }
1704
+ return [];
1705
+ }
1706
+ var NOT_ADOPTED = (configFile) => ({
1707
+ configFile,
1708
+ preset: null,
1709
+ adopted: false,
1710
+ conformant: false,
1711
+ drift: []
1712
+ });
1713
+ function readAdoption(cwd, options) {
1714
+ const target = resolveConfigTarget(cwd, options);
1715
+ if (target.reason === "none" || !existsSync4(join5(cwd, target.path))) {
1716
+ return NOT_ADOPTED(target.reason === "none" ? null : target.path);
1717
+ }
1718
+ let parsed;
1719
+ try {
1720
+ parsed = parseJsonc(readFileSync5(join5(cwd, target.path), "utf8"), target.path);
1721
+ } catch {
1722
+ return NOT_ADOPTED(target.path);
1723
+ }
1724
+ const preset = normaliseExtends(parsed.extends).find((entry) => options.presetPattern.test(entry)) ?? null;
1725
+ if (preset === null) return NOT_ADOPTED(target.path);
1726
+ const settings = options.settingsKey === null ? parsed : parsed[options.settingsKey];
1727
+ const drift = presetOwnedKeys(settings, options.permitted);
1728
+ return { configFile: target.path, preset, adopted: true, conformant: drift.length === 0, drift };
1729
+ }
1730
+
1731
+ // src/roles/lint/read-adoption.ts
1732
+ function readLintAdoption(cwd) {
1733
+ return readAdoption(cwd, {
1734
+ candidates: [LINT_CONFIG_FILE],
1735
+ markers: ["@hublo/sentinel/oxlint/"],
1736
+ fallback: LINT_CONFIG_FILE,
1737
+ presetPattern: SENTINEL_LINT_PRESET,
1738
+ // Drift for lint is any TOP-LEVEL key beyond `extends`, so the settings the shared
1739
+ // reader inspects are the config's own root rather than a nested options object.
1740
+ settingsKey: null,
1741
+ permitted: PERMITTED_LOCAL_KEYS
1742
+ });
1743
+ }
1744
+
1745
+ // src/roles/lint/render-config.ts
1746
+ var DEFAULT_IGNORE_PATTERNS = [
1747
+ "**/dist/**",
1748
+ "**/node_modules/**",
1749
+ "**/coverage/**",
1750
+ "**/*.mock.ts"
1751
+ ];
1752
+
1753
+ // src/roles/lint/resolve-oxlint.ts
1754
+ import { createRequire } from "module";
1755
+ import { fileURLToPath as fileURLToPath2 } from "url";
1756
+ import { existsSync as existsSync6 } from "fs";
1757
+ import { delimiter, dirname as dirname3, join as join7 } from "path";
1758
+
1759
+ // src/shared/resolve-bin.ts
1760
+ import { existsSync as existsSync5 } from "fs";
1761
+ import { dirname as dirname2, join as join6 } from "path";
1762
+ function resolveBin(fromDir, name) {
1763
+ let dir = fromDir;
1764
+ for (; ; ) {
1765
+ const candidate = join6(dir, "node_modules", ".bin", name);
1766
+ if (existsSync5(candidate)) return candidate;
1767
+ const parent = dirname2(dir);
1768
+ if (parent === dir) return void 0;
1769
+ dir = parent;
1770
+ }
1771
+ }
1772
+
1773
+ // src/roles/lint/resolve-oxlint.ts
1774
+ var PACKAGE_OF = {
1775
+ oxlint: "oxlint",
1776
+ tsgolint: "oxlint-tsgolint"
1777
+ };
1778
+ var require2 = createRequire(import.meta.url);
1779
+ function fromOwnInstall(name) {
1780
+ try {
1781
+ const packageName = PACKAGE_OF[name];
1782
+ const manifest = require2.resolve(`${packageName}/package.json`);
1783
+ const bin = require2(manifest).bin;
1784
+ const relative2 = typeof bin === "string" ? bin : bin?.[name];
1785
+ if (!relative2) return void 0;
1786
+ const executable = join7(dirname3(manifest), relative2);
1787
+ return existsSync6(executable) ? executable : void 0;
1788
+ } catch {
1789
+ return void 0;
1790
+ }
1791
+ }
1792
+ function resolveOxlint(cwd, name = "oxlint") {
1793
+ return resolveBin(cwd, name) ?? fromOwnInstall(name);
1794
+ }
1795
+ function canRunTypeAware(cwd) {
1796
+ return tsgolintShim(cwd) !== void 0;
1797
+ }
1798
+ function oxlintPath(cwd, env) {
1799
+ const shim = tsgolintShim(cwd);
1800
+ if (!shim) return env.PATH;
1801
+ return [dirname3(shim), env.PATH].filter(Boolean).join(delimiter);
1802
+ }
1803
+ function tsgolintShim(cwd) {
1804
+ const fromModule = resolveBin(cwd, "tsgolint");
1805
+ if (fromModule) return fromModule;
1806
+ const candidates = [];
1807
+ for (const owner of ["oxlint-tsgolint", "oxlint"]) {
1808
+ try {
1809
+ const packageDir = dirname3(require2.resolve(`${owner}/package.json`));
1810
+ candidates.push(join7(packageDir, "node_modules", ".bin", "tsgolint"));
1811
+ candidates.push(join7(packageDir, "..", ".bin", "tsgolint"));
1812
+ } catch {
1813
+ }
1814
+ }
1815
+ try {
1816
+ const ownRoot = dirname3(require2.resolve("@hublo/sentinel/package.json"));
1817
+ candidates.push(join7(ownRoot, "node_modules", ".bin", "tsgolint"));
1818
+ } catch {
1819
+ candidates.push(resolveBin(dirname3(fileURLToPath2(import.meta.url)), "tsgolint") ?? "");
1820
+ }
1821
+ return candidates.find((candidate) => candidate !== "" && existsSync6(candidate));
1822
+ }
1823
+ function oxlintSearchPath(cwd) {
1824
+ return [`${cwd}${delimiter}node_modules/.bin (and parents)`, "sentinel's own install"].join(
1825
+ " then "
1826
+ );
1827
+ }
1828
+
1829
+ // src/roles/lint/adapters/oxlint/oxlint.adapter.ts
1830
+ var MAX_FIX_PASSES = 3;
1831
+ function summariseByPlugin(rules) {
1832
+ const counts = /* @__PURE__ */ new Map();
1833
+ for (const name of deferredRuleNames(rules)) {
1834
+ const plugin = name.includes("/") ? name.split("/")[0] ?? name : "core";
1835
+ counts.set(plugin, (counts.get(plugin) ?? 0) + 1);
1836
+ }
1837
+ return [...counts.entries()].sort((left, right) => right[1] - left[1]).map(([plugin, count]) => `${count} ${plugin}`).join(", ");
1838
+ }
1839
+ var OxlintAdapter = class extends BaseAdapter {
1840
+ target = "lint";
1841
+ runner = "oxlint";
1842
+ /**
1843
+ * Handles every preset, deliberately. Adapter selection happens BEFORE the engine can
1844
+ * ask `declaredPreset`, so filtering here would make an adopted module unreachable:
1845
+ * detection answers `node` for a hoisted-deps monorepo, no adapter would match, and the
1846
+ * module would report `unsupported` while its committed config says otherwise. Whether a
1847
+ * preset actually ships a preset is gated inside `plan`, where it can say so clearly.
1848
+ */
1849
+ appliesTo(_flavour) {
1850
+ return true;
1851
+ }
1852
+ /**
1853
+ * The preset this module committed to, read from the config sentinel wrote. Preferred
1854
+ * over detection because a monorepo hoists dependencies: a module declares no `react`,
1855
+ * so detection honestly answers `node` while the committed config says otherwise.
1856
+ */
1857
+ declaredPreset(cwd) {
1858
+ return presetNameFromPath(readLintAdoption(cwd).preset);
1859
+ }
1860
+ plan(context) {
1861
+ if (!hasLintPreset(context.preset)) {
1862
+ return {
1863
+ operations: [],
1864
+ blocked: `sentinel does not ship a lint preset named "${context.preset}" yet (shipped: ${LINT_PRESETS.join(", ")}). Nothing was written; this module cannot adopt the lint preset until that preset ships.`
1865
+ };
1866
+ }
1867
+ const current = presetNameFromPath(readLintAdoption(context.cwd).preset);
1868
+ if (current !== void 0 && current !== context.preset) {
1869
+ return {
1870
+ operations: [],
1871
+ blocked: `this module is already adopted as "${current}", and --preset says "${context.preset}". Nothing was written. If the change is deliberate, delete ${LINT_CONFIG_FILE} and re-run; otherwise re-run with --preset ${current}.`
1872
+ };
1873
+ }
1874
+ const disabled = disabledRulesFor(context.preset);
1875
+ const own = readOwnPackage();
1876
+ const ignores = readEslintIgnores(context.cwd);
1877
+ const stub = {
1878
+ extends: [presetPath(context.preset)],
1879
+ ...ignores.patterns.length > 0 ? { ignorePatterns: ignores.patterns } : {}
1880
+ };
1881
+ const operations = [
1882
+ { kind: "write", path: LINT_CONFIG_FILE, contents: JSON.stringify(stub, null, 2) + "\n" },
1883
+ this.packageJsonOperation(context.cwd)
1884
+ ];
1885
+ const removableDeps = this.moduleEslintDependencies(context.cwd);
1886
+ if (removableDeps.length > 0) {
1887
+ operations.push({
1888
+ kind: "remove-json-keys",
1889
+ path: "package.json",
1890
+ keys: removableDeps
1891
+ });
1892
+ }
1893
+ const eslintConfigs = ESLINT_CONFIG_FILES.filter((name) => existsSync7(join8(context.cwd, name)));
1894
+ for (const name of eslintConfigs) operations.push({ kind: "delete", path: name });
1895
+ if (existsSync7(join8(context.cwd, "project.json")) && !this.hasLintTarget(context.cwd)) {
1896
+ operations.push({
1897
+ kind: "remove-json-keys",
1898
+ path: "project.json",
1899
+ keys: [["targets", "lint"]]
1900
+ });
1901
+ operations.push({
1902
+ kind: "merge-json",
1903
+ path: "project.json",
1904
+ value: { targets: { lint: lintTarget() } }
1905
+ });
1906
+ }
1907
+ const renames = findSuppressionRenames(
1908
+ context.cwd,
1909
+ renamedPrefixes(
1910
+ lintPresetFor(context.preset).jsPlugins,
1911
+ lintPresetFor(context.preset).renames
1912
+ )
1913
+ );
1914
+ const deleting = new Set(eslintConfigs);
1915
+ const rewritten = renames.filter((entry) => !deleting.has(entry.file));
1916
+ const byFile = /* @__PURE__ */ new Map();
1917
+ for (const entry of rewritten) {
1918
+ const list = byFile.get(entry.file) ?? [];
1919
+ list.push({ from: entry.from, to: entry.to });
1920
+ byFile.set(entry.file, list);
1921
+ }
1922
+ for (const [file, replacements] of byFile) {
1923
+ operations.push({ kind: "replace-lines", path: file, replacements });
1924
+ }
1925
+ const notes = [
1926
+ `wrote ${LINT_CONFIG_FILE} extending ${presetPath(context.preset)} (${own.name}@${own.version})`
1927
+ ];
1928
+ if (disabled.length > 0) {
1929
+ notes.push(
1930
+ `${disabled.length} rule(s) not enforced by this preset (${summariseByPlugin(disabled)}). Run --inspect --lint for each rule and why.`
1931
+ );
1932
+ }
1933
+ if (eslintConfigs.length > 0) {
1934
+ notes.push(`removed ${eslintConfigs.join(", ")} and pointed the nx lint target at oxlint`);
1935
+ }
1936
+ if (ignores.patterns.length > 0) {
1937
+ notes.push(
1938
+ `carried ${ignores.patterns.length} ignore pattern(s) over from the eslint config (${ignores.patterns.join(", ")}): they are this module's, not the preset's`
1939
+ );
1940
+ }
1941
+ if (ignores.unresolved.length > 0) {
1942
+ notes.push(
1943
+ `COULD NOT read ${ignores.unresolved.join(", ")} from the eslint config, so any ignores it contributed are NOT carried over. Check them and add what you need to \`ignorePatterns\` in ${LINT_CONFIG_FILE}`
1944
+ );
1945
+ }
1946
+ if (removableDeps.length > 0) {
1947
+ notes.push(
1948
+ `removed ${removableDeps.length} eslint dependenc(ies) this module declared (${removableDeps.map(([, name]) => name).join(", ")}); the ROOT is untouched, it serves every module that has not migrated yet`
1949
+ );
1950
+ }
1951
+ if (rewritten.length > 0) {
1952
+ notes.push(
1953
+ `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`
1954
+ );
1955
+ }
1956
+ const script = composeLintScript(this.existingLintScript(context.cwd));
1957
+ if (keepsOtherCommands(script)) {
1958
+ notes.push(
1959
+ `kept the rest of the lint script (\`${script}\`): only the eslint part is sentinel's. Formatting stays with prettier until the formatter moves, as its own decision.`
1960
+ );
1961
+ }
1962
+ notes.push("run `pnpm install`, then `pnpm run lint`");
1963
+ return { operations, notes };
1964
+ }
1965
+ /**
1966
+ * Write the `lint` script and the pinned `@hublo/sentinel` devDependency, creating a
1967
+ * `package.json` when the module has none. Real case: a BFF in the monorepo had no
1968
+ * `package.json` at all, so `pnpm exec` failed outright and there was nowhere to put a
1969
+ * script. `merge-json` preserves everything else and makes a re-run idempotent.
1970
+ */
1971
+ packageJsonOperation(cwd) {
1972
+ const own = readOwnPackage();
1973
+ const value = {
1974
+ scripts: { [LINT_SCRIPT_NAME]: composeLintScript(this.existingLintScript(cwd)) },
1975
+ devDependencies: { [own.name]: own.version }
1976
+ };
1977
+ if (existsSync7(join8(cwd, "package.json"))) {
1978
+ return { kind: "merge-json", path: "package.json", value };
1979
+ }
1980
+ return {
1981
+ kind: "merge-json",
1982
+ path: "package.json",
1983
+ value: { name: readNxProjectName(cwd) ?? basename(cwd), private: true, ...value }
1984
+ };
1985
+ }
1986
+ /**
1987
+ * True when the nx lint target already says exactly what sentinel would write.
1988
+ *
1989
+ * Worth checking rather than rewriting: the remove-then-merge cycle moves the key's
1990
+ * position in the file, so an unchanged target still produces a diff, and `--init` would
1991
+ * report a change on every run forever.
1992
+ */
1993
+ hasLintTarget(cwd) {
1994
+ try {
1995
+ const project = JSON.parse(readFileSync6(join8(cwd, "project.json"), "utf8"));
1996
+ return JSON.stringify(project.targets?.[LINT_SCRIPT_NAME]) === JSON.stringify(lintTarget());
1997
+ } catch {
1998
+ return false;
1999
+ }
2000
+ }
2001
+ /** The module's current `lint` script, so adoption replaces only the eslint part of it. */
2002
+ existingLintScript(cwd) {
2003
+ try {
2004
+ const pkg = JSON.parse(readFileSync6(join8(cwd, "package.json"), "utf8"));
2005
+ return pkg.scripts?.[LINT_SCRIPT_NAME];
2006
+ } catch {
2007
+ return void 0;
2008
+ }
2009
+ }
2010
+ /**
2011
+ * Lint the module. Announces the rules that are deliberately not enforced, so the
2012
+ * reduced coverage is never a silent gap, and refuses to report a pass when the linter
2013
+ * or its type-aware sidecar is missing.
2014
+ */
2015
+ async run(ctx) {
2016
+ if (!existsSync7(join8(ctx.cwd, LINT_CONFIG_FILE))) {
2017
+ process.stderr.write(
2018
+ `sentinel lint(oxlint): no ${LINT_CONFIG_FILE} in this module; run \`sentinel --init --lint\` to adopt.
2019
+ `
2020
+ );
2021
+ return { ok: true, code: 0 };
2022
+ }
2023
+ const oxlint = resolveOxlint(ctx.cwd);
2024
+ if (!oxlint) {
2025
+ process.stderr.write(
2026
+ `sentinel lint(oxlint): could not find the oxlint binary (looked in ${oxlintSearchPath(ctx.cwd)}). Run \`pnpm install\` in the module.
2027
+ `
2028
+ );
2029
+ return { ok: false, code: 1 };
2030
+ }
2031
+ const missingPreset = this.unresolvedPreset(ctx.cwd);
2032
+ if (missingPreset) {
2033
+ process.stderr.write(
2034
+ `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.
2035
+ `
2036
+ );
2037
+ return { ok: false, code: 1 };
2038
+ }
2039
+ this.announceDisabled(ctx.preset);
2040
+ const typeAware = canRunTypeAware(ctx.cwd);
2041
+ if (!typeAware) {
2042
+ process.stderr.write(
2043
+ `${palette(process.stderr).warn("sentinel lint(oxlint): oxlint-tsgolint is not installed, so type-aware rules will NOT run")}
2044
+ `
2045
+ );
2046
+ }
2047
+ const env = { ...process.env, PATH: oxlintPath(ctx.cwd, process.env) };
2048
+ const ignores = DEFAULT_IGNORE_PATTERNS.flatMap((pattern) => ["--ignore-pattern", pattern]);
2049
+ const lint = (extra) => {
2050
+ const passed = [...extra, ...ctx.toolArgs ?? []];
2051
+ const result = spawnSync(oxlint, ["-c", LINT_CONFIG_FILE, ...ignores, ...passed, "."], {
2052
+ cwd: ctx.cwd,
2053
+ stdio: "inherit",
2054
+ env
2055
+ });
2056
+ if (result.error) {
2057
+ process.stderr.write(
2058
+ `sentinel lint(oxlint): could not run oxlint (${result.error.message})
2059
+ `
2060
+ );
2061
+ return 1;
2062
+ }
2063
+ return result.status ?? 1;
2064
+ };
2065
+ if (ctx.fix) {
2066
+ for (let pass = 0; pass < MAX_FIX_PASSES; pass += 1) {
2067
+ if (lint(["--fix"]) === 0) break;
2068
+ }
2069
+ }
2070
+ const code = lint(typeAware ? ["--type-aware"] : []);
2071
+ return { ok: code === 0, code };
2072
+ }
2073
+ /** The module's resolved lint configuration, and what it deliberately does not enforce. */
2074
+ async inspect(ctx) {
2075
+ const adoption = readLintAdoption(ctx.cwd);
2076
+ const disabled = disabledRulesFor(ctx.preset);
2077
+ const rules = this.presetRuleCount(ctx.cwd);
2078
+ return {
2079
+ target: "lint",
2080
+ runner: "oxlint",
2081
+ adopted: adoption.adopted,
2082
+ preset: adoption.preset,
2083
+ configFile: adoption.configFile,
2084
+ rules,
2085
+ typeAware: canRunTypeAware(ctx.cwd),
2086
+ disabled: disabled.map((entry) => ({ rule: entry.rule, reason: entry.reason })),
2087
+ // Reported separately from `disabled`: these still run and still report, they just
2088
+ // cannot fail a build. Collapsing the two would hide the difference between "we do
2089
+ // not enforce this" and "we enforce this but will not block on it yet".
2090
+ downgraded: downgradedRulesFor(ctx.preset).map((entry) => ({
2091
+ rule: entry.rule,
2092
+ reason: entry.reason
2093
+ }))
2094
+ };
2095
+ }
2096
+ /**
2097
+ * The module's own eslint dependencies, as `package.json` key paths to remove.
2098
+ *
2099
+ * Deliberately narrow. Only packages that exist to serve eslint are listed, so nothing a
2100
+ * module might use for another purpose is taken away: `globals`, for instance, is a plain
2101
+ * data package that outlives its eslint config, and removing it on a guess would break a
2102
+ * build for a cleanup nobody asked for. Prettier is untouched too, since formatting stays
2103
+ * with prettier until the formatter migrates as its own decision.
2104
+ */
2105
+ moduleEslintDependencies(cwd) {
2106
+ const isEslintPackage = (name) => name === "eslint" || name === "@types/eslint" || name === "typescript-eslint" || name.startsWith("@typescript-eslint/") || name.startsWith("eslint-plugin-") || name.startsWith("eslint-config-") || name.startsWith("@eslint/");
2107
+ let manifest;
2108
+ try {
2109
+ manifest = JSON.parse(readFileSync6(join8(cwd, "package.json"), "utf8"));
2110
+ } catch {
2111
+ return [];
2112
+ }
2113
+ return ["dependencies", "devDependencies"].flatMap(
2114
+ (section) => Object.keys(manifest[section] ?? {}).filter(isEslintPackage).map((name) => [section, name])
2115
+ );
2116
+ }
2117
+ /** How many rules the extended preset enforces, read from the preset on disk. */
2118
+ presetRuleCount(cwd) {
2119
+ const target = this.unresolvedPreset(cwd);
2120
+ if (target) return 0;
2121
+ let total = 0;
2122
+ try {
2123
+ const stub = JSON.parse(readFileSync6(join8(cwd, LINT_CONFIG_FILE), "utf8"));
2124
+ for (const entry of stub.extends ?? []) {
2125
+ const preset = JSON.parse(readFileSync6(join8(cwd, entry), "utf8"));
2126
+ total += Object.keys(preset.rules ?? {}).length;
2127
+ }
2128
+ } catch {
2129
+ return 0;
2130
+ }
2131
+ return total;
2132
+ }
2133
+ /** Lint health for `--report`. Deepened in a later step; today it is the run outcome. */
2134
+ async report(ctx) {
2135
+ const result = await this.run(ctx);
2136
+ return { ...result, metrics: { adopted: readLintAdoption(ctx.cwd).adopted } };
2137
+ }
2138
+ /** Adoption + conformity, read from the committed config with no tool run. */
2139
+ async status(ctx) {
2140
+ const adoption = readLintAdoption(ctx.cwd);
2141
+ return {
2142
+ adopted: adoption.adopted,
2143
+ preset: adoption.preset,
2144
+ conformant: adoption.conformant,
2145
+ drift: adoption.drift
2146
+ };
2147
+ }
2148
+ /**
2149
+ * The `extends` target this module points at, when it cannot be found on disk. Returns
2150
+ * undefined when everything resolves. Read from the committed config rather than
2151
+ * recomputed, so it checks what the module actually says.
2152
+ */
2153
+ unresolvedPreset(cwd) {
2154
+ let parsed;
2155
+ try {
2156
+ parsed = JSON.parse(readFileSync6(join8(cwd, LINT_CONFIG_FILE), "utf8"));
2157
+ } catch {
2158
+ return void 0;
2159
+ }
2160
+ const targets = Array.isArray(parsed.extends) ? parsed.extends.filter((entry) => typeof entry === "string") : typeof parsed.extends === "string" ? [parsed.extends] : [];
2161
+ return targets.find((target) => !existsSync7(join8(cwd, target)));
2162
+ }
2163
+ /** Announce what is not enforced, so reduced coverage is never silent. */
2164
+ announceDisabled(preset) {
2165
+ const softened = downgradedRulesFor(preset);
2166
+ if (softened.length > 0) {
2167
+ process.stderr.write(
2168
+ `${palette(process.stderr).warn(
2169
+ `sentinel lint(oxlint): ${softened.length} rule(s) report but will not fail the build (${summariseByPlugin(softened)}); run --inspect --lint for each rule and why`
2170
+ )}
2171
+ `
2172
+ );
2173
+ }
2174
+ const disabled = disabledRulesFor(preset);
2175
+ if (disabled.length === 0) return;
2176
+ process.stderr.write(
2177
+ `${palette(process.stderr).warn(
2178
+ `sentinel lint(oxlint): ${disabled.length} rule(s) not enforced by default (${summariseByPlugin(disabled)}); run --inspect --lint for each rule and why`
2179
+ )}
2180
+ `
2181
+ );
2182
+ }
2183
+ };
2184
+
2185
+ // src/roles/lint/register.ts
2186
+ function registerLint() {
2187
+ register(new OxlintAdapter());
2188
+ setDefaultRunner("lint", "oxlint");
2189
+ }
2190
+
2191
+ // src/roles/typescript/adapters/tsc/tsc.adapter.ts
2192
+ import { spawnSync as spawnSync2 } from "child_process";
2193
+ import { createRequire as createRequire2 } from "module";
2194
+ import { existsSync as existsSync9, readFileSync as readFileSync8 } from "fs";
2195
+ import { basename as basename2, join as join10 } from "path";
2196
+
2197
+ // src/core/config/preset-evidence.ts
2198
+ import { existsSync as existsSync8, readdirSync as readdirSync2, readFileSync as readFileSync7 } from "fs";
2199
+ import { join as join9 } from "path";
2200
+ var PATH_SIGNALS = [
2201
+ {
2202
+ preset: "nest",
2203
+ pattern: /(^|\/)apps\/nest\//,
2204
+ evidence: "it lives under apps/nest"
2205
+ }
2206
+ ];
2207
+ var DEPENDENCY_SIGNALS = [
2208
+ { preset: "react", pattern: /^(react|react-dom|@types\/react)$|^eslint-plugin-react/ },
2209
+ { preset: "svelte", pattern: /^svelte$|^@sveltejs\// },
2210
+ { preset: "nest", pattern: /^@nestjs\// }
2211
+ ];
2212
+ function dependencyNames(cwd) {
2213
+ const path = join9(cwd, "package.json");
2214
+ if (!existsSync8(path)) return [];
2215
+ try {
2216
+ const manifest = parseJsonc(
2217
+ readFileSync7(path, "utf8"),
2218
+ path
2219
+ );
2220
+ return [
2221
+ ...Object.keys(manifest.dependencies ?? {}),
2222
+ ...Object.keys(manifest.devDependencies ?? {})
2223
+ ];
2224
+ } catch {
2225
+ return [];
2226
+ }
2227
+ }
2228
+ function declaresJsx(cwd) {
2229
+ let entries;
2230
+ try {
2231
+ entries = readdirSync2(cwd).filter(
2232
+ (name) => name.startsWith("tsconfig") && name.endsWith(".json")
2233
+ );
2234
+ } catch {
2235
+ return false;
2236
+ }
2237
+ for (const name of entries) {
2238
+ try {
2239
+ const config = parseJsonc(
2240
+ readFileSync7(join9(cwd, name), "utf8"),
2241
+ name
2242
+ );
2243
+ if (config.compilerOptions?.jsx !== void 0) return true;
2244
+ } catch {
2245
+ }
2246
+ }
2247
+ return false;
2248
+ }
2249
+ function presetSignals(cwd) {
2250
+ const signals = [];
2251
+ const dependencies = dependencyNames(cwd);
2252
+ for (const { preset, pattern } of DEPENDENCY_SIGNALS) {
2253
+ const hit = dependencies.find((name) => pattern.test(name));
2254
+ if (hit) signals.push({ preset, evidence: `it depends on "${hit}"` });
2255
+ }
2256
+ if (declaresJsx(cwd) && !signals.some((signal) => signal.preset === "react")) {
2257
+ signals.push({ preset: "react", evidence: 'its tsconfig sets "jsx"' });
2258
+ }
2259
+ const location = cwd.replaceAll("\\", "/");
2260
+ for (const { preset, pattern, evidence } of PATH_SIGNALS) {
2261
+ if (pattern.test(location) && !signals.some((signal) => signal.preset === preset)) {
2262
+ signals.push({ preset, evidence });
2263
+ }
2264
+ }
2265
+ return signals;
2266
+ }
2267
+ function presetContradiction(declared, cwd) {
2268
+ const signals = presetSignals(cwd);
2269
+ if (signals.length === 0) return void 0;
2270
+ if (signals.some((signal) => signal.preset === declared)) return void 0;
2271
+ const [first] = signals;
2272
+ if (!first) return void 0;
2273
+ const evidence = signals.map((signal) => signal.evidence).join(", and ");
2274
+ return `--preset ${declared} does not match this module: ${evidence}, which makes it "${first.preset}". Nothing was written. Re-run with --preset ${first.preset}, or adopt from the module you actually meant.`;
2275
+ }
2276
+
2277
+ // src/roles/typescript/config-policy.ts
2278
+ var PERMITTED_COMPILER_OPTIONS = [
2279
+ "paths",
2280
+ "baseUrl",
2281
+ "rootDir",
2282
+ "outDir",
2283
+ "tsBuildInfoFile"
2284
+ ];
2285
+ function presetOwnedKeys2(compilerOptions) {
2286
+ return presetOwnedKeys(compilerOptions, PERMITTED_COMPILER_OPTIONS);
2287
+ }
2288
+
2289
+ // src/roles/typescript/presets/base.json
2290
+ var base_default2 = {
2291
+ $schema: "https://json.schemastore.org/tsconfig",
2292
+ compilerOptions: {
2293
+ strict: true,
2294
+ noFallthroughCasesInSwitch: true,
2295
+ forceConsistentCasingInFileNames: true,
2296
+ esModuleInterop: true,
2297
+ skipLibCheck: true
2298
+ },
2299
+ deferred: {
2300
+ noImplicitAny: {
2301
+ phase: 2,
2302
+ reason: "the implicit-any migration (TS70xx)"
2303
+ },
2304
+ noUnusedLocals: {
2305
+ phase: 2,
2306
+ reason: "unused-local cleanup (TS6133)"
2307
+ },
2308
+ noUnusedParameters: {
2309
+ phase: 2,
2310
+ reason: "unused-parameter cleanup (TS6133)"
2311
+ }
2312
+ }
2313
+ };
2314
+
2315
+ // src/roles/typescript/presets/nest.json
2316
+ var nest_default2 = {
2317
+ compilerOptions: {
2318
+ module: "esnext",
2319
+ moduleResolution: "bundler",
2320
+ types: ["node"],
2321
+ experimentalDecorators: true,
2322
+ emitDecoratorMetadata: true,
2323
+ declaration: true,
2324
+ sourceMap: true
2325
+ },
2326
+ notes: {
2327
+ module: "the backends are webpack-bundled and tsc only typechecks, so the module format is the bundler's concern; `bundler` resolution is what the repo already uses, and the only Node-friendly option TS7 still accepts since it removed classic `node`",
2328
+ target: "deliberately NOT set, here or in lib: those are environment the repo base owns. Overriding target flips useDefineForClassFields (TS2612) and narrowing lib drops DOM (GLfloat); both REPLACE the base rather than merge"
2329
+ }
2330
+ };
2331
+
2332
+ // src/roles/typescript/presets/node.json
2333
+ var node_default2 = {
2334
+ compilerOptions: {
2335
+ module: "esnext",
2336
+ moduleResolution: "bundler",
2337
+ types: ["node"],
2338
+ declaration: true
2339
+ },
2340
+ notes: {
2341
+ module: "esnext/bundler like the react and nest presets, matching a monorepo whose libs are bundler-resolved in practice. NOT NodeNext: that would force its stricter ESM rules onto libs the repo resolves through a bundler"
2342
+ }
2343
+ };
2344
+
2345
+ // src/roles/typescript/presets/react.json
2346
+ var react_default2 = {
2347
+ compilerOptions: {
2348
+ target: "ES2023",
2349
+ lib: ["ES2023", "DOM", "DOM.Iterable"],
2350
+ module: "ESNext",
2351
+ moduleResolution: "bundler",
2352
+ jsx: "react-jsx",
2353
+ useDefineForClassFields: true,
2354
+ isolatedModules: true,
2355
+ moduleDetection: "force",
2356
+ noEmit: true,
2357
+ noUncheckedSideEffectImports: true
2358
+ },
2359
+ notes: {
2360
+ noEmit: "vite owns the build; tsc only typechecks here"
2361
+ }
2362
+ };
2363
+
2364
+ // src/roles/typescript/preset-data.ts
2365
+ var BASE = base_default2;
2366
+ var LAYERS = {
2367
+ nest: nest_default2,
2368
+ node: node_default2,
2369
+ react: react_default2
2370
+ };
2371
+ var SHIPPED_PRESETS = Object.keys(LAYERS).sort();
2372
+ var DEFERRED_RULES = Object.entries(BASE.deferred ?? {}).map(([rule, entry]) => ({ rule, phase: entry.phase, reason: entry.reason })).sort((left, right) => left.rule.localeCompare(right.rule));
2373
+ function validate2() {
2374
+ for (const { rule, phase, reason } of DEFERRED_RULES) {
2375
+ if (!Number.isInteger(phase) || phase < 2) {
2376
+ throw new Error(
2377
+ `deferred rule "${rule}" has phase ${phase}; a deferred rule is scheduled for phase 2 or later`
2378
+ );
2379
+ }
2380
+ if (reason.trim() === "") {
2381
+ throw new Error(
2382
+ `deferred rule "${rule}" has no reason; --inspect reports it and would say nothing`
2383
+ );
2384
+ }
2385
+ if (rule in BASE.compilerOptions) {
2386
+ throw new Error(
2387
+ `"${rule}" is deferred and also set by the base preset; it would be announced as not enforced while being enforced`
2388
+ );
2389
+ }
2390
+ for (const [preset, layer] of Object.entries(LAYERS)) {
2391
+ if (layer && rule in layer.compilerOptions) {
2392
+ throw new Error(
2393
+ `"${rule}" is deferred and also set by the ${preset} preset; it would be announced as not enforced while being enforced`
2394
+ );
2395
+ }
2396
+ }
2397
+ }
2398
+ }
2399
+ validate2();
2400
+ function hasShippedPreset(preset) {
2401
+ return preset in LAYERS;
2402
+ }
2403
+
2404
+ // src/roles/typescript/phased-rules.ts
2405
+ function deferredRuleNames2() {
2406
+ return deferredRuleNames(DEFERRED_RULES);
2407
+ }
2408
+
2409
+ // src/roles/typescript/resolve-tsconfig-target.ts
2410
+ var TSCONFIG_CANDIDATES = ["tsconfig.app.json", "tsconfig.json"];
2411
+ var TSCONFIG_MARKERS = ["tsconfig.base.json", "@hublo/sentinel/tsconfig/"];
2412
+ var TSCONFIG_FALLBACK = "tsconfig.json";
2413
+ function resolveTsconfigTarget(moduleDir) {
2414
+ return resolveConfigTarget(moduleDir, {
2415
+ candidates: TSCONFIG_CANDIDATES,
2416
+ markers: TSCONFIG_MARKERS,
2417
+ fallback: TSCONFIG_FALLBACK
2418
+ });
2419
+ }
2420
+
2421
+ // src/roles/typescript/read-adoption.ts
2422
+ var SENTINEL_PRESET = /^@hublo\/sentinel\/tsconfig\/[a-z-]+$/;
2423
+ function readTsconfigAdoption(cwd) {
2424
+ return readAdoption(cwd, {
2425
+ candidates: TSCONFIG_CANDIDATES,
2426
+ markers: TSCONFIG_MARKERS,
2427
+ fallback: TSCONFIG_FALLBACK,
2428
+ presetPattern: SENTINEL_PRESET,
2429
+ settingsKey: "compilerOptions",
2430
+ permitted: PERMITTED_COMPILER_OPTIONS
2431
+ });
2432
+ }
2433
+
2434
+ // src/roles/typescript/adapters/tsc/tsc.adapter.ts
2435
+ var SENTINEL_PACKAGE = "@hublo/sentinel";
2436
+ var SENTINEL_PRESET_SCOPE = `${SENTINEL_PACKAGE}/tsconfig/`;
2437
+ var TYPECHECK_SCRIPT = { typecheck: "sentinel --run --typescript" };
2438
+ var INSTALL_NOTE = "run `pnpm install` to fetch @hublo/sentinel (added to the module devDependencies) so `extends` and the typecheck script resolve";
2439
+ var DEFAULT_MAX_DIAGNOSTICS = 100;
2440
+ var DIAGNOSTIC_RE = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.+)$/;
2441
+ function parseDiagnostics(output) {
2442
+ const diagnostics = [];
2443
+ for (const raw of output.split("\n")) {
2444
+ const m = DIAGNOSTIC_RE.exec(raw.trim());
2445
+ if (m) {
2446
+ diagnostics.push({
2447
+ file: m[1],
2448
+ line: Number(m[2]),
2449
+ col: Number(m[3]),
2450
+ code: m[4],
2451
+ message: m[5]
2452
+ });
2453
+ }
2454
+ }
2455
+ return diagnostics;
2456
+ }
2457
+ 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, or \`sentinel --run --typescript -- --noImplicitAny\` to measure what one would cost today.`;
2458
+ function composeExtends(current, preset) {
2459
+ const chain = typeof current === "string" ? [current] : Array.isArray(current) ? current.filter((entry) => typeof entry === "string") : [];
2460
+ return chain.includes(preset) ? chain : [...chain, preset];
2461
+ }
2462
+ var TscAdapter = class extends BaseAdapter {
2463
+ target = "typescript";
2464
+ runner = "tsc";
2465
+ /**
2466
+ * The tsc adapter drives type-checking for any preset: `--run`/`--report`/
2467
+ * `--inspect` just execute tsc against the module's existing config, which is
2468
+ * meaningful regardless of preset. `--init` is the exception, it only WRITES a
2469
+ * preset for the names that ship one (gated inside `plan`), so svelte is not
2470
+ * clobbered with a non-existent preset.
2471
+ */
2472
+ appliesTo(_flavour) {
2473
+ return true;
2474
+ }
2475
+ /**
2476
+ * The preset read from the committed `extends` chain
2477
+ * (`@hublo/sentinel/tsconfig/nest` -> `nest`), or undefined when the module has not
2478
+ * adopted a preset, so the engine falls back to dependency detection.
2479
+ *
2480
+ * This is the same detection-free read `--status` uses, and it is why an adopted React app
2481
+ * reports `react` even in a monorepo that hoists `react` to the root.
2482
+ */
2483
+ declaredPreset(cwd) {
2484
+ const { preset } = readTsconfigAdoption(cwd);
2485
+ if (!preset) return void 0;
2486
+ const name = preset.slice(preset.lastIndexOf("/") + 1);
2487
+ return PRESET_NAMES.includes(name) ? name : void 0;
2488
+ }
2489
+ /**
2490
+ * Plan `--init`: make the module extend the sentinel preset with a THIN,
2491
+ * conformant stub, route type-checking through the CLI, and pin the
2492
+ * `@hublo/sentinel` devDependency into the module. The engine applies the ops, so
2493
+ * adoption is `--init` then `pnpm install`, with nothing to add by hand.
2494
+ *
2495
+ * Per resolved case:
2496
+ * - extends-base: append the preset to the `extends` chain (keep the base for
2497
+ * the monorepo's paths/structure) + strip preset-owned `compilerOptions`
2498
+ * (drift), keeping the project's own paths/include (the allowlist).
2499
+ * - none: create a fresh thin `tsconfig.json`.
2500
+ * - other-chain (svelte): skip, its config extends a different base.
2501
+ */
2502
+ plan(context) {
2503
+ if (!hasShippedPreset(context.preset)) {
2504
+ return {
2505
+ operations: [],
2506
+ blocked: `sentinel does not ship a TypeScript preset named "${context.preset}" yet (shipped: ${SHIPPED_PRESETS.join(", ")}). Nothing was written; this module cannot adopt the TypeScript preset until that preset ships.`
2507
+ };
2508
+ }
2509
+ const current = this.declaredPreset(context.cwd);
2510
+ if (current !== void 0 && current !== context.preset) {
2511
+ return {
2512
+ operations: [],
2513
+ blocked: `this module is already adopted as "${current}", and --preset says "${context.preset}". Nothing was written. If the change is deliberate, remove the sentinel preset from the tsconfig \`extends\` chain and re-run; otherwise re-run with --preset ${current}.`
2514
+ };
2515
+ }
2516
+ const contradiction = presetContradiction(context.preset, context.cwd);
2517
+ if (contradiction !== void 0) {
2518
+ return { operations: [], blocked: contradiction };
2519
+ }
2520
+ const target = resolveTsconfigTarget(context.cwd);
2521
+ const preset = `@hublo/sentinel/tsconfig/${context.preset}`;
2522
+ const addScript = this.packageJsonOperation(context.cwd);
2523
+ if (target.reason === "other-chain") {
2524
+ return {
2525
+ operations: [],
2526
+ 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.`
2527
+ };
2528
+ }
2529
+ if (target.reason === "no-extends") {
2530
+ return {
2531
+ operations: [],
2532
+ blocked: `${target.path} extends nothing, so it defines its own complete set of compilerOptions and sentinel will not silently take ownership of them. Nothing was written. Make it extend the workspace base and re-run, and --init will then move the shared options into the preset.`
2533
+ };
2534
+ }
2535
+ if (target.reason === "none") {
2536
+ const contents = JSON.stringify({ extends: preset, include: ["src"] }, null, 2) + "\n";
2537
+ return {
2538
+ operations: [{ kind: "write", path: target.path, contents }, addScript],
2539
+ notes: [`created ${target.path} (no tsconfig found)`, INSTALL_NOTE]
2540
+ };
2541
+ }
2542
+ const existing = parseJsonc(
2543
+ readFileSync8(join10(context.cwd, target.path), "utf8"),
2544
+ target.path
2545
+ );
2546
+ const extendsChain = composeExtends(existing.extends, preset);
2547
+ const drift = presetOwnedKeys2(existing.compilerOptions);
2548
+ const operations = [
2549
+ { kind: "merge-json", path: target.path, value: { extends: extendsChain } }
2550
+ ];
2551
+ const notes = [];
2552
+ if (drift.length > 0) {
2553
+ const localKeys = Object.keys(existing.compilerOptions ?? {});
2554
+ const stripsAll = localKeys.length > 0 && localKeys.every((key) => drift.includes(key));
2555
+ operations.push({
2556
+ kind: "remove-json-keys",
2557
+ path: target.path,
2558
+ keys: stripsAll ? [["compilerOptions"]] : drift.map((key) => ["compilerOptions", key])
2559
+ });
2560
+ notes.push(`stripped preset-owned compilerOptions: ${drift.join(", ")}`);
2561
+ }
2562
+ operations.push(addScript);
2563
+ notes.push(INSTALL_NOTE);
2564
+ return { operations, notes };
2565
+ }
2566
+ /**
2567
+ * The op that routes type-checking through the CLI. If the module already has a
2568
+ * `package.json`, merge the script in and leave the rest untouched. If it does
2569
+ * NOT (common for nx apps/services that carry only a `project.json`), scaffold a
2570
+ * minimal, workspace-valid one, its nx name + `private: true`, so pnpm accepts it
2571
+ * and it can carry the pinned `@hublo/sentinel` devDep this op also writes. The
2572
+ * exact version is written (never a range), so `pnpm install` resolves the same
2573
+ * build the stub's `extends` points at.
2574
+ */
2575
+ packageJsonOperation(cwd) {
2576
+ const own = readOwnPackage();
2577
+ const devDependencies = { [own.name]: own.version };
2578
+ if (existsSync9(join10(cwd, "package.json"))) {
2579
+ return {
2580
+ kind: "merge-json",
2581
+ path: "package.json",
2582
+ value: { scripts: TYPECHECK_SCRIPT, devDependencies }
2583
+ };
2584
+ }
2585
+ const name = readNxProjectName(cwd) ?? basename2(cwd);
2586
+ return {
2587
+ kind: "merge-json",
2588
+ path: "package.json",
2589
+ value: { name, private: true, scripts: TYPECHECK_SCRIPT, devDependencies }
2590
+ };
2591
+ }
2592
+ /**
2593
+ * Type-check the module with `tsc -b` (build mode) on its solution config, the
2594
+ * way the monorepo itself does. Build mode walks the config's `references`, so a
2595
+ * references-only solution (Pattern A: app + spec) is actually checked instead of
2596
+ * passing vacuously; it also only caches SUCCESSFUL builds, so errors are always
2597
+ * re-reported. Uses the module's own tsc. Nothing to check is a pass.
2598
+ */
2599
+ /**
2600
+ * Translate "the preset file is not there" into the one thing that fixes it.
2601
+ *
2602
+ * tsc reports a missing `extends` target as a bare TS6053, once per referenced project,
2603
+ * which reads as a broken preset rather than a missing install:
2604
+ *
2605
+ * tsconfig.json(2,46): error TS6053: File '@hublo/sentinel/tsconfig/node' not found.
2606
+ *
2607
+ * The usual cause is mundane and recurring: the module's `tsconfig` extends the package
2608
+ * from `node_modules`, and switching branches changes the lockfile without reinstalling,
2609
+ * so the package is simply absent. Twice while developing this, a green module looked like
2610
+ * a regression for exactly that reason.
2611
+ *
2612
+ * The lint role already says this; saying it here too means a developer gets the same
2613
+ * answer whichever tool they happen to run first.
2614
+ */
2615
+ explainMissingPreset(output) {
2616
+ const missing = /error TS6053: File '(@hublo\/sentinel\/[^']+)' not found/.exec(output);
2617
+ if (!missing) return;
2618
+ process.stderr.write(
2619
+ `sentinel typescript(tsc): the tsconfig extends ${missing[1]}, which is not installed in this module. That usually means the lockfile changed without a reinstall (a branch switch is the common one). Run \`pnpm install\` and try again.
2620
+ `
2621
+ );
2622
+ }
2623
+ /**
2624
+ * The sentinel preset a tsconfig extends, when the package is not installed here.
2625
+ *
2626
+ * Checked BEFORE running, because `run` inherits tsc's stdio and never sees its output.
2627
+ * Without this the developer gets a bare TS6053 repeated once per referenced project:
2628
+ *
2629
+ * tsconfig.json(2,46): error TS6053: File '@hublo/sentinel/tsconfig/node' not found.
2630
+ *
2631
+ * which reads as a broken preset rather than the mundane and recurring cause: the module
2632
+ * extends the package from `node_modules`, and a branch switch changes the lockfile
2633
+ * without reinstalling. Twice while building this, a green module looked like a
2634
+ * regression for exactly that reason. The lint role already says it; both roles should.
2635
+ */
2636
+ missingPresetPackage(cwd) {
2637
+ const target = resolveTsconfigTarget(cwd);
2638
+ if (target.reason === "none") return void 0;
2639
+ let chain;
2640
+ try {
2641
+ const parsed = parseJsonc(
2642
+ readFileSync8(join10(cwd, target.path), "utf8"),
2643
+ target.path
2644
+ );
2645
+ chain = parsed.extends;
2646
+ } catch {
2647
+ return void 0;
2648
+ }
2649
+ const entries = typeof chain === "string" ? [chain] : Array.isArray(chain) ? chain : [];
2650
+ const preset = entries.find(
2651
+ (entry) => typeof entry === "string" && entry.startsWith(SENTINEL_PRESET_SCOPE)
2652
+ );
2653
+ if (preset === void 0) return void 0;
2654
+ try {
2655
+ createRequire2(join10(cwd, "noop.js")).resolve(preset);
2656
+ return void 0;
2657
+ } catch {
2658
+ return preset;
2659
+ }
2660
+ }
2661
+ async run(ctx) {
2662
+ const config = this.typecheckTarget(ctx.cwd);
2663
+ if (!config) {
2664
+ process.stderr.write("sentinel typescript(tsc): no tsconfig to check\n");
2665
+ return { ok: true, code: 0 };
2666
+ }
2667
+ const missing = this.missingPresetPackage(ctx.cwd);
2668
+ if (missing) {
2669
+ process.stderr.write(
2670
+ `sentinel typescript(tsc): the tsconfig extends ${missing}, which is not installed in this module. That usually means the lockfile changed without a reinstall (a branch switch is the common one). Run \`pnpm install\` and try again.
2671
+ `
2672
+ );
2673
+ return { ok: false, code: 1 };
2674
+ }
2675
+ process.stderr.write(`${palette(process.stderr).warn(PHASED_STRICTNESS_WARNING)}
2676
+ `);
2677
+ const tsc = resolveBin(ctx.cwd, "tsc") ?? "tsc";
2678
+ if (ctx.toolArgs && ctx.toolArgs.length > 0) return this.runWithOptions(ctx, tsc, config);
2679
+ const result = spawnSync2(tsc, ["-b", config], { cwd: ctx.cwd, stdio: "inherit" });
2680
+ if (result.error) {
2681
+ process.stderr.write(
2682
+ `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
2683
+ `
2684
+ );
2685
+ return { ok: false, code: 1 };
2686
+ }
2687
+ const code = result.status ?? 1;
2688
+ return { ok: code === 0, code };
2689
+ }
2690
+ /**
2691
+ * The same check, with the caller's own compiler options applied.
2692
+ *
2693
+ * It cannot reuse the `tsc -b` path above, and that is not a preference. Build mode
2694
+ * REFUSES most compiler options outright:
2695
+ *
2696
+ * $ tsc -b tsconfig.json --noImplicitAny
2697
+ * error TS5094: Compiler option '--noImplicitAny' may not be used with '--build'.
2698
+ *
2699
+ * So honouring them means project mode, which carries a trap of its own here: on this
2700
+ * repo's layout a module's `tsconfig.json` is a SOLUTION (`files: []`, `include: []`,
2701
+ * `references`), so `tsc -p` on it compiles NOTHING and exits 0. That would report a clean
2702
+ * answer for a check that never ran, and it is exactly why the normal path uses build
2703
+ * mode. So walk the references and check each project that actually holds files.
2704
+ *
2705
+ * `--noEmit` is forced: this asks a question about the code, it does not build. Letting a
2706
+ * run with non-standard options write output would poison the artifacts and the build info
2707
+ * the normal path depends on. `--composite false` rides with it, because a composite
2708
+ * project refuses `--noEmit`.
2709
+ */
2710
+ runWithOptions(ctx, tsc, config) {
2711
+ let worst = 0;
2712
+ for (const project of this.referencedProjects(ctx.cwd, config)) {
2713
+ const args = ["-p", project, "--noEmit", "--composite", "false", ...ctx.toolArgs ?? []];
2714
+ const result = spawnSync2(tsc, args, { cwd: ctx.cwd, stdio: "inherit" });
2715
+ if (result.error) {
2716
+ process.stderr.write(
2717
+ `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
2718
+ `
2719
+ );
2720
+ return { ok: false, code: 1 };
2721
+ }
2722
+ worst = Math.max(worst, result.status ?? 1);
2723
+ }
2724
+ return { ok: worst === 0, code: worst };
2725
+ }
2726
+ /**
2727
+ * The projects that actually hold files: a solution's `references`, else the config
2728
+ * itself. Returning the solution when it has references would check nothing, so the
2729
+ * fallback is only for a module whose config really is the compilation unit.
2730
+ */
2731
+ referencedProjects(cwd, config) {
2732
+ try {
2733
+ const parsed = parseJsonc(
2734
+ readFileSync8(join10(cwd, config), "utf8"),
2735
+ config
2736
+ );
2737
+ const referenced = (parsed.references ?? []).map((reference) => reference.path).filter((path) => typeof path === "string" && path.length > 0);
2738
+ return referenced.length > 0 ? referenced : [config];
2739
+ } catch {
2740
+ return [config];
2741
+ }
2742
+ }
2743
+ /**
2744
+ * The config to type-check with `tsc -b`. Prefer the module's root `tsconfig.json`
2745
+ * (the solution the monorepo builds; `tsc -b` follows its `references` to cover
2746
+ * app + spec), else the base-extending file, else null when there is nothing to
2747
+ * check.
2748
+ */
2749
+ typecheckTarget(cwd) {
2750
+ if (existsSync9(join10(cwd, "tsconfig.json"))) return "tsconfig.json";
2751
+ const target = resolveTsconfigTarget(cwd);
2752
+ return target.reason === "none" ? null : target.path;
2753
+ }
2754
+ /**
2755
+ * The module's resolved TypeScript config: which preset, which file, how, and the
2756
+ * phased-strictness state (`deferred` rules that are off in phase 1). This is the
2757
+ * "list what's deferred" query, `sentinel --inspect --typescript`.
2758
+ */
2759
+ async inspect(ctx) {
2760
+ const target = resolveTsconfigTarget(ctx.cwd);
2761
+ const { preset, adopted } = readTsconfigAdoption(ctx.cwd);
2762
+ return {
2763
+ module: ctx.module,
2764
+ target: "typescript",
2765
+ // The machine-output key stays `flavour` for now. In this shape `preset` already
2766
+ // means the preset PATH, and the output is consumed (the migration status page reads
2767
+ // it), so it is renamed with the rest of the output when --report/--status change.
2768
+ flavour: ctx.preset,
2769
+ configFile: target.path,
2770
+ configState: target.reason,
2771
+ preset,
2772
+ adopted,
2773
+ phase: 1,
2774
+ deferred: DEFERRED_RULES
2775
+ };
2776
+ }
2777
+ /**
2778
+ * Adoption + conformity from the committed tsconfig, for `--status`. No tsc run and
2779
+ * no preset guessing: reads the actual `extends` chain, so a workspace-wide scan is
2780
+ * a cheap coverage + drift dashboard (adopted? which preset? drifted?).
2781
+ */
2782
+ async status(ctx) {
2783
+ const { adopted, preset, conformant, drift } = readTsconfigAdoption(ctx.cwd);
2784
+ return { adopted, preset, conformant, drift };
2785
+ }
2786
+ /**
2787
+ * Report conformance for the module: `tsc -b` (build mode, so app + spec are
2788
+ * covered) and count total type errors plus the implicit-`any` family (TS70xx:
2789
+ * 7006/7031/7053/… ), the signal that drives the noImplicitAny migration. No
2790
+ * tsconfig is a clean, empty report.
2791
+ */
2792
+ async report(ctx) {
2793
+ const config = this.typecheckTarget(ctx.cwd);
2794
+ if (!config) {
2795
+ return { ok: true, code: 0, metrics: { errors: 0, implicitAny: "deferred", diagnostics: [] } };
2796
+ }
2797
+ const tsc = resolveBin(ctx.cwd, "tsc") ?? "tsc";
2798
+ const result = spawnSync2(tsc, ["-b", config], { cwd: ctx.cwd, encoding: "utf8" });
2799
+ if (result.error) {
2800
+ process.stderr.write(
2801
+ `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
2802
+ `
2803
+ );
2804
+ return { ok: false, code: 1, metrics: { error: "tsc not available" } };
2805
+ }
2806
+ const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
2807
+ this.explainMissingPreset(output);
2808
+ const errors = (output.match(/error TS\d+/g) ?? []).length;
2809
+ const parsed = parseDiagnostics(output);
2810
+ const cap = ctx.maxDiagnostics === 0 ? Infinity : ctx.maxDiagnostics ?? DEFAULT_MAX_DIAGNOSTICS;
2811
+ const diagnostics = Number.isFinite(cap) ? parsed.slice(0, cap) : parsed;
2812
+ return {
2813
+ ok: errors === 0,
2814
+ code: result.status ?? 0,
2815
+ metrics: {
2816
+ errors,
2817
+ implicitAny: this.implicitAnyMetric(ctx.cwd, tsc, config, output),
2818
+ diagnostics,
2819
+ diagnosticsTruncated: diagnostics.length < parsed.length
2820
+ }
2821
+ };
2822
+ }
2823
+ /**
2824
+ * The `implicitAny` report metric, honestly. Implicit-`any` violations (TS70xx) are
2825
+ * only *visible* to tsc when `noImplicitAny` is ON. In phase 1 the rule is DEFERRED
2826
+ * (off), so counting TS70xx from the committed-config run is structurally always 0 —
2827
+ * a misleading "no implicit-any" when the rule simply was not applied. So: report the
2828
+ * real count only when the rule is on, otherwise `'deferred'` (never a fake `0`). The
2829
+ * remaining debt while deferred is a separate, opt-in probe (a later `--migration`).
2830
+ */
2831
+ implicitAnyMetric(cwd, tsc, config, mainOutput) {
2832
+ return this.noImplicitAnyEnabled(cwd, tsc, config, mainOutput) ? (mainOutput.match(/error TS70\d\d/g) ?? []).length : "deferred";
2833
+ }
2834
+ /**
2835
+ * Whether `noImplicitAny` is effectively ON in the module's resolved config. Read from
2836
+ * `tsc --showConfig` (an explicit value wins; otherwise `strict` implies it). If
2837
+ * `--showConfig` is unavailable, fall back to the run's own evidence: implicit-`any`
2838
+ * errors in the output mean the rule must be on.
2839
+ */
2840
+ noImplicitAnyEnabled(cwd, tsc, config, mainOutput) {
2841
+ const shown = spawnSync2(tsc, ["-p", config, "--showConfig"], { cwd, encoding: "utf8" });
2842
+ if (shown.status === 0 && shown.stdout) {
2843
+ try {
2844
+ const co = parseJsonc(
2845
+ shown.stdout,
2846
+ "tsconfig(--showConfig)"
2847
+ ).compilerOptions;
2848
+ if (co) return co.noImplicitAny ?? co.strict === true;
2849
+ } catch {
2850
+ }
2851
+ }
2852
+ return /error TS70\d\d/.test(mainOutput);
2853
+ }
2854
+ };
2855
+
2856
+ // src/roles/typescript/register.ts
2857
+ function registerTypescript() {
2858
+ register(new TscAdapter());
2859
+ setDefaultRunner("typescript", "tsc");
2860
+ }
2861
+
2862
+ // src/adapters.ts
2863
+ function registerAdapters() {
2864
+ registerTypescript();
2865
+ registerLint();
2866
+ }
2867
+
2868
+ // src/core/detect-framework.ts
2869
+ var FRAMEWORK_SIGNALS = [
2870
+ { preset: "nest", dependency: "@nestjs/core" },
2871
+ { preset: "react", dependency: "react" },
2872
+ { preset: "svelte", dependency: "svelte" }
2873
+ ];
2874
+ var BACKEND_HINTS = ["sails", "express", "koa", "fastify", "@hapi/hapi"];
2875
+ function describeFramework(packageJson) {
2876
+ const dependencies = { ...packageJson.dependencies, ...packageJson.devDependencies };
2877
+ const matched = FRAMEWORK_SIGNALS.filter((s) => s.dependency in dependencies);
2878
+ const chosen = matched[0];
2879
+ const preset = chosen?.preset ?? "node";
2880
+ const source = chosen?.dependency ?? "(no framework dependency)";
2881
+ const otherFlavourSignals = matched.slice(1).map((s) => s.dependency);
2882
+ const backendHints = preset === "react" || preset === "svelte" ? BACKEND_HINTS.filter((hint) => hint in dependencies) : [];
2883
+ const conflicts = [...otherFlavourSignals, ...backendHints];
2884
+ return { preset, source, ambiguous: conflicts.length > 0, conflicts };
2885
+ }
2886
+ function detectFramework(packageJson) {
2887
+ return describeFramework(packageJson).preset;
2888
+ }
2889
+
2890
+ // src/shared/text.ts
2891
+ function ensureLines(current, lines) {
2892
+ const present = new Set(current.split("\n").map((line) => line.trim()));
2893
+ const missing = lines.filter((line) => !present.has(line.trim()));
2894
+ if (missing.length === 0) return current;
2895
+ const prefix = current.length === 0 || current.endsWith("\n") ? current : current + "\n";
2896
+ return prefix + missing.join("\n") + "\n";
2897
+ }
2898
+ function toLines(text) {
2899
+ if (text.length === 0) return [];
2900
+ return text.replace(/\n$/, "").split("\n");
2901
+ }
2902
+ function diffLines(before, after) {
2903
+ const from = toLines(before);
2904
+ const to = toLines(after);
2905
+ const lcs = Array.from(
2906
+ { length: from.length + 1 },
2907
+ () => new Array(to.length + 1).fill(0)
2908
+ );
2909
+ const cell = (i2, j2) => lcs[i2]?.[j2] ?? 0;
2910
+ for (let i2 = from.length - 1; i2 >= 0; i2--) {
2911
+ const row = lcs[i2];
2912
+ if (!row) continue;
2913
+ for (let j2 = to.length - 1; j2 >= 0; j2--) {
2914
+ row[j2] = from[i2] === to[j2] ? cell(i2 + 1, j2 + 1) + 1 : Math.max(cell(i2 + 1, j2), cell(i2, j2 + 1));
2915
+ }
2916
+ }
2917
+ const out = [];
2918
+ let i = 0;
2919
+ let j = 0;
2920
+ while (i < from.length && j < to.length) {
2921
+ if (from[i] === to[j]) {
2922
+ out.push(` ${from[i] ?? ""}`);
2923
+ i++;
2924
+ j++;
2925
+ } else if (cell(i + 1, j) >= cell(i, j + 1)) {
2926
+ out.push(`- ${from[i] ?? ""}`);
2927
+ i++;
2928
+ } else {
2929
+ out.push(`+ ${to[j] ?? ""}`);
2930
+ j++;
2931
+ }
2932
+ }
2933
+ while (i < from.length) out.push(`- ${from[i++] ?? ""}`);
2934
+ while (j < to.length) out.push(`+ ${to[j++] ?? ""}`);
2935
+ return out;
2936
+ }
2937
+ function replaceLines(current, replacements) {
2938
+ if (replacements.length === 0) return current;
2939
+ const byLine = new Map(replacements.map((entry) => [entry.from, entry.to]));
2940
+ return current.split("\n").map((line) => byLine.get(line) ?? line).join("\n");
2941
+ }
2942
+
2943
+ // src/core/apply-plan.ts
2944
+ import { existsSync as existsSync10, readFileSync as readFileSync9, renameSync, rmSync, writeFileSync } from "fs";
2945
+ import { resolve as resolve2, sep } from "path";
2946
+ import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser";
2947
+
2948
+ // src/shared/deep-merge.ts
2949
+ function isPlainObject(value) {
2950
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2951
+ }
2952
+
2953
+ // src/core/apply-plan.ts
2954
+ function resolveWithinRoot(cwd, relativePath) {
2955
+ const root = resolve2(cwd);
2956
+ const absolutePath = resolve2(root, relativePath);
2957
+ if (absolutePath !== root && !absolutePath.startsWith(root + sep)) {
2958
+ throw new Error(`Refusing to write outside the module root: "${relativePath}".`);
2959
+ }
2960
+ return absolutePath;
2961
+ }
2962
+ function readIfExists(absolutePath) {
2963
+ return existsSync10(absolutePath) ? readFileSync9(absolutePath, "utf8") : void 0;
2964
+ }
2965
+ function* leaves(value, prefix = []) {
2966
+ for (const [key, keyValue] of Object.entries(value)) {
2967
+ const path = [...prefix, key];
2968
+ if (isPlainObject(keyValue)) yield* leaves(keyValue, path);
2969
+ else yield [path, keyValue];
2970
+ }
2971
+ }
2972
+ function mergeJsonc(current, value) {
2973
+ let text = current.trim().length > 0 ? current : "{}\n";
2974
+ for (const [path, leaf] of leaves(value)) {
2975
+ const edits = modify(text, path, leaf, {
2976
+ formattingOptions: { insertSpaces: true, tabSize: 2 }
2977
+ });
2978
+ text = applyEdits(text, edits);
2979
+ }
2980
+ return text.endsWith("\n") ? text : text + "\n";
2981
+ }
2982
+ function removeJsoncKeys(current, keys) {
2983
+ let text = current.trim().length > 0 ? current : "{}\n";
2984
+ for (const path of keys) {
2985
+ const root = parseTree(text);
2986
+ if (!root || !findNodeAtLocation(root, path)) continue;
2987
+ const edits = modify(text, path, void 0, {
2988
+ formattingOptions: { insertSpaces: true, tabSize: 2 }
2989
+ });
2990
+ text = applyEdits(text, edits);
2991
+ }
2992
+ return text.endsWith("\n") ? text : text + "\n";
2993
+ }
2994
+ function sameJson(left, right) {
2995
+ try {
2996
+ return JSON.stringify(parseJsonc(left, "a")) === JSON.stringify(parseJsonc(right, "b"));
2997
+ } catch {
2998
+ return false;
2999
+ }
3000
+ }
3001
+ function applyOperationTo(current, operation) {
3002
+ switch (operation.kind) {
3003
+ case "write":
3004
+ return sameJson(current, operation.contents) ? current : operation.contents;
3005
+ case "merge-json":
3006
+ return mergeJsonc(current, operation.value);
3007
+ case "ensure-lines":
3008
+ return ensureLines(current, operation.lines);
3009
+ case "remove-json-keys":
3010
+ return removeJsoncKeys(current, operation.keys);
3011
+ case "replace-lines":
3012
+ return replaceLines(current, operation.replacements);
3013
+ case "delete":
3014
+ return "";
3015
+ default: {
3016
+ const unreachable = operation;
3017
+ throw new Error(`Unknown file operation: ${JSON.stringify(unreachable)}`);
3018
+ }
3019
+ }
3020
+ }
3021
+ function preparePlan(cwd, plan) {
3022
+ const prepared = /* @__PURE__ */ new Map();
3023
+ for (const operation of plan.operations) {
3024
+ const absolutePath = resolveWithinRoot(cwd, operation.path);
3025
+ const existing = prepared.get(operation.path);
3026
+ const before = existing?.before ?? readIfExists(absolutePath) ?? "";
3027
+ const current = existing?.after ?? before;
3028
+ prepared.set(operation.path, {
3029
+ path: operation.path,
3030
+ absolutePath,
3031
+ before,
3032
+ after: applyOperationTo(current, operation),
3033
+ // A later operation on the same path un-deletes it: the last word wins, the same
3034
+ // way content operations chain.
3035
+ deleted: operation.kind === "delete"
3036
+ });
3037
+ }
3038
+ return [...prepared.values()];
3039
+ }
3040
+ function writeFileAtomic(absolutePath, contents) {
3041
+ const tempPath = `${absolutePath}.sentinel-${process.pid}.tmp`;
3042
+ writeFileSync(tempPath, contents);
3043
+ renameSync(tempPath, absolutePath);
3044
+ }
3045
+ function applyPlan(cwd, plan) {
3046
+ const changed = preparePlan(cwd, plan).filter((file) => file.before !== file.after);
3047
+ for (const file of changed) {
3048
+ if (file.deleted) {
3049
+ rmSync(file.absolutePath, { force: true });
3050
+ continue;
3051
+ }
3052
+ writeFileAtomic(file.absolutePath, file.after);
3053
+ }
3054
+ return changed.map((file) => file.path);
3055
+ }
3056
+
3057
+ // src/core/dispatch.ts
3058
+ function resolveFlavour(opts) {
3059
+ if (opts.preset) return opts.preset;
3060
+ const detection = describeFramework(readProjectPackageJson(opts.cwd));
3061
+ if (detection.ambiguous) {
3062
+ const warn = palette(process.stderr);
3063
+ process.stderr.write(
3064
+ warn.warn(
3065
+ `sentinel: preset is ambiguous, detected "${detection.preset}" (from ${detection.source}), also found ${detection.conflicts.join(", ")}. Pass --preset to write the intended preset.`
3066
+ ) + "\n"
3067
+ );
3068
+ }
3069
+ return detection.preset;
3070
+ }
3071
+ function previewPlan(opts, plan) {
3072
+ const changed = preparePlan(opts.cwd, plan).filter((file) => file.before !== file.after);
3073
+ if (opts.json) {
3074
+ process.stdout.write(
3075
+ JSON.stringify(
3076
+ {
3077
+ dryRun: true,
3078
+ notes: plan.notes ?? [],
3079
+ files: changed.map(({ path, before, after, deleted }) => ({
3080
+ path,
3081
+ action: deleted ? "delete" : before.length === 0 ? "create" : "update",
3082
+ before,
3083
+ after
3084
+ }))
3085
+ },
3086
+ null,
3087
+ 2
3088
+ ) + "\n"
3089
+ );
3090
+ return 0;
3091
+ }
3092
+ process.stderr.write(" dry run: no files written\n");
3093
+ for (const note of plan.notes ?? []) process.stderr.write(` ${note}
3094
+ `);
3095
+ if (changed.length === 0) {
3096
+ process.stderr.write(" nothing to change\n");
3097
+ return 0;
3098
+ }
3099
+ for (const { path, before, after, deleted } of changed) {
3100
+ const action = deleted ? "delete" : before.length === 0 ? "create" : "update";
3101
+ process.stdout.write(`
3102
+ ${action} ${path}
3103
+ `);
3104
+ for (const line of diffLines(before, after)) process.stdout.write(` ${line}
3105
+ `);
3106
+ }
3107
+ return 0;
3108
+ }
3109
+ async function dispatch(opts) {
3110
+ if (opts.verb !== "init") {
3111
+ throw new Error(`dispatch handles --init only; --${opts.verb} routes through analyse()`);
3112
+ }
3113
+ const detected = resolveFlavour(opts);
3114
+ const adapter = resolve(opts.target, detected, opts.runner);
3115
+ const preset = opts.preset ?? adapter.declaredPreset?.(opts.cwd) ?? detected;
3116
+ const context = { cwd: opts.cwd, preset };
3117
+ const plan = await adapter.plan(context);
3118
+ if (plan.blocked) {
3119
+ process.stderr.write(`sentinel (${opts.target}): ${plan.blocked}
3120
+ `);
3121
+ return 1;
3122
+ }
3123
+ if (opts.dryRun) {
3124
+ return previewPlan(opts, plan);
3125
+ }
3126
+ const written = applyPlan(opts.cwd, plan);
3127
+ for (const path of written) process.stderr.write(` wrote ${path}
3128
+ `);
3129
+ for (const note of plan.notes ?? []) process.stderr.write(` ${note}
3130
+ `);
3131
+ return 0;
3132
+ }
3133
+
3134
+ export {
3135
+ register,
3136
+ setDefaultRunner,
3137
+ all,
3138
+ availableTargets,
3139
+ resolve,
3140
+ BaseAdapter,
3141
+ palette,
3142
+ readOwnPackage,
3143
+ readOwnVersion,
3144
+ readProjectPackageJson,
3145
+ readNxProjectName,
3146
+ resolveBin,
3147
+ VERBS,
3148
+ TARGETS,
3149
+ PRESET_NAMES,
3150
+ registerAdapters,
3151
+ describeFramework,
3152
+ detectFramework,
3153
+ dispatch
3154
+ };
3155
+ //# sourceMappingURL=chunk-FLLX5MN3.js.map