Component barrels (`export { default as X } from './X.svelte'`) and
monorepo workspace imports (`@scope/ui/widgets`) left the consumer↔component
edge uncreated, so live components showed a false `0 callers` — the canonical
dead-code signal — risking deletion of live code.
The Svelte default-barrel case broke at FOUR layers, each of which alone left
it unresolved:
- findExportedSymbol matched only function/class for a default export, never
`component` (Svelte/Vue SFCs are kind 'component').
- extractImportMappings had no svelte/vue branch, so SFC consumers produced
zero import mappings and resolveViaImport never ran.
- EXTENSION_RESOLUTION had no svelte/vue entry, so relative imports from an
SFC (`./lib` -> `/index.ts`) resolved to nothing.
- getReExports parsed the barrel in the CONSUMER's threaded language, so a
.svelte consumer made extractReExports bail on a .ts index barrel.
Workspace package-subpath barrels get a new workspace-packages module
(mirrors go-module/path-aliases): reads package.json `workspaces`
(npm/yarn/bun) + pnpm-workspace.yaml, maps member name->dir, resolves
`@scope/ui/widgets` -> `packages/ui/widgets`. Gated behind the workspaces
field so single-package repos are unaffected.
Bare `./`/`.` directory imports already resolved; covered with a regression
test. Verified both directions (callers/impact AND callees) for Svelte; Vue
script-level imports also resolve. 4 new tests; full suite green (1126).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7b62356f53
commit
bdfd55e69c
@@ -26,6 +26,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- Go methods declared on generic types (e.g. `func (s *Stack[T]) Push(...)`) are now correctly attached to their type, so callers, callees, and impact include them. (#583)
|
||||
- Asking what a symbol impacts no longer drags in every unrelated sibling method of its class — impact now follows real dependencies instead of the structural "contains" relationship, keeping the result focused on what actually depends on the symbol. (#536)
|
||||
- CodeGraph's MCP server now answers an agent's `resources/list` and `prompts/list` probes with an empty list instead of an error, clearing the `-32601` messages some clients (opencode, Codex) logged on connect. (#621)
|
||||
- Svelte and Vue components used through a barrel file — `export { default as Button } from './Button.svelte'` re-exported from an `index.ts` and imported elsewhere — are no longer falsely reported as having **0 callers**. CodeGraph now follows the default re-export all the way to the component and resolves the imports that `.svelte` / `.vue` files themselves use, so `codegraph_callers` and `codegraph_impact` see every place a component is used. This also covers components imported from another package in a workspace/monorepo (`@scope/ui/widgets`) and bare directory imports (`import { x } from './'`). Previously a live component consumed only through a barrel looked like dead code. Thanks @nakisen. (#629)
|
||||
|
||||
## [0.9.9] - 2026-06-02
|
||||
|
||||
|
||||
@@ -1248,6 +1248,147 @@ func main() {
|
||||
const callers = cg.getCallers(signInNode!.id);
|
||||
expect(callers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('follows a default re-export of a .svelte component (export { default as Foo } from ./RealButton.svelte) (#629)', async () => {
|
||||
// The ubiquitous Svelte/React component-barrel form. The leaf is a
|
||||
// .svelte component (extracted as kind 'component', the default
|
||||
// export). The re-export ALIAS (`Foo`) deliberately differs from the
|
||||
// component's real name (`RealButton`) so the name-matcher fallback
|
||||
// can't coincidentally connect them — the only path to the edge is
|
||||
// the import-chase, which must match a `component` (not just
|
||||
// function/class) for the default export. Otherwise the
|
||||
// consumer↔component edge is never created and `callers` returns a
|
||||
// false 0.
|
||||
fs.mkdirSync(path.join(tempDir, 'src/lib'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'src/lib/RealButton.svelte'),
|
||||
`<script lang="ts">\n export let label: string = '';\n</script>\n\n<button>{label}</button>\n`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'src/lib/index.ts'),
|
||||
`export { default as Foo } from './RealButton.svelte';\n`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'src/Bar.svelte'),
|
||||
`<script lang="ts">\n import { Foo } from './lib';\n</script>\n\n<Foo />\n`
|
||||
);
|
||||
|
||||
cg = await CodeGraph.init(tempDir, { index: true });
|
||||
cg.resolveReferences();
|
||||
|
||||
const fooNode = cg
|
||||
.getNodesByKind('component')
|
||||
.find((n) => n.name === 'RealButton' && n.filePath === 'src/lib/RealButton.svelte');
|
||||
expect(fooNode).toBeDefined();
|
||||
const callers = cg.getCallers(fooNode!.id);
|
||||
expect(callers.some((c) => c.node.filePath === 'src/Bar.svelte')).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves a bare directory import (import { x } from "." / "./") to index.ts (#629)', async () => {
|
||||
// `import { helper } from '.'` (or './') must map to the
|
||||
// directory's index.ts before the re-export chase can run. The
|
||||
// barrel renames `realHelper` → `helper` so the name-matcher can't
|
||||
// mask a path-resolution failure: only the bare-dir resolution +
|
||||
// rename chase can connect the edge.
|
||||
fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'src/util.ts'),
|
||||
`export function realHelper(): void {}\n`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'src/index.ts'),
|
||||
`export { realHelper as helper } from './util';\n`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'src/main.ts'),
|
||||
`import { helper } from '.';\nexport function go(): void { helper(); }\n`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'src/main2.ts'),
|
||||
`import { helper } from './';\nexport function go2(): void { helper(); }\n`
|
||||
);
|
||||
|
||||
cg = await CodeGraph.init(tempDir, { index: true });
|
||||
cg.resolveReferences();
|
||||
|
||||
const helperNode = cg
|
||||
.getNodesByKind('function')
|
||||
.find((n) => n.name === 'realHelper' && n.filePath === 'src/util.ts');
|
||||
expect(helperNode).toBeDefined();
|
||||
const callers = cg.getCallers(helperNode!.id);
|
||||
expect(callers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true);
|
||||
expect(callers.some((c) => c.node.filePath === 'src/main2.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves a workspace package-subpath barrel (@scope/pkg/sub) to its index (#629)', async () => {
|
||||
// bun/npm/pnpm workspace: `@scope/ui/widgets` → the `ui` package's
|
||||
// `widgets/` subdir index, which re-exports a .svelte component.
|
||||
// Alias `Thing` ≠ component `Widget` defeats the name-matcher, so
|
||||
// only workspace-package resolution can connect the edge.
|
||||
fs.mkdirSync(path.join(tempDir, 'packages/ui/widgets'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'package.json'),
|
||||
JSON.stringify({ name: 'root', private: true, workspaces: ['packages/*'] }, null, 2)
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'packages/ui/package.json'),
|
||||
JSON.stringify({ name: '@scope/ui', version: '1.0.0' }, null, 2)
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'packages/ui/widgets/Widget.svelte'),
|
||||
`<script lang="ts">\n export let label: string = '';\n</script>\n\n<button>{label}</button>\n`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'packages/ui/widgets/index.ts'),
|
||||
`export { default as Thing } from './Widget.svelte';\n`
|
||||
);
|
||||
fs.mkdirSync(path.join(tempDir, 'app'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'app/App.svelte'),
|
||||
`<script lang="ts">\n import { Thing } from '@scope/ui/widgets';\n</script>\n\n<Thing />\n`
|
||||
);
|
||||
|
||||
cg = await CodeGraph.init(tempDir, { index: true });
|
||||
cg.resolveReferences();
|
||||
|
||||
const buttonNode = cg
|
||||
.getNodesByKind('component')
|
||||
.find((n) => n.name === 'Widget' && n.filePath === 'packages/ui/widgets/Widget.svelte');
|
||||
expect(buttonNode).toBeDefined();
|
||||
const callers = cg.getCallers(buttonNode!.id);
|
||||
expect(callers.some((c) => c.node.filePath === 'app/App.svelte')).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves a barrel import from a Vue SFC <script> block (#629)', async () => {
|
||||
// The same import-resolution gaps (no SFC import mappings, no SFC
|
||||
// extension list, barrel parsed in the consumer's language) broke
|
||||
// Vue SFCs too. Guards the resolver-side generalization to `.vue`.
|
||||
// The barrel renames `realRun` → `run` so only the import-chase (not
|
||||
// the name-matcher) can connect the call.
|
||||
fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'src/util.ts'),
|
||||
`export function realRun(): void {}\n`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'src/index.ts'),
|
||||
`export { realRun as run } from './util';\n`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'src/App.vue'),
|
||||
`<script lang="ts">\nimport { run } from './';\nexport default { mounted() { run(); } };\n</script>\n<template><div/></template>\n`
|
||||
);
|
||||
|
||||
cg = await CodeGraph.init(tempDir, { index: true });
|
||||
cg.resolveReferences();
|
||||
|
||||
const runNode = cg
|
||||
.getNodesByKind('function')
|
||||
.find((n) => n.name === 'realRun' && n.filePath === 'src/util.ts');
|
||||
expect(runNode).toBeDefined();
|
||||
const callers = cg.getCallers(runNode!.id);
|
||||
expect(callers.some((c) => c.node.filePath === 'src/App.vue')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('C/C++ Import Resolution', () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as path from 'path';
|
||||
import { Language, Node } from '../types';
|
||||
import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, ReExport } from './types';
|
||||
import { applyAliases } from './path-aliases';
|
||||
import { resolveWorkspaceImport } from './workspace-packages';
|
||||
|
||||
/**
|
||||
* Extension resolution order by language
|
||||
@@ -18,6 +19,11 @@ const EXTENSION_RESOLUTION: Record<string, string[]> = {
|
||||
javascript: ['.js', '.jsx', '.mjs', '.cjs', '/index.js', '/index.jsx'],
|
||||
tsx: ['.tsx', '.ts', '.d.ts', '.js', '.jsx', '/index.tsx', '/index.ts', '/index.js'],
|
||||
jsx: ['.jsx', '.js', '/index.jsx', '/index.js'],
|
||||
// SFC consumers import plain TS/JS, sibling components, and barrels
|
||||
// (`./lib` → `./lib/index.ts`). Without a list, relative imports from a
|
||||
// `.svelte`/`.vue` file resolve to nothing, so barrel callers vanish (#629).
|
||||
svelte: ['.ts', '.js', '.svelte', '.tsx', '.jsx', '/index.ts', '/index.js', '/index.svelte'],
|
||||
vue: ['.ts', '.js', '.vue', '.tsx', '.jsx', '/index.ts', '/index.js', '/index.vue'],
|
||||
python: ['.py', '/__init__.py'],
|
||||
go: ['.go'],
|
||||
rust: ['.rs', '/mod.rs'],
|
||||
@@ -124,6 +130,15 @@ function isExternalImport(
|
||||
return false;
|
||||
}
|
||||
|
||||
// Workspace-member imports (`@scope/ui`, `@scope/ui/widgets`) are LOCAL to
|
||||
// a monorepo even though they look like bare npm specifiers. Consult the
|
||||
// workspace map first so they aren't misclassified as external (#629). The
|
||||
// map is null for single-package repos, so this is a no-op there.
|
||||
const workspaces = context?.getWorkspacePackages?.();
|
||||
if (workspaces && resolveWorkspaceImport(importPath, workspaces)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Common external patterns
|
||||
if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx') {
|
||||
// Node built-ins
|
||||
@@ -255,6 +270,18 @@ function resolveAliasedImport(
|
||||
}
|
||||
}
|
||||
|
||||
// 1.5 Workspace packages (`@scope/ui/widgets` → `packages/ui/widgets`).
|
||||
// Resolves a monorepo member import to the member's directory; the
|
||||
// extension/index permutations below then find its barrel (#629).
|
||||
const workspaces = context.getWorkspacePackages?.();
|
||||
if (workspaces) {
|
||||
const base = resolveWorkspaceImport(importPath, workspaces);
|
||||
if (base) {
|
||||
const hit = tryWithExt(base);
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Hard-coded fallback list. Kept for projects that use these
|
||||
// conventional aliases without declaring them in tsconfig.
|
||||
const fallbackAliases: Record<string, string> = {
|
||||
@@ -496,6 +523,16 @@ export function extractImportMappings(
|
||||
|
||||
if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx') {
|
||||
mappings.push(...extractJSImports(content));
|
||||
} else if (language === 'svelte' || language === 'vue') {
|
||||
// Svelte/Vue single-file components import via plain ES6 inside their
|
||||
// `<script>` block. Without this, a `.svelte`/`.vue` consumer produces
|
||||
// zero import mappings, so `resolveViaImport` can't run and a barrel
|
||||
// import (`import { Foo } from './lib'`) falls back to name-matching —
|
||||
// which silently fails whenever the re-export alias differs from the
|
||||
// component's real name, yielding a false 0 callers (#629). The ES6
|
||||
// import regex only matches `import … from '…'`, so running it over the
|
||||
// whole SFC (markup + styles included) is safe.
|
||||
mappings.push(...extractJSImports(content));
|
||||
} else if (language === 'python') {
|
||||
mappings.push(...extractPythonImports(content));
|
||||
} else if (language === 'go') {
|
||||
@@ -1248,9 +1285,17 @@ function findExportedSymbol(
|
||||
|
||||
// 1. Direct hit: the symbol is declared in this file.
|
||||
if (want.isDefault) {
|
||||
const direct = nodesInFile.find(
|
||||
(n) => n.isExported && (n.kind === 'function' || n.kind === 'class')
|
||||
);
|
||||
// Svelte/Vue single-file components ARE the module's default export,
|
||||
// but are extracted as kind 'component' (not function/class). Prefer
|
||||
// the component node; fall back to an exported function/class for the
|
||||
// `.ts`/`.tsx` `export default fn`/`class` case. Without the component
|
||||
// branch, an `export { default as X } from './X.svelte'` barrel never
|
||||
// resolves and the component shows a false 0 callers (#629).
|
||||
const direct =
|
||||
nodesInFile.find((n) => n.isExported && n.kind === 'component') ??
|
||||
nodesInFile.find(
|
||||
(n) => n.isExported && (n.kind === 'function' || n.kind === 'class')
|
||||
);
|
||||
if (direct) return direct;
|
||||
} else if (want.isNamespace && want.memberName) {
|
||||
const direct = nodesInFile.find(
|
||||
|
||||
+19
-1
@@ -22,6 +22,7 @@ import { detectFrameworks } from './frameworks';
|
||||
import { synthesizeCallbackEdges } from './callback-synthesizer';
|
||||
import { loadProjectAliases, type AliasMap } from './path-aliases';
|
||||
import { loadGoModule, type GoModule } from './go-module';
|
||||
import { loadWorkspacePackages, type WorkspacePackages } from './workspace-packages';
|
||||
import { logDebug } from '../errors';
|
||||
import type { ReExport } from './types';
|
||||
import { LRUCache } from './lru-cache';
|
||||
@@ -203,6 +204,8 @@ export class ReferenceResolver {
|
||||
private projectAliases: AliasMap | null | undefined = undefined;
|
||||
// go.mod module path. Same lazy/immutable convention as projectAliases.
|
||||
private goModule: GoModule | null | undefined = undefined;
|
||||
// Monorepo workspace member packages. Same lazy/immutable convention.
|
||||
private workspacePackages: WorkspacePackages | null | undefined = undefined;
|
||||
|
||||
constructor(projectRoot: string, queries: QueryBuilder) {
|
||||
this.projectRoot = projectRoot;
|
||||
@@ -423,6 +426,13 @@ export class ReferenceResolver {
|
||||
return this.goModule;
|
||||
},
|
||||
|
||||
getWorkspacePackages: () => {
|
||||
if (this.workspacePackages === undefined) {
|
||||
this.workspacePackages = loadWorkspacePackages(this.projectRoot);
|
||||
}
|
||||
return this.workspacePackages;
|
||||
},
|
||||
|
||||
getReExports: (filePath: string, language) => {
|
||||
const cached = this.reExportCache.get(filePath);
|
||||
if (cached) return cached;
|
||||
@@ -431,7 +441,15 @@ export class ReferenceResolver {
|
||||
this.reExportCache.set(filePath, []);
|
||||
return [];
|
||||
}
|
||||
const reExports = extractReExports(content, language);
|
||||
// Re-exports are a JS/TS-only construct, and what matters is the
|
||||
// BARREL file's own language — not the consuming reference's. A
|
||||
// `.svelte`/`.vue` consumer threads its own language down the
|
||||
// re-export chase, which would make extractReExports() bail on a
|
||||
// `.ts` index barrel and silently break the chain (#629). Re-key
|
||||
// the parse on the barrel's extension so the chase works no matter
|
||||
// what kind of file imports through it.
|
||||
const isJsFamily = /\.(?:d\.ts|[cm]?tsx?|[cm]?jsx?)$/i.test(filePath);
|
||||
const reExports = extractReExports(content, isJsFamily ? 'typescript' : language);
|
||||
this.reExportCache.set(filePath, reExports);
|
||||
return reExports;
|
||||
},
|
||||
|
||||
@@ -99,6 +99,13 @@ export interface ResolutionContext {
|
||||
* cross-package imports from third-party packages.
|
||||
*/
|
||||
getGoModule?(): import('./go-module').GoModule | null;
|
||||
/**
|
||||
* Monorepo workspace member packages, keyed by declared package name.
|
||||
* Returns `null` for single-package repos (no `workspaces` field).
|
||||
* Lets the resolver treat `@scope/ui/sub` as a local import into the
|
||||
* member's directory instead of an external npm package (#629).
|
||||
*/
|
||||
getWorkspacePackages?(): import('./workspace-packages').WorkspacePackages | null;
|
||||
/**
|
||||
* Re-exports declared by a file (`export { x } from './other'`,
|
||||
* `export * from './other'`). Empty array when the file has none.
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* JS/TS workspace (monorepo) package resolution.
|
||||
*
|
||||
* npm / yarn / bun read member packages from the root `package.json`
|
||||
* `workspaces` field; pnpm from `pnpm-workspace.yaml`. A cross-package
|
||||
* import like `@scope/ui/widgets` is LOCAL to the monorepo, but to a
|
||||
* single-package resolver it looks exactly like a third-party npm
|
||||
* specifier — so `isExternalImport` flags it external and the
|
||||
* consumer↔definition edge is never created. For component barrels
|
||||
* (`export { default as X } from './x.svelte'`) that surfaces as a false
|
||||
* `0 callers` on a live component (issue #629).
|
||||
*
|
||||
* This module maps each member package's declared `name` to its
|
||||
* directory so the resolver can rewrite `@scope/ui/widgets` →
|
||||
* `packages/ui/widgets` and then run normal extension/index resolution.
|
||||
*
|
||||
* 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/*`)
|
||||
* - 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-
|
||||
* package repos pay nothing and see no behaviour change.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { logDebug } from '../errors';
|
||||
|
||||
export interface WorkspacePackages {
|
||||
/** Member package `name` → directory relative to projectRoot (posix). */
|
||||
byName: Map<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load workspace member packages for `projectRoot`. Returns `null` when
|
||||
* the project declares no workspaces (the common single-package case) —
|
||||
* callers then skip all workspace logic.
|
||||
*
|
||||
* Cheap to call repeatedly only via the resolver's per-instance cache;
|
||||
* this function itself touches the filesystem, so the resolver memoises it
|
||||
* the same way it does {@link loadProjectAliases} / {@link loadGoModule}.
|
||||
*/
|
||||
export function loadWorkspacePackages(projectRoot: string): WorkspacePackages | null {
|
||||
const patterns = readWorkspaceGlobs(projectRoot);
|
||||
if (patterns.length === 0) return null;
|
||||
|
||||
const byName = new Map<string, string>();
|
||||
for (const pattern of patterns) {
|
||||
for (const dir of expandWorkspaceGlob(projectRoot, pattern)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (byName.size === 0) return null;
|
||||
|
||||
logDebug('workspace packages loaded', { count: byName.size });
|
||||
return { byName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a bare workspace import to a path relative to projectRoot,
|
||||
* WITHOUT an extension — the caller applies the language's extension/index
|
||||
* resolution. `@scope/ui/widgets` → `packages/ui/widgets`; the bare package
|
||||
* name `@scope/ui` → its directory. Returns `null` when no member package
|
||||
* name matches.
|
||||
*/
|
||||
export function resolveWorkspaceImport(
|
||||
importPath: string,
|
||||
ws: WorkspacePackages
|
||||
): string | null {
|
||||
// Longest matching package name wins, so `@scope/ui/core` prefers a
|
||||
// `@scope/ui/core` package over a `@scope/ui` one when both exist.
|
||||
let bestName: string | null = null;
|
||||
for (const name of ws.byName.keys()) {
|
||||
if (importPath === name || importPath.startsWith(name + '/')) {
|
||||
if (!bestName || name.length > bestName.length) bestName = name;
|
||||
}
|
||||
}
|
||||
if (!bestName) return null;
|
||||
const dir = ws.byName.get(bestName)!;
|
||||
const subpath = importPath.slice(bestName.length); // '' or '/widgets'
|
||||
return (dir + subpath).replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
/** Read workspace glob patterns from package.json + pnpm-workspace.yaml. */
|
||||
function readWorkspaceGlobs(projectRoot: string): string[] {
|
||||
const out: string[] = [];
|
||||
|
||||
// package.json `workspaces` (npm / yarn / bun): array, or Yarn's
|
||||
// `{ packages: [...], nohoist: [...] }` object form.
|
||||
try {
|
||||
const pkg = JSON.parse(
|
||||
fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8')
|
||||
);
|
||||
const ws = pkg?.workspaces;
|
||||
if (Array.isArray(ws)) {
|
||||
out.push(...ws.filter((w: unknown): w is string => typeof w === 'string'));
|
||||
} else if (ws && Array.isArray(ws.packages)) {
|
||||
out.push(...ws.packages.filter((w: unknown): w is string => typeof w === 'string'));
|
||||
}
|
||||
} catch {
|
||||
/* no / invalid package.json — not a workspace root */
|
||||
}
|
||||
|
||||
// pnpm-workspace.yaml `packages:` list. Parsed with a minimal line
|
||||
// scanner so we don't pull in a YAML dependency.
|
||||
try {
|
||||
const yaml = fs.readFileSync(path.join(projectRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
||||
out.push(...parsePnpmPackages(yaml));
|
||||
} catch {
|
||||
/* no pnpm-workspace.yaml */
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal pnpm-workspace.yaml `packages:` extractor. Handles the only shape
|
||||
* pnpm actually uses:
|
||||
* packages:
|
||||
* - 'packages/*'
|
||||
* - "apps/*"
|
||||
* - tools/build
|
||||
*/
|
||||
function parsePnpmPackages(yaml: string): string[] {
|
||||
const out: string[] = [];
|
||||
const lines = yaml.split(/\r?\n/);
|
||||
let inPackages = false;
|
||||
for (const line of lines) {
|
||||
if (/^\s*packages\s*:/.test(line)) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages) {
|
||||
const item = line.match(/^\s*-\s*(.+?)\s*$/);
|
||||
if (item) {
|
||||
out.push(item[1]!.replace(/^['"]|['"]$/g, ''));
|
||||
continue;
|
||||
}
|
||||
// A non-list, non-blank line ends the `packages:` block.
|
||||
if (line.trim() !== '' && !/^\s/.test(line)) inPackages = false;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Expand one level of a `packages/*` / `apps/**` glob to member dirs. */
|
||||
function expandWorkspaceGlob(projectRoot: string, pattern: string): string[] {
|
||||
const norm = pattern.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
const star = norm.indexOf('*');
|
||||
if (star === -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);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Read the `name` field from a member directory's package.json. */
|
||||
function readPackageName(dirAbs: string): string | null {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(dirAbs, 'package.json'), 'utf-8'));
|
||||
return typeof pkg?.name === 'string' && pkg.name ? pkg.name : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user