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

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