@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.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,332 @@ 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 === "complete") return [
361
+ `All three files are already here, so there is nothing to migrate — ${TARGET_CALLEE_NAME}() was`,
362
+ `reached from somewhere other than ${diagnosis.lintModule ?? LINT_MODULE_FILENAMES[0]}. Only the`,
363
+ "lint module may call it; the two configs import the renderers it exports."
364
+ ];
365
+ if (diagnosis.kind === "unmigratedV3") return [
366
+ "`useInsider()` no longer returns a flat-config array, so the config it is called from has",
367
+ "to become a module both linters can import. The migration does that rewrite for you:",
368
+ "",
369
+ ` ${MIGRATE_COMMAND}`
370
+ ];
371
+ if (diagnosis.kind === "noConfig") return [
372
+ "There is no linter configuration here yet. Scaffold the three-file layout:",
373
+ "",
374
+ ` ${SCAFFOLD_COMMAND}`
375
+ ];
376
+ return [
377
+ `The layout is incomplete — missing ${diagnosis.missing.join(", ")}.`,
378
+ "Add the missing file, or let the migration regenerate the whole layout:",
379
+ "",
380
+ ` ${MIGRATE_COMMAND}`
381
+ ];
382
+ }
383
+ function detailLines(detail) {
384
+ if (!detail || detail.length === 0) return [];
385
+ return ["", ...detail];
386
+ }
387
+ /**
388
+ * The consumer-facing failure text: a site-specific headline, then the branch that matches what is
389
+ * on disk, then the layout dump, then the bypass. The command to run always comes before the dump,
390
+ * because a consumer who reads only the first screen still has to be told about `migrate`.
391
+ * @example
392
+ * formatLayoutGuidance({ headline: 'no `insider.lint.*` module found in /repo.', diagnosis });
393
+ */
394
+ function formatLayoutGuidance(input) {
395
+ return [
396
+ `@useinsider/eslint-config: ${input.headline}`,
397
+ "",
398
+ ...inventoryLines(input.diagnosis),
399
+ "",
400
+ ...guidanceBody(input.diagnosis),
401
+ ...detailLines(input.detail),
402
+ "",
403
+ EXPECTED_LAYOUT,
404
+ "",
405
+ `Emergency bypass: set ${OPT_OUT_ENV}=true (do not commit).`
406
+ ].join("\n");
407
+ }
408
+ //#endregion
409
+ //#region src/utils/offerMigration.ts
410
+ var PACKAGE_NAME = "@useinsider/eslint-config";
411
+ var CLI_RELATIVE_PATH = "dist/cli.cjs";
412
+ var NO_PROMPT_ENV = "INSIDER_NO_PROMPT";
413
+ var IN_FLIGHT_ENV = "INSIDER_MIGRATION_IN_PROGRESS";
414
+ var MIGRATE_FROM_LINT_ARGS = ["migrate", "--from-lint"];
415
+ var BINARY_EXTENSIONS = /\.[mc]?js$/u;
416
+ function binaryName(argument) {
417
+ return basename(argument).replace(BINARY_EXTENSIONS, "");
418
+ }
419
+ /**
420
+ * Which linter is hosting the current process. oxlint evaluates `oxlint.config.ts` inside the Node
421
+ * process that runs its own `bin/oxlint` wrapper, so `useInsider()` genuinely executes under both
422
+ * binaries and the two have to be told apart before anything is written to stdout.
423
+ * @example
424
+ * detectLintHost(['node', '/app/node_modules/oxlint/bin/oxlint', '.']); // 'oxlint'
425
+ */
426
+ function detectLintHost(argv = process.argv) {
427
+ const names = argv.slice(1).map(binaryName);
428
+ if (names.includes("oxlint")) return "oxlint";
429
+ if (names.includes("eslint")) return "eslint";
430
+ return "unknown";
431
+ }
432
+ function defaultInteractive() {
433
+ return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
434
+ }
435
+ function skipReasonFor(env, argv, interactive) {
436
+ if (env[IN_FLIGHT_ENV] === "true") return "already-offered";
437
+ if (env.VITEST || env.NODE_ENV === "test") return "running-under-test";
438
+ if (env["INSIDER_SKIP_CONFIG_VALIDATION"] === "true" || env[NO_PROMPT_ENV] === "true") return "suppressed";
439
+ if (env.CI) return "running-in-ci";
440
+ if (detectLintHost(argv) === "oxlint") return "host-is-oxlint";
441
+ if (!interactive) return "no-tty";
442
+ return null;
443
+ }
444
+ function manifestNameAt(directory) {
445
+ const manifestPath = join(directory, "package.json");
446
+ if (!existsSync(manifestPath)) return null;
447
+ try {
448
+ return JSON.parse(readFileSync(manifestPath, "utf8")).name ?? null;
449
+ } catch {
450
+ return null;
451
+ }
452
+ }
453
+ function packageRootFrom(startDir) {
454
+ let current = startDir;
455
+ for (let depth = 0; depth < 10; depth += 1) {
456
+ if (manifestNameAt(current) === PACKAGE_NAME) return current;
457
+ const parent = dirname(current);
458
+ if (parent === current) return null;
459
+ current = parent;
460
+ }
461
+ return null;
462
+ }
463
+ /**
464
+ * Absolute path to this package's own built CLI, or `null` when it is not on disk. Resolved by
465
+ * walking up from `startDir` — this module's own directory by default — rather than through
466
+ * `require.resolve`, so it works from `dist/` and from `src/` alike.
467
+ * @example
468
+ * resolveCliEntry(); // '/app/node_modules/@useinsider/eslint-config/dist/cli.cjs'
469
+ */
470
+ function resolveCliEntry(startDir) {
471
+ const root = packageRootFrom(startDir ?? dirname(fileURLToPath(import.meta.url)));
472
+ if (!root) return null;
473
+ const entry = resolve(root, CLI_RELATIVE_PATH);
474
+ return existsSync(entry) ? entry : null;
475
+ }
476
+ function defaultRunCli(entry, args, cwd, env) {
477
+ const result = spawnSync(process.execPath, [entry, ...args], {
478
+ cwd,
479
+ env,
480
+ stdio: "inherit"
481
+ });
482
+ if (result.error) return 1;
483
+ return result.status ?? 1;
484
+ }
485
+ function defaultWarn(message) {
486
+ console.error(message);
487
+ }
488
+ function outdatedNotice(diagnosis) {
489
+ const config = diagnosis.eslintConfig ?? "eslint.config.*";
490
+ return [
491
+ `@useinsider/eslint-config: ${config} still uses the v3 single-file layout.`,
492
+ ` ${join(diagnosis.directory, config)} calls \`useInsider()\` itself, and`,
493
+ ` ${diagnosis.missing.join(" and ")} ${diagnosis.missing.length === 1 ? "is" : "are"} missing.`,
494
+ "",
495
+ "Starting the guided migration. It shows every change before applying it.",
496
+ `Skip it with ${NO_PROMPT_ENV}=true.`,
497
+ ""
498
+ ].join("\n");
499
+ }
500
+ var offerAttempted = false;
501
+ function withDefaults(deps) {
502
+ return {
503
+ env: deps.env ?? process.env,
504
+ argv: deps.argv ?? process.argv,
505
+ interactive: deps.interactive ?? defaultInteractive(),
506
+ resolveCliEntry: deps.resolveCliEntry ?? resolveCliEntry,
507
+ runCli: deps.runCli ?? defaultRunCli,
508
+ warn: deps.warn ?? defaultWarn
509
+ };
510
+ }
511
+ /**
512
+ * Offers the guided migration for an outdated layout and runs it when the consumer accepts. Declines
513
+ * silently — returning a skip reason instead of prompting — whenever writing to stdout would be
514
+ * wrong: under oxlint, in CI, without a TTY, or when already inside a migration.
515
+ * @example
516
+ * offerMigration(diagnoseLayout('/repo')).accepted; // true after the consumer confirmed
517
+ */
518
+ function offerMigration(diagnosis, deps = {}) {
519
+ const resolved = withDefaults(deps);
520
+ if (offerAttempted) return {
521
+ offered: false,
522
+ skipReason: "already-offered"
523
+ };
524
+ const skipReason = skipReasonFor(resolved.env, resolved.argv, resolved.interactive);
525
+ if (skipReason) return {
526
+ offered: false,
527
+ skipReason
528
+ };
529
+ const entry = resolved.resolveCliEntry();
530
+ if (!entry) return {
531
+ offered: false,
532
+ skipReason: "cli-not-found"
533
+ };
534
+ offerAttempted = true;
535
+ resolved.warn(outdatedNotice(diagnosis));
536
+ return {
537
+ offered: true,
538
+ accepted: resolved.runCli(entry, MIGRATE_FROM_LINT_ARGS, diagnosis.directory, {
539
+ ...resolved.env,
540
+ [IN_FLIGHT_ENV]: "true"
541
+ }) === 0
542
+ };
543
+ }
544
+ //#endregion
545
+ //#region src/utils/ensureConfigStructure.ts
546
+ var ANCHOR_FILE_REGEX = /(?:insider\.lint|eslint\.config)\.[mc]?[jt]s(?:$|\?)/;
547
+ var FILE_URL_PREFIX = "file://";
231
548
  var bypassWarnEmitted = false;
232
549
  function computeSelfModulePath() {
233
550
  const raw = fileURLToPath(import.meta.url);
@@ -242,7 +559,7 @@ function isPathSpecifier(source) {
242
559
  return source.startsWith(".") || source.startsWith("/");
243
560
  }
244
561
  function defaultIsInsiderImportSource(source, configPath) {
245
- if (source === INSIDER_IMPORT_SOURCE) return true;
562
+ if (source === "@useinsider/eslint-config") return true;
246
563
  if (!configPath || !isPathSpecifier(source)) return false;
247
564
  try {
248
565
  const absolute = resolve(dirname(configPath), source);
@@ -338,7 +655,7 @@ function validateProgramStructure(program, configPath = "", options = {}) {
338
655
  const insiderBindings = /* @__PURE__ */ new Set();
339
656
  const resolver = options.isInsiderImportSource ?? defaultIsInsiderImportSource;
340
657
  const isInsiderSource = (source) => resolver(source, configPath);
341
- const expectedCallee = options.expectedCallee ?? ESLINT_RENDERER_NAME;
658
+ const expectedCallee = options.expectedCallee ?? "useInsiderESLint";
342
659
  let exportSeen = false;
343
660
  program.body.forEach((node) => {
344
661
  if (node.type === AST_NODE_TYPES.ImportDeclaration) collectInsiderBindings(node, insiderBindings, isInsiderSource);
@@ -354,7 +671,7 @@ function validateProgramStructure(program, configPath = "", options = {}) {
354
671
  if (!exportSeen) violations.push({
355
672
  line: 1,
356
673
  column: 0,
357
- reason: `Missing \`export default ${options.expectedCallee ?? ESLINT_RENDERER_NAME}()\`.`
674
+ reason: `Missing \`export default ${options.expectedCallee ?? "useInsiderESLint"}()\`.`
358
675
  });
359
676
  return violations;
360
677
  }
@@ -518,24 +835,6 @@ function captureConsumerConfigDir(configPath) {
518
835
  function getConsumerConfigDir() {
519
836
  return consumerConfigDir ?? process.cwd();
520
837
  }
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
838
  function formatReport(configPath, violations) {
540
839
  const header = ["@useinsider/eslint-config: protected configuration structure violated in:", ` ${configPath}`].join("\n");
541
840
  const lines = violations.map((violation) => ` - [${violation.line}:${violation.column}] ${violation.reason}`);
@@ -546,7 +845,7 @@ function formatReport(configPath, violations) {
546
845
  "",
547
846
  EXPECTED_LAYOUT,
548
847
  "",
549
- `Emergency bypass: set ${OPT_OUT_ENV$1}=true (do not commit).`
848
+ `Emergency bypass: set ${OPT_OUT_ENV}=true (do not commit).`
550
849
  ].join("\n");
551
850
  return `${header}\n${lines.join("\n")}\n${footer}`;
552
851
  }
@@ -596,46 +895,48 @@ function validateFile(filePath, validator, options) {
596
895
  if (violations.length === 0) return;
597
896
  fail(formatReport(filePath, violations));
598
897
  }
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"));
898
+ var MIGRATED_EXIT_NOTICE = [
899
+ "@useinsider/eslint-config: migration finished.",
900
+ "",
901
+ "Re-run your lint command. This process already loaded the old configuration, so continuing now",
902
+ "would lint against a config that no longer exists on disk."
903
+ ].join("\n");
904
+ var DECLINED_DETAIL = ["The guided migration was not completed, so nothing was written."];
905
+ function failWithGuidance(headline, diagnosis, detail) {
906
+ return fail(formatLayoutGuidance({
907
+ headline,
908
+ diagnosis,
909
+ detail
910
+ }));
911
+ }
912
+ function handleOutdatedLayout(headline, diagnosis, offer) {
913
+ if (!diagnosis.autoMigratable) return failWithGuidance(headline, diagnosis);
914
+ const outcome = offer(diagnosis);
915
+ if (!outcome.offered) return failWithGuidance(headline, diagnosis);
916
+ if (!outcome.accepted) return failWithGuidance(headline, diagnosis, DECLINED_DETAIL);
917
+ return fail(MIGRATED_EXIT_NOTICE);
918
+ }
919
+ function runValidation(anchorPath, deps = {}) {
920
+ const diagnose = deps.diagnose ?? ((directory) => diagnoseLayout(directory));
921
+ const offer = deps.offer ?? offerMigration;
922
+ if (!anchorPath) handleOutdatedLayout("unable to locate the consumer `insider.lint.*` module for structure validation.", diagnose(process.cwd()), offer);
607
923
  const directory = dirname(resolve(anchorPath));
608
924
  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"));
925
+ if (!lintModulePath) handleOutdatedLayout(`no \`insider.lint.*\` module found in ${directory}.`, diagnose(directory), offer);
619
926
  validateFile(lintModulePath, validateLintModuleStructure, {});
620
927
  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"));
928
+ 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
929
  validateFile(eslintConfigPath, validateProgramStructure, { expectedCallee: ESLINT_RENDERER_NAME });
629
930
  }
630
- function ensureConfigStructure(overridePath) {
931
+ function ensureConfigStructure(overridePath, deps = {}) {
631
932
  const configPath = overridePath === void 0 ? getConsumerConfigPath() : overridePath;
632
933
  captureConsumerConfigDir(configPath);
633
- if (process.env[OPT_OUT_ENV$1] === "true") {
934
+ if (process.env["INSIDER_SKIP_CONFIG_VALIDATION"] === "true") {
634
935
  emitBypassWarning();
635
936
  return;
636
937
  }
637
938
  try {
638
- runValidation(configPath);
939
+ runValidation(configPath, deps);
639
940
  } catch (error) {
640
941
  if (error instanceof ConfigStructureExit) return;
641
942
  throw error;
@@ -759,6 +1060,14 @@ function clonePreset(preset) {
759
1060
  function isScopeName(key) {
760
1061
  return Object.keys(scopeMap).includes(key);
761
1062
  }
1063
+ /**
1064
+ * Every scope name `useInsider()` accepts, for error messages that have to spell the options out.
1065
+ * @example
1066
+ * scopeNames().includes('typescriptNode'); // true
1067
+ */
1068
+ function scopeNames() {
1069
+ return Object.keys(scopeMap);
1070
+ }
762
1071
  function applyCommonEntryFields(block, entry) {
763
1072
  if (entry.ignores && entry.ignores.length > 0) block.ignores = [...entry.ignores];
764
1073
  if (entry.globals) {
@@ -878,31 +1187,27 @@ function silenceDependencyWarning(input) {
878
1187
  }
879
1188
  //#endregion
880
1189
  //#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
1190
  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");
1191
+ return formatLayoutGuidance({
1192
+ headline: `no oxlint config found in ${directory}.`,
1193
+ diagnosis: diagnoseLayout(directory),
1194
+ detail: [
1195
+ "useInsiderESLint() turns off every rule oxlint owns, so running ESLint alone would silently",
1196
+ "drop those checks. Create the sibling oxlint config:",
1197
+ "",
1198
+ " // oxlint.config.ts",
1199
+ ` import { useInsiderOXC } from '${lintModuleSpecifier(directory)}';`,
1200
+ "",
1201
+ " export default await useInsiderOXC();",
1202
+ "",
1203
+ "and run both linters:",
1204
+ "",
1205
+ " \"lint\": \"oxlint --disable-nested-config && eslint .\"",
1206
+ "",
1207
+ `oxlint discovers only ${OXLINT_CONFIG_FILENAMES.join(" and ")} — any other extension is`,
1208
+ "ignored without a word."
1209
+ ]
1210
+ });
906
1211
  }
907
1212
  /**
908
1213
  * Fails when the consumer enabled the ESLint-side oxlint suppression without a sibling oxlint
@@ -912,7 +1217,7 @@ function formatMessage(directory) {
912
1217
  * ensureOxlintConfig(); // throws unless oxlint.config.{ts,mts} sits beside insider.lint.*
913
1218
  */
914
1219
  function ensureOxlintConfig(directory = getConsumerConfigDir()) {
915
- if (process.env[OPT_OUT_ENV] === "true") return;
1220
+ if (process.env["INSIDER_SKIP_CONFIG_VALIDATION"] === "true") return;
916
1221
  if (OXLINT_CONFIG_FILENAMES.some((filename) => existsSync(join(directory, filename)))) return;
917
1222
  throw new Error(formatMessage(directory));
918
1223
  }
@@ -977,6 +1282,11 @@ function assertNoRemovedKeys(config) {
977
1282
  const details = used.map((key) => ` - \`${key}\`: ${removedKeys.get(key)}`).join("\n");
978
1283
  throw new Error(`@useinsider/eslint-config: removed option(s) passed to useInsider():\n${details}`);
979
1284
  }
1285
+ function assertNoUnknownKeys(config) {
1286
+ const unknown = Object.keys(config).filter((key) => !reservedKeys.has(key) && !removedKeys.has(key) && !isScopeName(key));
1287
+ if (unknown.length === 0) return;
1288
+ throw new Error(`@useinsider/eslint-config: unknown option(s) passed to useInsider(): ${unknown.join(", ")}.\n Scopes: ${scopeNames().join(", ")}\n Other options: ${[...reservedKeys].join(", ")}`);
1289
+ }
980
1290
  function isProcessableScopeKey(key) {
981
1291
  return !reservedKeys.has(key) && isScopeName(key);
982
1292
  }
@@ -1029,6 +1339,7 @@ async function renderOxlint(config) {
1029
1339
  }
1030
1340
  function useInsider(config) {
1031
1341
  assertNoRemovedKeys(config);
1342
+ assertNoUnknownKeys(config);
1032
1343
  ensureConfigStructure();
1033
1344
  return {
1034
1345
  useInsiderESLint: () => renderEslint(config),