@nx/oxlint 23.2.0-rc.0 → 23.2.0-rc.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.
@@ -1,4 +1,4 @@
1
- import { buildPackageJsonPatterns, buildPackageJsonWorkspacesMatcher, calculateHashesForCreateNodes, combineGlobPatterns, globWithWorkspaceContext, hashObject, PluginCache, TargetProjectLocator, workspaceDataDirectory, } from '@nx/devkit/internal';
1
+ import { buildPackageJsonPatterns, buildPackageJsonWorkspacesMatcher, calculateHashesForCreateNodes, combineGlobPatterns, globWithWorkspaceContext, hashObject, PluginCache, TargetProjectLocator, workspaceDataDirectory, quoteShellArg, } from '@nx/devkit/internal';
2
2
  import { createNodesFromFiles, DependencyType, detectPackageManager, getPackageManagerCommand, readJsonFile, validateDependency, } from '@nx/devkit';
3
3
  import { getLockFileName, getRootTsConfigFileName } from '@nx/js';
4
4
  import { walkTsconfigExtendsChain, } from '@nx/js/internal';
@@ -27,6 +27,8 @@ const LINTABLE_EXTENSIONS = [
27
27
  'astro',
28
28
  ];
29
29
  const LINTABLE_FILES_GLOB = `**/*.{${LINTABLE_EXTENSIONS.join(',')}}`;
30
+ // Oxlint honours both, so both decide which files it lints.
31
+ const IGNORE_FILENAMES = ['.eslintignore', '.gitignore'];
30
32
  // The one rule that looks across projects; every other rule sees one file.
31
33
  const BOUNDARIES_PLUGIN_SPECIFIER = '@nx/oxlint/boundaries-plugin';
32
34
  const PROJECT_CONFIG_FILENAMES = ['project.json', 'package.json'];
@@ -34,7 +36,7 @@ const OXLINT_CONFIG_GLOB = combineGlobPatterns([
34
36
  ...OXLINT_CONFIG_FILENAMES.map((f) => `**/${f}`),
35
37
  ...PROJECT_CONFIG_FILENAMES.map((f) => `**/${f}`),
36
38
  ]);
37
- const internalCreateNodes = async (configFilePath, options, context, projectRootsByOxlintRoots, getLintableFilesPerProjectRoot, configChainsByConfig, jsPluginSpecifiersByConfig, tsconfigChainsByProjectRoot, projectsCache, hashByRoot, pmc, rootConfig) => {
39
+ const internalCreateNodes = async (configFilePath, options, context, projectRootsByOxlintRoots, nestedRootsByParent, getLintableFilesPerProjectRoot, configChainsByConfig, jsPluginSpecifiersByConfig, tsconfigChainsByProjectRoot, projectsCache, hashByRoot, pmc, rootConfig) => {
38
40
  const configDir = dirname(configFilePath);
39
41
  // Collect each project root's contribution in parallel, but write them into
40
42
  // `projects` afterwards in input order so insertion order (and therefore
@@ -55,7 +57,7 @@ const internalCreateNodes = async (configFilePath, options, context, projectRoot
55
57
  projectsCache.set(hash, {});
56
58
  return null;
57
59
  }
58
- const project = getProjectUsingOxlintConfig(configFilePath, projectRoot, options, context, pmc, configChainsByConfig, jsPluginSpecifiersByConfig, tsconfigChainsByProjectRoot.get(projectRoot) ?? [], rootConfig);
60
+ const project = getProjectUsingOxlintConfig(configFilePath, projectRoot, nestedRootsByParent, options, context, pmc, configChainsByConfig, jsPluginSpecifiersByConfig, tsconfigChainsByProjectRoot.get(projectRoot) ?? [], rootConfig);
59
61
  if (project) {
60
62
  const entry = { [projectRoot]: project };
61
63
  projectsCache.set(hash, entry);
@@ -80,6 +82,7 @@ export const createNodes = [
80
82
  const cachePath = join(workspaceDataDirectory, `oxlint-${optionsHash}.hash`);
81
83
  const targetsCache = new PluginCache(cachePath);
82
84
  const { oxlintConfigFiles, projectRoots, projectRootsByOxlintRoots } = splitConfigFiles(configFiles, context.workspaceRoot);
85
+ const nestedRootsByParent = nestedRootsByParentRoot(projectRoots);
83
86
  // The glob also matches `**/package.json`, so this callback runs in every
84
87
  // workspace, Oxlint or not. Bail before the chain walks and the hashing.
85
88
  if (oxlintConfigFiles.length === 0) {
@@ -118,7 +121,7 @@ export const createNodes = [
118
121
  ...allConfigs.flatMap((config) => localJsPluginFiles(config, jsPluginSpecifiersByConfig.get(config) ?? [])),
119
122
  // Change which files Oxlint considers lintable, and therefore
120
123
  // whether a target is inferred at all.
121
- ...ancestorEslintignorePaths(root),
124
+ ...ancestorIgnorePaths(root),
122
125
  lockFilePattern,
123
126
  ...(tsconfigChainsByProjectRoot.get(root) ?? []),
124
127
  ]),
@@ -126,7 +129,7 @@ export const createNodes = [
126
129
  }));
127
130
  const hashByRoot = new Map(projectRoots.map((r, i) => [r, hashes[i]]));
128
131
  try {
129
- return await createNodesFromFiles((configFile, fileOptions, fileContext) => internalCreateNodes(configFile, fileOptions, fileContext, projectRootsByOxlintRoots, getLintableFilesPerProjectRoot, configChainsByConfig, jsPluginSpecifiersByConfig, tsconfigChainsByProjectRoot, targetsCache, hashByRoot, pmc, rootConfig), oxlintConfigFiles, options, context);
132
+ return await createNodesFromFiles((configFile, fileOptions, fileContext) => internalCreateNodes(configFile, fileOptions, fileContext, projectRootsByOxlintRoots, nestedRootsByParent, getLintableFilesPerProjectRoot, configChainsByConfig, jsPluginSpecifiersByConfig, tsconfigChainsByProjectRoot, targetsCache, hashByRoot, pmc, rootConfig), oxlintConfigFiles, options, context);
130
133
  }
131
134
  finally {
132
135
  targetsCache.writeToDisk();
@@ -518,16 +521,18 @@ async function collectLintableFilesByProjectRoot(projectRoots, context) {
518
521
  return lintableFilesPerProjectRoot;
519
522
  }
520
523
  /**
521
- * `.eslintignore` candidates in every ancestor directory of the project root,
524
+ * Ignore-file candidates in every ancestor directory of the project root,
522
525
  * workspace root included. The project's own directory is excluded — files
523
- * there are covered by the `default` input — except at the workspace root,
524
- * which is its own ancestor and so keeps its `.eslintignore`.
526
+ * there are covered by the in-project ignore-file input — except at the
527
+ * workspace root, which is its own ancestor and so keeps its ignore files.
525
528
  */
526
- function ancestorEslintignorePaths(projectRoot) {
529
+ function ancestorIgnorePaths(projectRoot) {
527
530
  const result = [];
528
531
  let dir = projectRoot === '.' ? '.' : dirname(projectRoot);
529
532
  while (true) {
530
- result.push(dir === '.' ? '.eslintignore' : `${dir}/.eslintignore`);
533
+ for (const filename of IGNORE_FILENAMES) {
534
+ result.push(dir === '.' ? filename : `${dir}/${filename}`);
535
+ }
531
536
  if (dir === '.') {
532
537
  break;
533
538
  }
@@ -535,7 +540,69 @@ function ancestorEslintignorePaths(projectRoot) {
535
540
  }
536
541
  return result;
537
542
  }
538
- // Only the keys are read, so the value type is left open for both callers.
543
+ /**
544
+ * Escape the gitignore metacharacters in a path so `--ignore-pattern` matches it
545
+ * literally. The value crosses two languages and `quoteShellArg` only covers the
546
+ * shell: to Oxlint's matcher `\`, `[`, `]`, `*` and `?` are pattern syntax, and a
547
+ * trailing space is stripped unless escaped — so `/a[b]` excludes `ab` while
548
+ * leaving `a[b]` walked, which is both a miss and a silent over-exclusion.
549
+ */
550
+ function escapeIgnorePattern(pattern) {
551
+ return pattern
552
+ .replace(/([\\[\]*?])/g, '\\$1')
553
+ .replace(/ +$/, (spaces) => spaces.replace(/ /g, '\\ '));
554
+ }
555
+ /**
556
+ * Direct child project roots for each project root, keyed by the parent. A root
557
+ * belongs to its NEAREST enclosing root, so a grandchild lands under the child
558
+ * rather than the grandparent — which is what keeps an outer project from
559
+ * emitting an exclusion that a shallower one already covers.
560
+ *
561
+ * Built once per run: resolving each root's parent by walking up is linear in
562
+ * the workspace, where asking every project which roots sit below it is not.
563
+ */
564
+ function nestedRootsByParentRoot(projectRoots) {
565
+ // getRootForDirectory reads only the keys.
566
+ const roots = new Map(projectRoots.map((root) => [root, true]));
567
+ const byParent = new Map();
568
+ for (const root of projectRoots) {
569
+ const parent = getRootForDirectory(dirname(root), roots);
570
+ // A workspace-root project is its own ancestor, so it must not claim itself.
571
+ if (parent === null || parent === root) {
572
+ continue;
573
+ }
574
+ const claimed = byParent.get(parent);
575
+ if (claimed) {
576
+ claimed.push(root);
577
+ }
578
+ else {
579
+ byParent.set(parent, [root]);
580
+ }
581
+ }
582
+ for (const claimed of byParent.values()) {
583
+ claimed.sort();
584
+ }
585
+ return byParent;
586
+ }
587
+ /**
588
+ * The project roots nested directly below `projectRoot`, relative to it and
589
+ * limited to `lintDir` when the lint walk starts there. An excluded root either
590
+ * has its own inferred target or owns nothing lintable, so pruning it drops no
591
+ * lint coverage.
592
+ *
593
+ * Callers must emit each one anchored (`/dir`) and never as `dir/**`. A gitignore
594
+ * pattern is anchored only when it contains a slash, so a bare single-segment
595
+ * root would also match a same-named directory the outer project owns; and
596
+ * `dir/**` matches only entries inside `dir`, leaving Oxlint free to descend and
597
+ * read that directory's ignore files.
598
+ */
599
+ function nestedProjectRoots(projectRoot, nestedRootsByParent, lintDir) {
600
+ const prefix = projectRoot === '.' ? '' : `${projectRoot}/`;
601
+ return (nestedRootsByParent.get(projectRoot) ?? [])
602
+ .map((root) => root.slice(prefix.length))
603
+ .filter((rel) => !lintDir || rel === lintDir || rel.startsWith(`${lintDir}/`));
604
+ }
605
+ // Only the keys are read, so the value type is left open for all callers.
539
606
  function getRootForDirectory(directory, roots) {
540
607
  let currentPath = normalize(directory);
541
608
  while (currentPath !== dirname(currentPath)) {
@@ -546,7 +613,7 @@ function getRootForDirectory(directory, roots) {
546
613
  }
547
614
  return roots.has(currentPath) ? currentPath : null;
548
615
  }
549
- function getProjectUsingOxlintConfig(configFilePath, projectRoot, options, context, pmc, configChainsByConfig, jsPluginSpecifiersByConfig, tsconfigChainOutsideProjectRoot, rootConfig) {
616
+ function getProjectUsingOxlintConfig(configFilePath, projectRoot, nestedRootsByParent, options, context, pmc, configChainsByConfig, jsPluginSpecifiersByConfig, tsconfigChainOutsideProjectRoot, rootConfig) {
550
617
  let standaloneSrcPath;
551
618
  if (projectRoot === '.' &&
552
619
  existsSync(join(context.workspaceRoot, projectRoot, 'package.json'))) {
@@ -573,11 +640,17 @@ function getProjectUsingOxlintConfig(configFilePath, projectRoot, options, conte
573
640
  ];
574
641
  const isRootProject = projectRoot === '.';
575
642
  const lintPath = isRootProject && standaloneSrcPath ? `./${standaloneSrcPath}` : '.';
643
+ const nestedIgnoreArgs = nestedProjectRoots(projectRoot, nestedRootsByParent, isRootProject && standaloneSrcPath ? standaloneSrcPath : '')
644
+ // `quoteShellArg` documents that it cannot keep `%` literal through cmd.exe,
645
+ // so on Windows such a root is left unexcluded rather than excluded wrongly:
646
+ // it gets linted twice, which is what happened before this exclusion existed.
647
+ .filter((relativeRoot) => process.platform !== 'win32' || !relativeRoot.includes('%'))
648
+ .map((relativeRoot) => `--ignore-pattern ${quoteShellArg(escapeIgnorePattern(`/${relativeRoot}`))}`);
576
649
  const jsPluginFiles = new Set(configInputs.flatMap((config) => localJsPluginFiles(config, jsPluginSpecifiersByConfig.get(config) ?? [])));
577
650
  const lintableFiles = `{projectRoot}/${LINTABLE_FILES_GLOB}`;
578
651
  const usesBoundariesBridge = configInputs.some((config) => (jsPluginSpecifiersByConfig.get(config) ?? []).includes(BOUNDARIES_PLUGIN_SPECIFIER));
579
652
  const targetConfig = {
580
- command: `oxlint ${lintPath}`,
653
+ command: `oxlint ${[lintPath, ...nestedIgnoreArgs].join(' ')}`,
581
654
  options: { cwd: projectRoot },
582
655
  cache: true,
583
656
  inputs: [
@@ -596,13 +669,13 @@ function getProjectUsingOxlintConfig(configFilePath, projectRoot, options, conte
596
669
  : []),
597
670
  // Configs, ignore files and tsconfigs inside the project.
598
671
  {
599
- fileset: `{projectRoot}/**/{${[...OXLINT_CONFIG_FILENAMES, '.eslintignore', 'tsconfig*.json'].join(',')}}`,
672
+ fileset: `{projectRoot}/**/{${[...OXLINT_CONFIG_FILENAMES, ...IGNORE_FILENAMES, 'tsconfig*.json'].join(',')}}`,
600
673
  },
601
674
  ...configInputs.map((config) => `{workspaceRoot}/${config}`),
602
675
  ...[...jsPluginFiles].map((file) => `{workspaceRoot}/${file}`),
603
- // Oxlint layers .eslintignore files from every ancestor of a linted
604
- // file. Declared even when absent, like the extends chain.
605
- ...ancestorEslintignorePaths(projectRoot).map((file) => `{workspaceRoot}/${file}`),
676
+ // Oxlint layers ignore files from every ancestor of a linted file.
677
+ // Declared even when absent, like the extends chain.
678
+ ...ancestorIgnorePaths(projectRoot).map((file) => `{workspaceRoot}/${file}`),
606
679
  ...tsconfigChainOutsideProjectRoot.map((file) => `{workspaceRoot}/${file}`),
607
680
  { externalDependencies: ['oxlint'] },
608
681
  ],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nx/oxlint",
3
3
  "description": "Experimental. The Oxlint plugin for Nx contains generators and utilities used for linting JavaScript/TypeScript projects within an Nx workspace.",
4
- "version": "23.2.0-rc.0",
4
+ "version": "23.2.0-rc.2",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "dist",
@@ -69,9 +69,9 @@
69
69
  "generators": "./generators.json",
70
70
  "dependencies": {
71
71
  "tslib": "^2.3.0",
72
- "@nx/devkit": "23.2.0-rc.0",
73
- "@nx/eslint-plugin": "23.2.0-rc.0",
74
- "@nx/js": "23.2.0-rc.0"
72
+ "@nx/devkit": "23.2.0-rc.2",
73
+ "@nx/eslint-plugin": "23.2.0-rc.2",
74
+ "@nx/js": "23.2.0-rc.2"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "oxlint": "^1.43.0"
@@ -85,6 +85,6 @@
85
85
  "node": "^20.19.0 || >=22.12.0"
86
86
  },
87
87
  "devDependencies": {
88
- "nx": "23.2.0-rc.0"
88
+ "nx": "23.2.0-rc.2"
89
89
  }
90
90
  }