fix(resolution): workspace globs reach nested members, and a negated glob leaves members out (#2638)

pnpm's `packages/**/*` (folo) and npm's `libs/**/*` (bitwarden) list
member packages at any depth, `packages/plugin-*` and `apps/*/web` name
them by part of a folder name or by a folder below the wildcard, and a
negated glob (`!**/example`, `!examples/with-nx`) leaves some out. The
workspace loader listed only the folders directly under the text before
the first `*` and took a negation for a folder name, so folo's
`@follow/utils`, two levels under `packages/`, was no member and every
import of it counted as an outside package's.

Globs now match folder by folder: `*`, `?` and `{a,b}` within a name,
`**` across folders, breadth-first so a member nearer the root keeps a
name a deeper copy also declares. A wildcard never enters a dot-folder,
`node_modules` or `bower_components`, and a `**` skips `test` and `tests`
folders as pnpm's default ignores do; a folder the glob names literally
is always entered. What a negated glob matches is no member.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-18 21:54:50 +00:00
committed by GitHub
parent 53ad0d48b3
commit 8ed0202071
3 changed files with 291 additions and 16 deletions
+1
View File
@@ -87,6 +87,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In JavaScript and TypeScript, a file that re-exports a module, like an `index.ts` with `export * from './button'` or `export { Button } from './button'`, now counts as importing that module, the way an `import` statement does. `codegraph affected` and a module's dependents now include the barrels that re-export it; before, a barrel made only of `export *` lines showed no dependency at all. Re-index JavaScript and TypeScript projects after upgrading.
- `codegraph sync` and the file watcher now also update the files that import through an `index.ts` re-exporting another barrel, like `export * from './dropdown-menu'`, when that inner barrel is deleted or added. Before, their imports stayed linked through the deleted barrel, or stayed linked the way they were while it was missing, until each importing file changed or the project was re-indexed.
- An import in a Vue, Svelte or Astro component now links the component to the file it imports, as an import in a script does, and so does an import of a scoped package that a `tsconfig` path or a workspace package maps to a project file, like `import { debounce } from '@excalidraw/common'`. Before, only the names such an import binds were linked, so a barrel or a module imported for its side effects showed no dependency from those files in `codegraph affected`, and `codegraph sync` could not update them when a barrel they import through was deleted or added. `codegraph sync` and the file watcher also link such an import again, and one written as `.` or `..`, when the file it names comes back. Re-index JavaScript, TypeScript, Vue, Svelte and Astro projects after upgrading.
- In a pnpm, npm, Yarn or Bun workspace whose globs reach packages more than one folder down, like pnpm's `packages/**/*` or npm's `libs/**/*`, those packages now count as part of the project, so an import of one is no longer taken for an outside package's, and `codegraph sync` and the file watcher relink the calls through it when its files change. A workspace glob can now also name packages by part of a folder name (`packages/plugin-*`) or by a folder below the wildcard (`apps/*/web`), and a negated glob like `!**/example` leaves packages out, as the package managers do. Before, only the folders directly under a glob's first wildcard were read, and a negated glob was ignored. Re-index after upgrading.
- In a project that uses Vue, an import written through an `@/` or `~/` alias, like `import { Button } from '@/components'`, now links the file it imports, as it does in any other project, so `codegraph sync` and the file watcher update the files that import a module when that module, or a barrel they import through, is added or deleted. Before, Vue support linked such an import to the first declaration or the component in that file, which sync does not follow, and an import through a `tsconfig` alias that starts like one of Nuxt's built-in modules, like `#app/where`, linked the importing file to itself. Re-index Vue projects after upgrading.
- `codegraph sync` and the file watcher now move an import written without an extension, like `import { sizeOf } from './button'`, to a file added beside the one it found before when the new file comes first, such as `button.ts` next to `button.vue`. Before, the import and the names it binds stayed linked to `button.vue` until the importing file changed or the project was re-indexed. JavaScript, TypeScript, Vue, Svelte and Astro.
- In C, C++ and Objective-C, a struct or class member declared as a pointer, reference, array or function, like `SharedState* shared;`, `jv elements[];` or `virtual Status Put(…) = 0;`, no longer makes the struct look like it inherits from the member's type. Before, `codegraph_explore` and impact listed those types as base classes, every struct holding a pointer to a type as one of its subclasses, and a method of that struct as an override of the type's method with the same name. Real base classes like `class Derived : public Base`, Objective-C superclasses and Go's embedded fields are unchanged. Re-index C, C++ and Objective-C projects after upgrading.
+169
View File
@@ -0,0 +1,169 @@
/**
* Workspace globs reach nested member packages.
*
* pnpm's `packages/**\/*` (folo) and npm's `libs/**\/*` (bitwarden) list
* members at any depth, `packages/plugin-*` and `apps/*\/web` name them by
* part of a folder name or by a folder below the wildcard, and a negated glob
* (`!**\/example`, `!examples/with-nx`) leaves some out. The workspace loader
* listed only the folders directly under the text before the first `*`, and
* took a negation for a folder name: folo's `@follow/utils`, two levels under
* `packages/`, was no member, so every import of it was taken for an outside
* package's, and an excluded example was a member all the same.
*/
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
import { loadWorkspacePackages } from '../src/resolution/workspace-packages';
type Files = Record<string, string>;
let roots: string[] = [];
let graphs: CodeGraph[] = [];
afterEach(() => {
for (const graph of graphs) graph.close();
graphs = [];
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
roots = [];
});
function workspace(files: Files): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ws-globs-'));
roots.push(root);
for (const [file, text] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
fs.writeFileSync(path.join(root, file), text);
}
return root;
}
const pkg = (name: string) => JSON.stringify({ name });
const pnpm = (...globs: string[]) => `packages:\n${globs.map((g) => ` - '${g}'`).join('\n')}\n`;
/** The loader's member map, as sorted `name -> dir` lines. */
function members(root: string): string[] {
const ws = loadWorkspacePackages(root);
return [...(ws?.byName ?? new Map<string, string>())].map(([name, dir]) => `${name} -> ${dir}`).sort();
}
describe('workspace globs', () => {
it("reach pnpm's nested members through `packages/**/*`", () => {
const root = workspace({
'package.json': pkg('root'),
'pnpm-workspace.yaml': pnpm('apps/*', 'packages/**/*'),
'apps/web/package.json': pkg('@acme/web'),
'packages/cli/package.json': pkg('@acme/cli'),
'packages/internal/utils/package.json': pkg('@acme/utils'),
'packages/internal/ui/package.json': pkg('@acme/ui'),
'packages/internal/AGENTS.md': '# not a package\n',
});
expect(members(root)).toEqual([
'@acme/cli -> packages/cli',
'@acme/ui -> packages/internal/ui',
'@acme/utils -> packages/internal/utils',
'@acme/web -> apps/web',
]);
});
it("reach npm's nested members through `libs/**/*`, and `**` alone counts the folder itself", () => {
const root = workspace({
'package.json': JSON.stringify({ name: 'root', workspaces: ['libs/**/*', 'tools/**'] }),
'libs/common/package.json': pkg('@bw/common'),
'libs/tools/generator/core/package.json': pkg('@bw/generator-core'),
'tools/package.json': pkg('@bw/tools'),
'tools/lint/package.json': pkg('@bw/lint'),
});
expect(members(root)).toEqual([
'@bw/common -> libs/common',
'@bw/generator-core -> libs/tools/generator/core',
'@bw/lint -> tools/lint',
'@bw/tools -> tools',
]);
});
it('match part of a folder name, a choice of names, and folders below the wildcard', () => {
const root = workspace({
'package.json': JSON.stringify({ name: 'root', workspaces: ['packages/plugin-*', '{apps,sites}/*/web'] }),
'packages/plugin-a/package.json': pkg('plugin-a'),
'packages/core/package.json': pkg('core'),
'apps/shop/web/package.json': pkg('shop-web'),
'apps/shop/package.json': pkg('shop'),
'sites/blog/web/package.json': pkg('blog-web'),
});
expect(members(root)).toEqual([
'blog-web -> sites/blog/web',
'plugin-a -> packages/plugin-a',
'shop-web -> apps/shop/web',
]);
});
it('leave out what a negated glob matches, wherever it is listed', () => {
const root = workspace({
'package.json': pkg('root'),
// Quoted both ways and bare, as folo writes them.
'pnpm-workspace.yaml': "packages:\n - '!packages/legacy'\n - packages/*\n - \"apps/**\"\n - \"!**/example\"\n - '!examples/with-*'\n - examples/*\n",
'packages/core/package.json': pkg('core'),
'packages/legacy/package.json': pkg('legacy'),
'apps/mobile/package.json': pkg('mobile'),
'apps/mobile/native/example/package.json': pkg('mobile-example'),
'examples/basic/package.json': pkg('example-basic'),
'examples/with-nx/package.json': pkg('example-nx'),
});
expect(members(root)).toEqual([
'core -> packages/core',
'example-basic -> examples/basic',
'mobile -> apps/mobile',
]);
});
it("don't take installed packages, hidden folders or a `**` walk's test fixtures for members, unless a glob names them", () => {
const root = workspace({
'package.json': JSON.stringify({ name: 'root', workspaces: ['packages/**', 'e2e/tests/*'] }),
'packages/core/package.json': pkg('core'),
'packages/core/test/fixtures/react/package.json': pkg('react'),
'packages/core/node_modules/lodash/package.json': pkg('lodash'),
'packages/.cache/stale/package.json': pkg('stale'),
'e2e/tests/smoke/package.json': pkg('smoke'),
});
expect(members(root)).toEqual([
'core -> packages/core',
'smoke -> e2e/tests/smoke',
]);
});
it('keep the member nearest the root when a deeper copy declares its name', () => {
const root = workspace({
'package.json': pkg('root'),
'pnpm-workspace.yaml': pnpm('packages/**'),
'packages/a-build/core/package.json': pkg('@acme/core'),
'packages/core/package.json': pkg('@acme/core'),
});
// `packages/a-build/core` sorts first, but `packages/core` is a level up.
expect(members(root)).toEqual(['@acme/core -> packages/core']);
});
});
describe('a call through an import of a nested workspace member', () => {
it('links to the member package', async () => {
const root = workspace({
'package.json': pkg('root'),
'pnpm-workspace.yaml': pnpm('apps/*', 'packages/**/*'),
'apps/web/package.json': pkg('@acme/web'),
// Imported under another name, so only the import can link the call.
'apps/web/src/main.ts': "import { fmt as format } from '@acme/utils';\nexport function main() {\n return format('x');\n}\n",
'packages/internal/utils/package.json': pkg('@acme/utils'),
'packages/internal/utils/index.ts': 'export function fmt(s: string) {\n return s.trim();\n}\n',
});
const graph = await CodeGraph.init(root, { index: true });
graphs.push(graph);
const fmt = graph.getNodesInFile('packages/internal/utils/index.ts').find((n) => n.name === 'fmt');
expect(fmt).toBeDefined();
// The call, and the file's import of the name.
expect(graph.getCallers(fmt!.id).map((c) => `${c.edge.kind} ${c.node.filePath}::${c.node.name}`).sort()).toEqual([
'calls apps/web/src/main.ts::main',
'imports apps/web/src/main.ts::main.ts',
]);
}, 60_000);
});
+121 -16
View File
@@ -17,7 +17,9 @@
* Scope deliberately small for v1 (mirrors path-aliases.ts):
* - reads `workspaces` (array OR `{ packages: [...] }`) from package.json,
* plus a minimal `pnpm-workspace.yaml` `packages:` list
* - expands one level of `*` / `**` globs (`packages/*`, `apps/*`)
* - expands `*`, `?` and `{a,b}` within a folder name and `**` across
* folders (`packages/*`, pnpm's nested `packages/**\/*`), and leaves out
* what a negated glob (`!**\/example`) matches
* - subpath resolution is directory-based (`@scope/ui/sub` → `<ui>/sub`);
* it does NOT yet honour a member's `exports` map or `main` field
* - returns null when the project declares no workspaces, so single-
@@ -56,8 +58,13 @@ export function loadWorkspacePackages(projectRoot: string): WorkspacePackages |
const byName = new Map<string, string>();
const patterns = readWorkspaceGlobs(projectRoot);
// What a negated glob matches (`!examples/with-nx`, pnpm's `!**/example`) is
// no member, wherever the glob is listed.
const excluded = patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1));
for (const pattern of patterns) {
if (pattern.startsWith('!')) continue;
for (const dir of expandWorkspaceGlob(projectRoot, pattern)) {
if (excluded.some((x) => workspaceGlobMatches(x, dir))) continue;
const pkgName = readPackageName(path.join(projectRoot, dir));
// First declaration wins — workspace patterns are tried in order.
if (pkgName && !byName.has(pkgName)) byName.set(pkgName, dir);
@@ -291,28 +298,126 @@ function parsePnpmPackages(yaml: string): string[] {
return out;
}
/** Expand one level of a `packages/*` / `apps/**` glob to member dirs. */
/** Folders no wildcard matches: installed packages. */
const GLOB_SKIP_DIRS = new Set(['node_modules', 'bower_components']);
/**
* Folders a `**` doesn't descend into either, as pnpm's default ignores
* don't: their fixture packages are no members, and their names (`react`,
* `lodash`) would make imports of the real package the project's.
*/
const GLOBSTAR_SKIP_DIRS = new Set(['test', 'tests']);
/** How far a `**` reaches below the glob's literal prefix, and how many folders one glob reads. */
const GLOBSTAR_MAX_DEPTH = 8;
const GLOB_DIR_BUDGET = 10_000;
/**
* Expand a workspace glob to the member directories it matches, nearest
* first: `packages/*`, `packages/plugin-*`, `apps/*\/web`, `{apps,libs}/*`,
* and the nested members of pnpm's `packages/**\/*` or npm's `libs/**\/*`
* (folo's `@follow/utils` is `packages/internal/utils`, two levels down).
* A wildcard never matches a dot-folder or one of GLOB_SKIP_DIRS, and a `**`
* none of GLOBSTAR_SKIP_DIRS; a folder the glob names literally is always
* entered. Breadth-first with sorted names, so a member nearer the root wins
* a package name a deeper copy also declares.
*/
function expandWorkspaceGlob(projectRoot: string, pattern: string): string[] {
const norm = pattern.replace(/\\/g, '/').replace(/\/+$/, '');
const star = norm.indexOf('*');
if (star === -1) return [norm]; // exact directory
const glob = compileGlob(norm);
const first = glob.findIndex((seg) => seg.wildcard);
if (first === -1) return [norm]; // exact directory
// Everything before the wildcard segment is the base to enumerate.
const base = norm.slice(0, star).replace(/\/+$/, '');
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(path.join(projectRoot, base), { withFileTypes: true });
} catch {
return [];
}
const out: string[] = [];
for (const e of entries) {
if (!e.isDirectory() || e.name.startsWith('.') || e.name === 'node_modules') continue;
out.push(base ? `${base}/${e.name}` : e.name);
const base = glob.slice(0, first).map((seg) => seg.text).join('/');
const start = globClosure(glob, new Set([first]));
// `tools/**` is `tools` too, as npm and pnpm match `tools/**/package.json`.
const out: string[] = start.has(glob.length) ? [base || '.'] : [];
let level = [{ rel: base, states: start }];
let read = 0;
for (let depth = 0; level.length > 0 && depth <= GLOBSTAR_MAX_DEPTH; depth++) {
const next: typeof level = [];
for (const { rel, states } of level) {
if (++read > GLOB_DIR_BUDGET) {
logDebug('workspace glob stopped at its folder budget', { pattern, read });
return out;
}
let names: string[];
try {
names = fs.readdirSync(path.join(projectRoot, rel), { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort();
} catch {
continue;
}
for (const name of names) {
const advanced = globStep(glob, states, name, true);
if (advanced.size === 0) continue;
const child = rel ? `${rel}/${name}` : name;
if (advanced.has(glob.length)) out.push(child);
if ([...advanced].some((state) => state < glob.length)) next.push({ rel: child, states: advanced });
}
}
level = next;
}
return out;
}
/** Whether a negated workspace glob's pattern (`**\/example`, `examples/with-*`) matches a member directory. */
function workspaceGlobMatches(pattern: string, dir: string): boolean {
const glob = compileGlob(pattern.replace(/\\/g, '/').replace(/\/+$/, ''));
let states = globClosure(glob, new Set([0]));
for (const name of dir.split('/').filter((s) => s !== '' && s !== '.')) {
states = globStep(glob, states, name, false);
if (states.size === 0) return false;
}
return states.has(glob.length);
}
interface GlobSegment {
text: string;
/** `**`: any number of folders, none included. */
globstar: boolean;
wildcard: boolean;
re: RegExp | null;
}
/** A glob's folder segments: `*`, `?` and `{a,b}` within one, `**` across them. */
function compileGlob(pattern: string): GlobSegment[] {
return pattern.split('/').filter((s) => s !== '' && s !== '.').map((text) => {
if (text === '**') return { text, globstar: true, wildcard: true, re: null };
if (!/[*?{]/.test(text)) return { text, globstar: false, wildcard: false, re: null };
const source = text
.replace(/[.+^$()|[\]\\]/g, '\\$&')
.replace(/\*+/g, '[^/]*')
.replace(/\?/g, '[^/]')
.replace(/\{([^{}]*)\}/g, (_, alts: string) => `(?:${alts.split(',').join('|')})`);
return { text, globstar: false, wildcard: true, re: new RegExp(`^${source}$`) };
});
}
/** The states a folder named `name` leads to from `states`; `walking` skips what pnpm's walk skips. */
function globStep(glob: GlobSegment[], states: Set<number>, name: string, walking: boolean): Set<number> {
const next = new Set<number>();
const hidden = walking && (name.startsWith('.') || GLOB_SKIP_DIRS.has(name));
for (const i of states) {
const seg = glob[i];
if (!seg) continue;
if (seg.globstar) {
if (!hidden && !(walking && GLOBSTAR_SKIP_DIRS.has(name))) next.add(i);
} else if (seg.re ? !hidden && seg.re.test(name) : seg.text === name) {
next.add(i + 1);
}
}
return globClosure(glob, next);
}
/** `states` with every `**` that can match no folder at all stepped over. */
function globClosure(glob: GlobSegment[], states: Set<number>): Set<number> {
for (const i of [...states]) {
for (let j = i; glob[j]?.globstar; j++) states.add(j + 1);
}
return states;
}
/** Read the `name` field from a member directory's package.json. */
function readPackageName(dirAbs: string): string | null {
try {