@useinsider/eslint-config 4.0.0-beta.1 → 4.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -5,9 +5,10 @@ import { defineConfig } from "eslint/config";
5
5
  import globals from "globals";
6
6
  import { existsSync, readFileSync, realpathSync } from "node:fs";
7
7
  import { createRequire } from "node:module";
8
- import { dirname, join, resolve } from "node:path";
8
+ import { basename, dirname, join, resolve } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { AST_NODE_TYPES } from "@typescript-eslint/types";
11
+ import { spawnSync } from "node:child_process";
11
12
  import { installPackage } from "@antfu/install-pkg";
12
13
  import semver from "semver";
13
14
  import { warn } from "node:console";
@@ -209,14 +210,7 @@ function buildOxlintConfig(blocks, ignores) {
209
210
  };
210
211
  }
211
212
  //#endregion
212
- //#region src/utils/ensureConfigStructure.ts
213
- var ANCHOR_FILE_REGEX = /(?:insider\.lint|eslint\.config)\.[mc]?[jt]s(?:$|\?)/;
214
- var OPT_OUT_ENV$1 = "INSIDER_SKIP_CONFIG_VALIDATION";
215
- var FILE_URL_PREFIX = "file://";
216
- var TARGET_CALLEE_NAME = "useInsider";
217
- var ESLINT_RENDERER_NAME = "useInsiderESLint";
218
- var OXC_RENDERER_NAME = "useInsiderOXC";
219
- var INSIDER_IMPORT_SOURCE = "@useinsider/eslint-config";
213
+ //#region src/utils/layoutDiagnosis.ts
220
214
  var MODULE_EXTENSIONS = [
221
215
  "ts",
222
216
  "mts",
@@ -225,9 +219,327 @@ var MODULE_EXTENSIONS = [
225
219
  "mjs",
226
220
  "cjs"
227
221
  ];
228
- var RENDERER_EXPORT = `export const ${`{ ${ESLINT_RENDERER_NAME}, ${OXC_RENDERER_NAME} }`} = ${TARGET_CALLEE_NAME}({ ... })`;
229
222
  var LINT_MODULE_FILENAMES = MODULE_EXTENSIONS.map((ext) => `insider.lint.${ext}`);
230
223
  var ESLINT_CONFIG_FILENAMES = MODULE_EXTENSIONS.map((ext) => `eslint.config.${ext}`);
224
+ var OXLINT_CONFIG_FILENAMES = ["oxlint.config.ts", "oxlint.config.mts"];
225
+ var OPT_OUT_ENV = "INSIDER_SKIP_CONFIG_VALIDATION";
226
+ var MIGRATE_COMMAND = "npx @useinsider/eslint-config migrate";
227
+ var SCAFFOLD_COMMAND = "npx @useinsider/eslint-config";
228
+ var INSIDER_IMPORT_SOURCE = "@useinsider/eslint-config";
229
+ var TARGET_CALLEE_NAME = "useInsider";
230
+ var ESLINT_RENDERER_NAME = "useInsiderESLint";
231
+ var OXC_RENDERER_NAME = "useInsiderOXC";
232
+ var RENDERER_EXPORT = `export const ${`{ ${ESLINT_RENDERER_NAME}, ${OXC_RENDERER_NAME} }`} = ${TARGET_CALLEE_NAME}({ ... })`;
233
+ var EXPECTED_LAYOUT = [
234
+ "Expected layout (three sibling files):",
235
+ "",
236
+ " // insider.lint.ts",
237
+ ` import { ${TARGET_CALLEE_NAME} } from '${INSIDER_IMPORT_SOURCE}';`,
238
+ "",
239
+ ` ${RENDERER_EXPORT};`,
240
+ "",
241
+ " // eslint.config.ts",
242
+ ` import { ${ESLINT_RENDERER_NAME} } from './insider.lint.ts';`,
243
+ "",
244
+ ` export default ${ESLINT_RENDERER_NAME}();`,
245
+ "",
246
+ " // oxlint.config.ts",
247
+ ` import { ${OXC_RENDERER_NAME} } from './insider.lint.ts';`,
248
+ "",
249
+ ` export default await ${OXC_RENDERER_NAME}();`
250
+ ].join("\n");
251
+ function findSibling(directory, filenames, fileExists) {
252
+ return filenames.find((filename) => fileExists(join(directory, filename))) ?? null;
253
+ }
254
+ var lazyRequire$1 = createRequire(import.meta.url);
255
+ function parseProgram(source) {
256
+ try {
257
+ const { parse } = lazyRequire$1("@typescript-eslint/parser");
258
+ return parse(source, {
259
+ sourceType: "module",
260
+ range: true,
261
+ loc: true
262
+ });
263
+ } catch {
264
+ return null;
265
+ }
266
+ }
267
+ function collectUseInsiderAliases(program) {
268
+ const aliases = /* @__PURE__ */ new Set();
269
+ program.body.forEach((node) => {
270
+ if (node.type !== AST_NODE_TYPES.ImportDeclaration) return;
271
+ node.specifiers.forEach((specifier) => {
272
+ if (specifier.type === AST_NODE_TYPES.ImportSpecifier && specifier.imported.type === AST_NODE_TYPES.Identifier && specifier.imported.name === "useInsider") aliases.add(specifier.local.name);
273
+ });
274
+ });
275
+ return aliases;
276
+ }
277
+ function callsAny(node, aliases) {
278
+ if (!node || typeof node !== "object") return false;
279
+ if (Array.isArray(node)) return node.some((child) => callsAny(child, aliases));
280
+ const candidate = node;
281
+ if (candidate.type === AST_NODE_TYPES.CallExpression) {
282
+ const { callee } = candidate;
283
+ if (callee.type === AST_NODE_TYPES.Identifier && aliases.has(callee.name)) return true;
284
+ }
285
+ return Object.entries(node).some(([key, value]) => key !== "parent" && callsAny(value, aliases));
286
+ }
287
+ /**
288
+ * Whether a flat-config source is still the removed v3 shape: it imports `useInsider` and calls it
289
+ * itself, instead of importing a renderer from a sibling `insider.lint.*` module. Both the plain
290
+ * `export default useInsider({...})` form and the spread form are recognized.
291
+ * @example
292
+ * isV3EslintConfigSource("import { useInsider } from '@useinsider/eslint-config';\nexport default useInsider({});");
293
+ * // true
294
+ */
295
+ function isV3EslintConfigSource(source) {
296
+ const program = parseProgram(source);
297
+ if (!program) return false;
298
+ const aliases = collectUseInsiderAliases(program);
299
+ if (aliases.size === 0) return false;
300
+ return program.body.some((node) => callsAny(node, aliases));
301
+ }
302
+ function classify(lintModule, eslintConfig, oxlintConfig, eslintConfigIsV3) {
303
+ if (lintModule && eslintConfig && oxlintConfig) return "complete";
304
+ if (!lintModule && !eslintConfig && !oxlintConfig) return "noConfig";
305
+ if (!lintModule && eslintConfig && eslintConfigIsV3) return "unmigratedV3";
306
+ return "partial";
307
+ }
308
+ /**
309
+ * What of the three-file layout is on disk in a directory, and which of the four guidance branches
310
+ * applies. `unmigratedV3` is the only branch the in-lint migration offer acts on, because it is the
311
+ * only one {@link import('../cli/migrate').runMigrate} can actually rewrite.
312
+ * @example
313
+ * diagnoseLayout('/repo').kind; // 'unmigratedV3' when eslint.config.js still calls useInsider()
314
+ */
315
+ function diagnoseLayout(directory, options = {}) {
316
+ const fileExists = options.fileExists ?? existsSync;
317
+ const readSource = options.readSource ?? ((path) => readFileSync(path, "utf8"));
318
+ const lintModule = findSibling(directory, LINT_MODULE_FILENAMES, fileExists);
319
+ const eslintConfig = findSibling(directory, ESLINT_CONFIG_FILENAMES, fileExists);
320
+ const oxlintConfig = findSibling(directory, OXLINT_CONFIG_FILENAMES, fileExists);
321
+ let eslintConfigIsV3 = false;
322
+ if (eslintConfig && !lintModule) try {
323
+ eslintConfigIsV3 = isV3EslintConfigSource(readSource(join(directory, eslintConfig)));
324
+ } catch {
325
+ eslintConfigIsV3 = false;
326
+ }
327
+ const kind = classify(lintModule, eslintConfig, oxlintConfig, eslintConfigIsV3);
328
+ return {
329
+ kind,
330
+ directory,
331
+ lintModule,
332
+ eslintConfig,
333
+ oxlintConfig,
334
+ missing: [
335
+ lintModule ? null : "insider.lint.ts",
336
+ eslintConfig ? null : "eslint.config.ts",
337
+ oxlintConfig ? null : "oxlint.config.ts"
338
+ ].filter((name) => name !== null),
339
+ autoMigratable: kind === "unmigratedV3"
340
+ };
341
+ }
342
+ /**
343
+ * Import specifier for the consumer's lint module, naming the extension that is actually on disk so
344
+ * the suggested snippet resolves as printed.
345
+ * @example
346
+ * lintModuleSpecifier('/repo'); // './insider.lint.mts' when that is the file present
347
+ */
348
+ function lintModuleSpecifier(directory, fileExists = existsSync) {
349
+ return `./${findSibling(directory, LINT_MODULE_FILENAMES, fileExists) ?? "insider.lint.ts"}`;
350
+ }
351
+ function inventoryLines(diagnosis) {
352
+ const v3Note = diagnosis.kind === "unmigratedV3" ? " (still calls `useInsider()` itself)" : "";
353
+ return [
354
+ diagnosis.lintModule ? ` present: ${diagnosis.lintModule}` : " MISSING: insider.lint.{ts,mts}",
355
+ diagnosis.eslintConfig ? ` present: ${diagnosis.eslintConfig}${v3Note}` : " MISSING: eslint.config.{ts,mts}",
356
+ diagnosis.oxlintConfig ? ` present: ${diagnosis.oxlintConfig}` : ` MISSING: ${OXLINT_CONFIG_FILENAMES.join(" or ")}`
357
+ ];
358
+ }
359
+ function guidanceBody(diagnosis) {
360
+ if (diagnosis.kind === "unmigratedV3") return [
361
+ "`useInsider()` no longer returns a flat-config array, so the config it is called from has",
362
+ "to become a module both linters can import. The migration does that rewrite for you:",
363
+ "",
364
+ ` ${MIGRATE_COMMAND}`
365
+ ];
366
+ if (diagnosis.kind === "noConfig") return [
367
+ "There is no linter configuration here yet. Scaffold the three-file layout:",
368
+ "",
369
+ ` ${SCAFFOLD_COMMAND}`
370
+ ];
371
+ return [
372
+ `The layout is incomplete — missing ${diagnosis.missing.join(", ")}.`,
373
+ "Add the missing file, or let the migration regenerate the whole layout:",
374
+ "",
375
+ ` ${MIGRATE_COMMAND}`
376
+ ];
377
+ }
378
+ function detailLines(detail) {
379
+ if (!detail || detail.length === 0) return [];
380
+ return ["", ...detail];
381
+ }
382
+ /**
383
+ * The consumer-facing failure text: a site-specific headline, then the branch that matches what is
384
+ * on disk, then the layout dump, then the bypass. The command to run always comes before the dump,
385
+ * because a consumer who reads only the first screen still has to be told about `migrate`.
386
+ * @example
387
+ * formatLayoutGuidance({ headline: 'no `insider.lint.*` module found in /repo.', diagnosis });
388
+ */
389
+ function formatLayoutGuidance(input) {
390
+ return [
391
+ `@useinsider/eslint-config: ${input.headline}`,
392
+ "",
393
+ ...inventoryLines(input.diagnosis),
394
+ "",
395
+ ...guidanceBody(input.diagnosis),
396
+ ...detailLines(input.detail),
397
+ "",
398
+ EXPECTED_LAYOUT,
399
+ "",
400
+ `Emergency bypass: set ${OPT_OUT_ENV}=true (do not commit).`
401
+ ].join("\n");
402
+ }
403
+ //#endregion
404
+ //#region src/utils/offerMigration.ts
405
+ var PACKAGE_NAME = "@useinsider/eslint-config";
406
+ var CLI_RELATIVE_PATH = "dist/cli.cjs";
407
+ var NO_PROMPT_ENV = "INSIDER_NO_PROMPT";
408
+ var IN_FLIGHT_ENV = "INSIDER_MIGRATION_IN_PROGRESS";
409
+ var MIGRATE_FROM_LINT_ARGS = ["migrate", "--from-lint"];
410
+ var BINARY_EXTENSIONS = /\.[mc]?js$/u;
411
+ function binaryName(argument) {
412
+ return basename(argument).replace(BINARY_EXTENSIONS, "");
413
+ }
414
+ /**
415
+ * Which linter is hosting the current process. oxlint evaluates `oxlint.config.ts` inside the Node
416
+ * process that runs its own `bin/oxlint` wrapper, so `useInsider()` genuinely executes under both
417
+ * binaries and the two have to be told apart before anything is written to stdout.
418
+ * @example
419
+ * detectLintHost(['node', '/app/node_modules/oxlint/bin/oxlint', '.']); // 'oxlint'
420
+ */
421
+ function detectLintHost(argv = process.argv) {
422
+ const names = argv.slice(1).map(binaryName);
423
+ if (names.includes("oxlint")) return "oxlint";
424
+ if (names.includes("eslint")) return "eslint";
425
+ return "unknown";
426
+ }
427
+ function defaultInteractive() {
428
+ return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
429
+ }
430
+ function skipReasonFor(env, argv, interactive) {
431
+ if (env[IN_FLIGHT_ENV] === "true") return "already-offered";
432
+ if (env.VITEST || env.NODE_ENV === "test") return "running-under-test";
433
+ if (env["INSIDER_SKIP_CONFIG_VALIDATION"] === "true" || env[NO_PROMPT_ENV] === "true") return "suppressed";
434
+ if (env.CI) return "running-in-ci";
435
+ if (detectLintHost(argv) === "oxlint") return "host-is-oxlint";
436
+ if (!interactive) return "no-tty";
437
+ return null;
438
+ }
439
+ function manifestNameAt(directory) {
440
+ const manifestPath = join(directory, "package.json");
441
+ if (!existsSync(manifestPath)) return null;
442
+ try {
443
+ return JSON.parse(readFileSync(manifestPath, "utf8")).name ?? null;
444
+ } catch {
445
+ return null;
446
+ }
447
+ }
448
+ function packageRootFrom(startDir) {
449
+ let current = startDir;
450
+ for (let depth = 0; depth < 10; depth += 1) {
451
+ if (manifestNameAt(current) === PACKAGE_NAME) return current;
452
+ const parent = dirname(current);
453
+ if (parent === current) return null;
454
+ current = parent;
455
+ }
456
+ return null;
457
+ }
458
+ /**
459
+ * Absolute path to this package's own built CLI, or `null` when it is not on disk. Resolved by
460
+ * walking up from `startDir` — this module's own directory by default — rather than through
461
+ * `require.resolve`, so it works from `dist/` and from `src/` alike.
462
+ * @example
463
+ * resolveCliEntry(); // '/app/node_modules/@useinsider/eslint-config/dist/cli.cjs'
464
+ */
465
+ function resolveCliEntry(startDir) {
466
+ const root = packageRootFrom(startDir ?? dirname(fileURLToPath(import.meta.url)));
467
+ if (!root) return null;
468
+ const entry = resolve(root, CLI_RELATIVE_PATH);
469
+ return existsSync(entry) ? entry : null;
470
+ }
471
+ function defaultRunCli(entry, args, cwd, env) {
472
+ const result = spawnSync(process.execPath, [entry, ...args], {
473
+ cwd,
474
+ env,
475
+ stdio: "inherit"
476
+ });
477
+ if (result.error) return 1;
478
+ return result.status ?? 1;
479
+ }
480
+ function defaultWarn(message) {
481
+ console.error(message);
482
+ }
483
+ function outdatedNotice(diagnosis) {
484
+ const config = diagnosis.eslintConfig ?? "eslint.config.*";
485
+ return [
486
+ `@useinsider/eslint-config: ${config} still uses the v3 single-file layout.`,
487
+ ` ${join(diagnosis.directory, config)} calls \`useInsider()\` itself, and`,
488
+ ` ${diagnosis.missing.join(" and ")} ${diagnosis.missing.length === 1 ? "is" : "are"} missing.`,
489
+ "",
490
+ "Starting the guided migration. It shows every change before applying it.",
491
+ `Skip it with ${NO_PROMPT_ENV}=true.`,
492
+ ""
493
+ ].join("\n");
494
+ }
495
+ var offerAttempted = false;
496
+ function withDefaults(deps) {
497
+ return {
498
+ env: deps.env ?? process.env,
499
+ argv: deps.argv ?? process.argv,
500
+ interactive: deps.interactive ?? defaultInteractive(),
501
+ resolveCliEntry: deps.resolveCliEntry ?? resolveCliEntry,
502
+ runCli: deps.runCli ?? defaultRunCli,
503
+ warn: deps.warn ?? defaultWarn
504
+ };
505
+ }
506
+ /**
507
+ * Offers the guided migration for an outdated layout and runs it when the consumer accepts. Declines
508
+ * silently — returning a skip reason instead of prompting — whenever writing to stdout would be
509
+ * wrong: under oxlint, in CI, without a TTY, or when already inside a migration.
510
+ * @example
511
+ * offerMigration(diagnoseLayout('/repo')).accepted; // true after the consumer confirmed
512
+ */
513
+ function offerMigration(diagnosis, deps = {}) {
514
+ const resolved = withDefaults(deps);
515
+ if (offerAttempted) return {
516
+ offered: false,
517
+ skipReason: "already-offered"
518
+ };
519
+ const skipReason = skipReasonFor(resolved.env, resolved.argv, resolved.interactive);
520
+ if (skipReason) return {
521
+ offered: false,
522
+ skipReason
523
+ };
524
+ const entry = resolved.resolveCliEntry();
525
+ if (!entry) return {
526
+ offered: false,
527
+ skipReason: "cli-not-found"
528
+ };
529
+ offerAttempted = true;
530
+ resolved.warn(outdatedNotice(diagnosis));
531
+ return {
532
+ offered: true,
533
+ accepted: resolved.runCli(entry, MIGRATE_FROM_LINT_ARGS, diagnosis.directory, {
534
+ ...resolved.env,
535
+ [IN_FLIGHT_ENV]: "true"
536
+ }) === 0
537
+ };
538
+ }
539
+ //#endregion
540
+ //#region src/utils/ensureConfigStructure.ts
541
+ var ANCHOR_FILE_REGEX = /(?:insider\.lint|eslint\.config)\.[mc]?[jt]s(?:$|\?)/;
542
+ var FILE_URL_PREFIX = "file://";
231
543
  var bypassWarnEmitted = false;
232
544
  function computeSelfModulePath() {
233
545
  const raw = fileURLToPath(import.meta.url);
@@ -242,7 +554,7 @@ function isPathSpecifier(source) {
242
554
  return source.startsWith(".") || source.startsWith("/");
243
555
  }
244
556
  function defaultIsInsiderImportSource(source, configPath) {
245
- if (source === INSIDER_IMPORT_SOURCE) return true;
557
+ if (source === "@useinsider/eslint-config") return true;
246
558
  if (!configPath || !isPathSpecifier(source)) return false;
247
559
  try {
248
560
  const absolute = resolve(dirname(configPath), source);
@@ -338,7 +650,7 @@ function validateProgramStructure(program, configPath = "", options = {}) {
338
650
  const insiderBindings = /* @__PURE__ */ new Set();
339
651
  const resolver = options.isInsiderImportSource ?? defaultIsInsiderImportSource;
340
652
  const isInsiderSource = (source) => resolver(source, configPath);
341
- const expectedCallee = options.expectedCallee ?? ESLINT_RENDERER_NAME;
653
+ const expectedCallee = options.expectedCallee ?? "useInsiderESLint";
342
654
  let exportSeen = false;
343
655
  program.body.forEach((node) => {
344
656
  if (node.type === AST_NODE_TYPES.ImportDeclaration) collectInsiderBindings(node, insiderBindings, isInsiderSource);
@@ -354,7 +666,7 @@ function validateProgramStructure(program, configPath = "", options = {}) {
354
666
  if (!exportSeen) violations.push({
355
667
  line: 1,
356
668
  column: 0,
357
- reason: `Missing \`export default ${options.expectedCallee ?? ESLINT_RENDERER_NAME}()\`.`
669
+ reason: `Missing \`export default ${options.expectedCallee ?? "useInsiderESLint"}()\`.`
358
670
  });
359
671
  return violations;
360
672
  }
@@ -518,24 +830,6 @@ function captureConsumerConfigDir(configPath) {
518
830
  function getConsumerConfigDir() {
519
831
  return consumerConfigDir ?? process.cwd();
520
832
  }
521
- var EXPECTED_LAYOUT = [
522
- "Expected layout (three sibling files):",
523
- "",
524
- " // insider.lint.ts",
525
- " import { useInsider } from '@useinsider/eslint-config';",
526
- "",
527
- ` ${RENDERER_EXPORT};`,
528
- "",
529
- " // eslint.config.ts",
530
- ` import { ${ESLINT_RENDERER_NAME} } from './insider.lint.ts';`,
531
- "",
532
- ` export default ${ESLINT_RENDERER_NAME}();`,
533
- "",
534
- " // oxlint.config.ts",
535
- ` import { ${OXC_RENDERER_NAME} } from './insider.lint.ts';`,
536
- "",
537
- ` export default await ${OXC_RENDERER_NAME}();`
538
- ].join("\n");
539
833
  function formatReport(configPath, violations) {
540
834
  const header = ["@useinsider/eslint-config: protected configuration structure violated in:", ` ${configPath}`].join("\n");
541
835
  const lines = violations.map((violation) => ` - [${violation.line}:${violation.column}] ${violation.reason}`);
@@ -546,7 +840,7 @@ function formatReport(configPath, violations) {
546
840
  "",
547
841
  EXPECTED_LAYOUT,
548
842
  "",
549
- `Emergency bypass: set ${OPT_OUT_ENV$1}=true (do not commit).`
843
+ `Emergency bypass: set ${OPT_OUT_ENV}=true (do not commit).`
550
844
  ].join("\n");
551
845
  return `${header}\n${lines.join("\n")}\n${footer}`;
552
846
  }
@@ -596,46 +890,48 @@ function validateFile(filePath, validator, options) {
596
890
  if (violations.length === 0) return;
597
891
  fail(formatReport(filePath, violations));
598
892
  }
599
- function runValidation(anchorPath) {
600
- if (!anchorPath) fail([
601
- "@useinsider/eslint-config: unable to locate the consumer `insider.lint.*` module for structure validation.",
602
- "",
603
- EXPECTED_LAYOUT,
604
- "",
605
- `If this is an unexpected environment, set ${OPT_OUT_ENV$1}=true (do not commit).`
606
- ].join("\n"));
893
+ var MIGRATED_EXIT_NOTICE = [
894
+ "@useinsider/eslint-config: migration finished.",
895
+ "",
896
+ "Re-run your lint command. This process already loaded the old configuration, so continuing now",
897
+ "would lint against a config that no longer exists on disk."
898
+ ].join("\n");
899
+ var DECLINED_DETAIL = ["The guided migration was not completed, so nothing was written."];
900
+ function failWithGuidance(headline, diagnosis, detail) {
901
+ return fail(formatLayoutGuidance({
902
+ headline,
903
+ diagnosis,
904
+ detail
905
+ }));
906
+ }
907
+ function handleOutdatedLayout(headline, diagnosis, offer) {
908
+ if (!diagnosis.autoMigratable) return failWithGuidance(headline, diagnosis);
909
+ const outcome = offer(diagnosis);
910
+ if (!outcome.offered) return failWithGuidance(headline, diagnosis);
911
+ if (!outcome.accepted) return failWithGuidance(headline, diagnosis, DECLINED_DETAIL);
912
+ return fail(MIGRATED_EXIT_NOTICE);
913
+ }
914
+ function runValidation(anchorPath, deps = {}) {
915
+ const diagnose = deps.diagnose ?? ((directory) => diagnoseLayout(directory));
916
+ const offer = deps.offer ?? offerMigration;
917
+ if (!anchorPath) handleOutdatedLayout("unable to locate the consumer `insider.lint.*` module for structure validation.", diagnose(process.cwd()), offer);
607
918
  const directory = dirname(resolve(anchorPath));
608
919
  const lintModulePath = resolveSibling(directory, LINT_MODULE_FILENAMES);
609
- if (!lintModulePath) fail([
610
- `@useinsider/eslint-config: no \`insider.lint.*\` module found in ${directory}.`,
611
- "",
612
- `\`${TARGET_CALLEE_NAME}()\` now returns the two renderers instead of a flat-config array,`,
613
- "so the config it is called from must be a module both linters can import.",
614
- "",
615
- EXPECTED_LAYOUT,
616
- "",
617
- `Emergency bypass: set ${OPT_OUT_ENV$1}=true (do not commit).`
618
- ].join("\n"));
920
+ if (!lintModulePath) handleOutdatedLayout(`no \`insider.lint.*\` module found in ${directory}.`, diagnose(directory), offer);
619
921
  validateFile(lintModulePath, validateLintModuleStructure, {});
620
922
  const eslintConfigPath = resolveSibling(directory, ESLINT_CONFIG_FILENAMES);
621
- if (!eslintConfigPath) fail([
622
- `@useinsider/eslint-config: no \`eslint.config.*\` file found in ${directory}.`,
623
- "",
624
- EXPECTED_LAYOUT,
625
- "",
626
- `Emergency bypass: set ${OPT_OUT_ENV$1}=true (do not commit).`
627
- ].join("\n"));
923
+ if (!eslintConfigPath) failWithGuidance(`no \`eslint.config.*\` file found in ${directory}.`, diagnose(directory), [`\`${TARGET_CALLEE_NAME}()\` returns renderers, not a flat-config array, so ESLint still`, `needs a config file whose default export is \`${ESLINT_RENDERER_NAME}()\`.`]);
628
924
  validateFile(eslintConfigPath, validateProgramStructure, { expectedCallee: ESLINT_RENDERER_NAME });
629
925
  }
630
- function ensureConfigStructure(overridePath) {
926
+ function ensureConfigStructure(overridePath, deps = {}) {
631
927
  const configPath = overridePath === void 0 ? getConsumerConfigPath() : overridePath;
632
928
  captureConsumerConfigDir(configPath);
633
- if (process.env[OPT_OUT_ENV$1] === "true") {
929
+ if (process.env["INSIDER_SKIP_CONFIG_VALIDATION"] === "true") {
634
930
  emitBypassWarning();
635
931
  return;
636
932
  }
637
933
  try {
638
- runValidation(configPath);
934
+ runValidation(configPath, deps);
639
935
  } catch (error) {
640
936
  if (error instanceof ConfigStructureExit) return;
641
937
  throw error;
@@ -759,6 +1055,14 @@ function clonePreset(preset) {
759
1055
  function isScopeName(key) {
760
1056
  return Object.keys(scopeMap).includes(key);
761
1057
  }
1058
+ /**
1059
+ * Every scope name `useInsider()` accepts, for error messages that have to spell the options out.
1060
+ * @example
1061
+ * scopeNames().includes('typescriptNode'); // true
1062
+ */
1063
+ function scopeNames() {
1064
+ return Object.keys(scopeMap);
1065
+ }
762
1066
  function applyCommonEntryFields(block, entry) {
763
1067
  if (entry.ignores && entry.ignores.length > 0) block.ignores = [...entry.ignores];
764
1068
  if (entry.globals) {
@@ -878,31 +1182,27 @@ function silenceDependencyWarning(input) {
878
1182
  }
879
1183
  //#endregion
880
1184
  //#region src/utils/ensureOxlintConfig.ts
881
- var OPT_OUT_ENV = "INSIDER_SKIP_CONFIG_VALIDATION";
882
- var OXLINT_CONFIG_FILENAMES = ["oxlint.config.ts", "oxlint.config.mts"];
883
- function lintModuleSpecifier(directory) {
884
- return `./${LINT_MODULE_FILENAMES.find((filename) => existsSync(join(directory, filename))) ?? "insider.lint.ts"}`;
885
- }
886
1185
  function formatMessage(directory) {
887
- return [
888
- "@useinsider/eslint-config: no oxlint config found in",
889
- ` ${directory}`,
890
- "",
891
- "useInsiderESLint() turns off every rule oxlint owns, so running ESLint alone would silently",
892
- "drop those checks. Create the sibling oxlint config:",
893
- "",
894
- " // oxlint.config.ts",
895
- ` import { useInsiderOXC } from '${lintModuleSpecifier(directory)}';`,
896
- "",
897
- " export default await useInsiderOXC();",
898
- "",
899
- "and run both linters:",
900
- "",
901
- " \"lint\": \"oxlint --disable-nested-config && eslint .\"",
902
- "",
903
- `Expected one of: ${OXLINT_CONFIG_FILENAMES.join(", ")}.`,
904
- `Emergency bypass: set ${OPT_OUT_ENV}=true (do not commit).`
905
- ].join("\n");
1186
+ return formatLayoutGuidance({
1187
+ headline: `no oxlint config found in ${directory}.`,
1188
+ diagnosis: diagnoseLayout(directory),
1189
+ detail: [
1190
+ "useInsiderESLint() turns off every rule oxlint owns, so running ESLint alone would silently",
1191
+ "drop those checks. Create the sibling oxlint config:",
1192
+ "",
1193
+ " // oxlint.config.ts",
1194
+ ` import { useInsiderOXC } from '${lintModuleSpecifier(directory)}';`,
1195
+ "",
1196
+ " export default await useInsiderOXC();",
1197
+ "",
1198
+ "and run both linters:",
1199
+ "",
1200
+ " \"lint\": \"oxlint --disable-nested-config && eslint .\"",
1201
+ "",
1202
+ `oxlint discovers only ${OXLINT_CONFIG_FILENAMES.join(" and ")} — any other extension is`,
1203
+ "ignored without a word."
1204
+ ]
1205
+ });
906
1206
  }
907
1207
  /**
908
1208
  * Fails when the consumer enabled the ESLint-side oxlint suppression without a sibling oxlint
@@ -912,7 +1212,7 @@ function formatMessage(directory) {
912
1212
  * ensureOxlintConfig(); // throws unless oxlint.config.{ts,mts} sits beside insider.lint.*
913
1213
  */
914
1214
  function ensureOxlintConfig(directory = getConsumerConfigDir()) {
915
- if (process.env[OPT_OUT_ENV] === "true") return;
1215
+ if (process.env["INSIDER_SKIP_CONFIG_VALIDATION"] === "true") return;
916
1216
  if (OXLINT_CONFIG_FILENAMES.some((filename) => existsSync(join(directory, filename)))) return;
917
1217
  throw new Error(formatMessage(directory));
918
1218
  }
@@ -977,6 +1277,11 @@ function assertNoRemovedKeys(config) {
977
1277
  const details = used.map((key) => ` - \`${key}\`: ${removedKeys.get(key)}`).join("\n");
978
1278
  throw new Error(`@useinsider/eslint-config: removed option(s) passed to useInsider():\n${details}`);
979
1279
  }
1280
+ function assertNoUnknownKeys(config) {
1281
+ const unknown = Object.keys(config).filter((key) => !reservedKeys.has(key) && !removedKeys.has(key) && !isScopeName(key));
1282
+ if (unknown.length === 0) return;
1283
+ throw new Error(`@useinsider/eslint-config: unknown option(s) passed to useInsider(): ${unknown.join(", ")}.\n Scopes: ${scopeNames().join(", ")}\n Other options: ${[...reservedKeys].join(", ")}`);
1284
+ }
980
1285
  function isProcessableScopeKey(key) {
981
1286
  return !reservedKeys.has(key) && isScopeName(key);
982
1287
  }
@@ -1029,6 +1334,7 @@ async function renderOxlint(config) {
1029
1334
  }
1030
1335
  function useInsider(config) {
1031
1336
  assertNoRemovedKeys(config);
1337
+ assertNoUnknownKeys(config);
1032
1338
  ensureConfigStructure();
1033
1339
  return {
1034
1340
  useInsiderESLint: () => renderEslint(config),