mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-25 12:00:02 +08:00
fix(js,ts): imports of a repo's own packages follow their package.json entries (#2799)
Issue 2502: a pnpm (or npm, Yarn, Bun) workspace member that declares its
entry with `main`, `module`, `types` or `exports`, like
`"main": "./src/index.ts"`, has no index file at its root, so
`import { caseStatus } from '@acme/core'` failed and its calls fell back to
name matching, where a same-named private function nearer the caller took
them. Issue 2456: a sibling package another declares as a path dependency
(`"acme-shared": "file:../common"`), with no root `workspaces`, was not
known as a package at all.
Root cause: workspace-packages.ts mapped a member's name to its folder only
(its header said it did not yet honour `exports` or `main`), and the
resolver probed `<folder>/index.*`. Path dependencies were read only from
ohpm manifests.
Fix:
- workspace-packages.ts reads each package's declared entries: `exports`
(subpaths, `*` patterns, fallback arrays, nested conditions, read with
import/require/node/module/default first, other conditions next, types
last) and the root's source/module/main/types/typings. It reads `file:`,
`link:` and `portal:` dependencies from every package.json in the
existing manifest walk: the root manifest's for every file, any other's
for the files under it, as npm installs them.
- The resolver takes the first declared target, `exports` before the
main-style fields, that the index holds or that is build output the
index lacks, read as the source it is built from (dist/, build/, lib/,
out/, es/, esm/, cjs/ mirrored under src/, then without a format folder).
Then the package's folder, as before, and a path into its build output
(`@acme/utils/lib/path`) read as its source. A file a local build left on
disk never wins over an indexed source, and no mapping links nothing.
- Sync: a full reconcile reads the manifests again and, when their digest
moved, re-opens every JavaScript-family reference, as a moved Python root
set does. A file added inside a package re-checks the imports of that
package that landed elsewhere in it. A full index drops the cached map.
Validation:
- __tests__/workspace-package-entries.test.ts (26 tests): 25 fail on main,
the control passes; all pass on the kernel and with CODEGRAPH_KERNEL=0.
Each of 17 rules removed in turn fails at least one test.
- Sync equals a fresh index in 15 CLI scenarios (entries, subpaths, a
member renamed, added or leaving the globs, file: deps added, removed or
retargeted, an entry's file appearing). On real repos: two vben manifest
edits (237 edge rows move) and a vite playground file: edit, synced graph
identical to a fresh index.
- A/B on 14 TS/JS repos and 2 controls: vben +2,955 edges, insomnia +2,222,
webdriverio +2,472, jest +1,700, nuxt +706, memfs +470, vite +251,
t3-turbo +47, social-app +40, vue3-antdv-admin +8, with name-matched
namesakes corrected; nitro, outline, pathe, c12, flask and gin are
byte-identical.
- Cost: fresh index within 3% (median of 3, interleaved); a full sync reads
the manifests, +30 to 160 ms on these monorepos.
Fixes #2502
Fixes #2456
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c1744e7e76
commit
e8ddc4e2be
@@ -3403,6 +3403,37 @@ export class QueryBuilder {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The module edges (`imports` edges into a file node) into any file under
|
||||
* the folder `dir` (projectRoot-relative, no trailing slash), with the
|
||||
* source file and language a resurrection needs: where an import of a
|
||||
* package by its name can land, whichever file of the package its manifest
|
||||
* or the folder's index makes the entry.
|
||||
*/
|
||||
getModuleImportsUnder(
|
||||
dir: string
|
||||
): Array<Edge & { edgeId: number; targetFilePath: string; sourceFilePath: string; sourceLanguage: Language }> {
|
||||
// `dir/` up to `dir0` ('0' is the byte after '/').
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT e.*, tgt.file_path AS target_file_path, src.file_path AS source_file_path, src.language AS source_language
|
||||
FROM nodes tgt
|
||||
JOIN edges e ON e.target = tgt.id AND e.kind = 'imports'
|
||||
JOIN nodes src ON src.id = e.source
|
||||
WHERE tgt.kind = 'file' AND tgt.file_path >= ? AND tgt.file_path < ?`
|
||||
)
|
||||
.all(`${dir}/`, `${dir}0`) as Array<
|
||||
EdgeRow & { target_file_path: string; source_file_path: string; source_language: Language }
|
||||
>;
|
||||
return rows.map((row) => ({
|
||||
...rowToEdge(row),
|
||||
edgeId: row.id,
|
||||
targetFilePath: row.target_file_path,
|
||||
sourceFilePath: row.source_file_path,
|
||||
sourceLanguage: row.source_language,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The module edges (`imports` edges into a file node) into the files an
|
||||
* import of `dir/name` can find instead of a file added there: `dir/name.*`
|
||||
|
||||
+45
-2
@@ -68,6 +68,7 @@ import { loadDeprioritizePatterns } from './project-config';
|
||||
import { CodeGraphPackageVersion } from './mcp/version';
|
||||
import { extractSegmentSearchWords, segmentLookupVariants, splitIdentifierSegments } from './search/identifier-segments';
|
||||
import { createYielder } from './resolution/cooperative-yield';
|
||||
import { ESM_IMPORT_LANGUAGES } from './resolution/import-resolver';
|
||||
import { minRefsForPool } from './resolution/resolver-pool';
|
||||
|
||||
// Re-export types for consumers
|
||||
@@ -150,6 +151,8 @@ export interface IndexOptions {
|
||||
*/
|
||||
/** project_metadata key for the Python package-root digest (see sync). */
|
||||
const PYTHON_ROOTS_METADATA_KEY = 'python_root_fingerprint';
|
||||
/** Metadata key: the digest of the workspace packages and path dependencies the index was resolved by. */
|
||||
const WORKSPACE_METADATA_KEY = 'js_workspace_fingerprint';
|
||||
|
||||
export class CodeGraph {
|
||||
private db: DatabaseConnection;
|
||||
@@ -772,6 +775,7 @@ export class CodeGraph {
|
||||
// real full index — a sync touches a subset, so it must NOT advance the
|
||||
// extraction stamp (the bulk would still be stale). See extraction-version.ts.
|
||||
if (result.success) this.stampPythonRootFingerprint();
|
||||
if (result.success) this.stampWorkspaceFingerprint();
|
||||
|
||||
if (result.success && result.filesIndexed > 0) {
|
||||
try {
|
||||
@@ -882,6 +886,13 @@ export class CodeGraph {
|
||||
} catch { /* metadata is advisory */ }
|
||||
}
|
||||
|
||||
/** Record the workspace-package digest a later sync compares against (see sync). */
|
||||
private stampWorkspaceFingerprint(fingerprint = this.resolver.getWorkspaceFingerprint()): void {
|
||||
try {
|
||||
this.queries.setMetadata(WORKSPACE_METADATA_KEY, fingerprint);
|
||||
} catch { /* metadata is advisory */ }
|
||||
}
|
||||
|
||||
async sync(options: IndexOptions = {}): Promise<SyncResult> {
|
||||
return this.indexMutex.withLock(async () => {
|
||||
const startedAt = Date.now();
|
||||
@@ -1047,6 +1058,35 @@ export class CodeGraph {
|
||||
}
|
||||
}
|
||||
|
||||
// Workspace packages and path dependencies (#2456, #2502). A
|
||||
// package.json edit — a member's name, its `main` or `exports`, a
|
||||
// `file:` dependency — moves what an import of the package resolves to
|
||||
// in files it never touched, and no indexed file changes with it. A
|
||||
// full reconcile (`codegraph sync`, a git hook, the watcher's catch-up)
|
||||
// reads the manifests again; when what they declare moved, every
|
||||
// JavaScript-family reference is re-opened, as a moved Python root set
|
||||
// re-opens Python's below, and the orphan sweep resolves them by the
|
||||
// new map exactly as a fresh index would. Package.json edits that leave
|
||||
// the map alone (a version bump, a new registry dependency) cost one
|
||||
// read. The watcher's scoped syncs follow source files only and skip
|
||||
// the read: a monorepo's manifests take a walk of the tree to find.
|
||||
// An index stamped by an older engine has no digest: record one.
|
||||
if (fullReconcile) {
|
||||
const tWorkspace = Date.now();
|
||||
const before = this.queries.getMetadata(WORKSPACE_METADATA_KEY);
|
||||
const now = this.resolver.refreshWorkspacePackages();
|
||||
if (before !== null && before !== now) {
|
||||
let reopened = 0;
|
||||
for (const language of ESM_IMPORT_LANGUAGES) reopened += this.orchestrator.resurrectResolutionEdgesForLanguage(language);
|
||||
refreshSynthesis = true;
|
||||
this.queries.setMetadata('synthesis_pending', '1');
|
||||
if (process.env.CODEGRAPH_SYNTH_TIMINGS) {
|
||||
console.error(`[phase-timing] sync-workspace-packages: ${Date.now() - tWorkspace}ms (${reopened} refs re-opened)`);
|
||||
}
|
||||
}
|
||||
if (before !== now) this.stampWorkspaceFingerprint(now);
|
||||
}
|
||||
|
||||
// Resolve references if files were updated
|
||||
const filesChanged = result.filesAdded > 0 || result.filesModified > 0;
|
||||
// The last edge before the failed-ref retry below: the rebind's
|
||||
@@ -1170,7 +1210,9 @@ export class CodeGraph {
|
||||
// `index.ts` re-exporting what it imports: what the files importing
|
||||
// it bound by name in its absence, or failed, goes back to pending
|
||||
// for the sweep below, which binds it through the module as a fresh
|
||||
// index does. (The removal half runs before removed files are deleted.)
|
||||
// index does. So does what an import of a workspace package bound
|
||||
// through another of its files while the entry its manifest names
|
||||
// was missing. (The removal half runs before removed files are deleted.)
|
||||
const importReader = createImportReader(this.projectRoot);
|
||||
const added = (result.addedFilePaths ?? []).flatMap((filePath) => {
|
||||
const file = this.queries.getFileByPath(filePath);
|
||||
@@ -1180,7 +1222,8 @@ export class CodeGraph {
|
||||
const tAdded = Date.now();
|
||||
const reopened = reopenReferencesThroughAddedFiles(
|
||||
this.queries, importReader, added, edgesBeforeResolution,
|
||||
(specifier, fromFile, language) => this.resolver.resolveModuleFile(specifier, fromFile, language)
|
||||
(specifier, fromFile, language) => this.resolver.resolveModuleFile(specifier, fromFile, language),
|
||||
(file) => this.resolver.workspacePackageDirOf(file)
|
||||
);
|
||||
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-added-module-importers: ${Date.now() - tAdded}ms (${reopened} refs re-opened)`);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Language, Node } from '../types';
|
||||
import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, ReExport } from './types';
|
||||
import { applyAliases, aliasAndBaseUrlCandidates } from './path-aliases';
|
||||
import { extractLocalExportAliases } from './alias-binding';
|
||||
import { resolveWorkspaceImport } from './workspace-packages';
|
||||
import { resolveWorkspaceImport, workspaceImportTargets, type WorkspaceImportTargets } from './workspace-packages';
|
||||
import { stripCommentsForRegex } from './strip-comments';
|
||||
import { makeLineAt } from './synth-utils';
|
||||
import { dartDirectiveFile } from './dart-libraries';
|
||||
@@ -389,7 +389,7 @@ function resolveImportPathUncached(
|
||||
// alias map first (custom prefixes like `@components/*` would
|
||||
// otherwise be misclassified as npm). What still looks like a package
|
||||
// may name a file through the importing app's own config.
|
||||
if (isExternalImport(importPath, language, context)) {
|
||||
if (isExternalImport(importPath, language, context, { fromFile })) {
|
||||
return resolveFromOwnConfig(importPath, fromFile, language, context);
|
||||
}
|
||||
|
||||
@@ -1105,12 +1105,16 @@ const RUST_STDLIB_ROOTS = new Set(['std', 'core', 'alloc', 'proc_macro']);
|
||||
* asked `resolveImportPath` whether an alias maps the specifier to a file.
|
||||
* Matching a prefix proves nothing by itself: a catch-all `"*"` pattern
|
||||
* (`"*": ["./typings/*"]`) has an empty prefix and matches every package.
|
||||
*
|
||||
* `fromFile`, the importing file, lets a package another package depends on
|
||||
* by path (`"acme-shared": "file:../common"`) count as the project's for the
|
||||
* files under the manifest that declares it (#2456).
|
||||
*/
|
||||
export function isExternalImport(
|
||||
importPath: string,
|
||||
language: Language,
|
||||
context?: ResolutionContext,
|
||||
options: { aliasPrefixes?: boolean } = {}
|
||||
options: { aliasPrefixes?: boolean; fromFile?: string } = {}
|
||||
): boolean {
|
||||
// Relative imports are not external
|
||||
if (importPath.startsWith('.')) {
|
||||
@@ -1122,7 +1126,7 @@ export function isExternalImport(
|
||||
// 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)) {
|
||||
if (workspaces && resolveWorkspaceImport(importPath, workspaces, options.fromFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1244,7 +1248,8 @@ const EMITTED_TO_SOURCE_EXTENSIONS: ReadonlyArray<readonly [RegExp, readonly str
|
||||
function findSourceForEmittedSpecifier(
|
||||
relativePath: string,
|
||||
language: Language,
|
||||
context: ResolutionContext
|
||||
context: ResolutionContext,
|
||||
exists: (file: string) => boolean = (file) => context.fileExists(file)
|
||||
): string | null {
|
||||
if (!EMITTED_SPECIFIER_LANGUAGES.has(language)) return null;
|
||||
for (const [emitted, sources] of EMITTED_TO_SOURCE_EXTENSIONS) {
|
||||
@@ -1252,7 +1257,7 @@ function findSourceForEmittedSpecifier(
|
||||
const stem = relativePath.replace(emitted, '');
|
||||
for (const ext of sources) {
|
||||
const candidate = stem + ext;
|
||||
if (context.fileExists(candidate)) return candidate;
|
||||
if (exists(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1297,16 +1302,23 @@ 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).
|
||||
// 1.5 Workspace packages and path dependencies: the entry the package's
|
||||
// manifest declares for the import (#2502), else the member's
|
||||
// directory (`@scope/ui/widgets` → `packages/ui/widgets`), where the
|
||||
// extension/index permutations find its barrel (#629). A path into the
|
||||
// package's build output the index lacks (`@acme/utils/lib/path`) is
|
||||
// read as the source it is built from (`src/path.ts`), as a declared
|
||||
// entry is, before a file only a local build left on disk.
|
||||
const workspaces = context.getWorkspacePackages?.();
|
||||
if (workspaces) {
|
||||
const base = resolveWorkspaceImport(importPath, workspaces);
|
||||
if (base) {
|
||||
const hit = tryWithExt(base);
|
||||
if (hit) return hit;
|
||||
}
|
||||
const targets = workspaces ? workspaceImportTargets(importPath, workspaces, fromFile) : null;
|
||||
if (targets) {
|
||||
const declared = declaredWorkspaceEntry(targets, language, context);
|
||||
if (declared) return declared;
|
||||
const direct = tryWithExt(targets.directory);
|
||||
if (direct && (context.isIndexedFile?.(direct) ?? true)) return direct;
|
||||
const built = builtSourceOf(targets.directory, targets.packageDir, language, context);
|
||||
if (built) return built;
|
||||
if (direct) return direct;
|
||||
}
|
||||
|
||||
// 2. Hard-coded fallback list. Kept for projects that use these
|
||||
@@ -1364,6 +1376,80 @@ function indexedSpelling(filePath: string, language: Language): string {
|
||||
: filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* The file a package's declared entry for an import lands on (#2502): the
|
||||
* first declared target, in order (`exports` by condition, then the root's
|
||||
* `main`-style fields), that the index holds, or that is build output the
|
||||
* index lacks (`dist/index.js`), read as the source it is built from
|
||||
* (`src/index.ts`). So `import`'s `./build/index.js` leads to `src/index.ts`
|
||||
* before a custom condition's `./src/browser.ts` does. Null when none holds
|
||||
* — the caller then tries the package's directory, as it did before entries
|
||||
* were read.
|
||||
*/
|
||||
function declaredWorkspaceEntry(targets: WorkspaceImportTargets, language: Language, context: ResolutionContext): string | null {
|
||||
for (const target of targets.declared) {
|
||||
const hit = indexedTarget(target, language, context) ?? builtSourceOf(target, targets.packageDir, language, context);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `target`, build output the index lacks, as the indexed source it is built from (see builtSourcesOf); null when there is none. */
|
||||
function builtSourceOf(target: string, packageDir: string, language: Language, context: ResolutionContext): string | null {
|
||||
for (const source of builtSourcesOf(target, packageDir)) {
|
||||
const hit = indexedTarget(source, language, context);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `target` as a file the index holds: as written, with the language's
|
||||
* extensions or index files, or as the source of an emitted `.js`. Only the
|
||||
* index counts: build output a developer has built locally is on disk, but
|
||||
* nothing links to it.
|
||||
*/
|
||||
function indexedTarget(target: string, language: Language, context: ResolutionContext): string | null {
|
||||
const indexed = (file: string): boolean => context.isIndexedFile?.(file) ?? context.fileExists(file);
|
||||
const asIs = indexedSpelling(target, language);
|
||||
if (indexed(asIs)) return asIs;
|
||||
for (const ext of EXTENSION_RESOLUTION[language] || []) {
|
||||
const candidate = indexedSpelling(target + ext, language);
|
||||
if (indexed(candidate)) return candidate;
|
||||
}
|
||||
return findSourceForEmittedSpecifier(asIs, language, context, indexed);
|
||||
}
|
||||
|
||||
/** A package's build folder: `dist/`, `build/`, `lib/`, `out/`, `es/`, `esm/`, `cjs/`. */
|
||||
const BUILD_ROOT = /^(?:dist|build|lib|out|output|es|esm|cjs|umd)\//;
|
||||
/** The format subfolder a bundler adds below it (`dist/esm/`, `dist/types/`), or the source folder tsc mirrors (`dist/src/`). */
|
||||
const BUILD_FORMAT = /^(?:esm|cjs|es|mjs|module|modules|commonjs|types|typings|umd|node|browser|src)\//;
|
||||
/** An emitted file's extension, declaration files included. */
|
||||
const EMITTED_EXTENSION = /(?:\.d)?\.(?:[cm]?[jt]s|[jt]sx)$/;
|
||||
|
||||
/**
|
||||
* The source files a package's build output may be built from,
|
||||
* extension-less, most literal first: the build folder mirrored under
|
||||
* `src/` (vite's `dist/node/index.js` → `src/node/index`), then without a
|
||||
* format subfolder (`dist/esm/index.mjs`, `dist/types/index.d.ts` →
|
||||
* `src/index`). The caller links one only if the index holds it, so a
|
||||
* mapping that is not clear links nothing. Empty for a target outside a
|
||||
* build folder.
|
||||
*/
|
||||
function builtSourcesOf(target: string, packageDir: string): string[] {
|
||||
const prefix = packageDir === '' ? '' : packageDir + '/';
|
||||
if (!target.startsWith(prefix)) return [];
|
||||
const inPackage = target.slice(prefix.length);
|
||||
const root = BUILD_ROOT.exec(inPackage);
|
||||
if (!root) return [];
|
||||
const mirrored = inPackage.slice(root[0].length);
|
||||
const format = BUILD_FORMAT.exec(mirrored);
|
||||
const stems = [mirrored, ...(format ? [mirrored.slice(format[0].length)] : [])]
|
||||
.map((rest) => rest.replace(EMITTED_EXTENSION, ''))
|
||||
.filter((stem) => stem !== '' && !stem.startsWith('src/'));
|
||||
return [...new Set(stems)].map((stem) => `${prefix}src/${stem}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare specifier `isExternalImport` takes for a package, resolved through
|
||||
* the importing app's own tsconfig / jsconfig — the one nearest the file,
|
||||
@@ -4242,7 +4328,7 @@ export function isBoundToOutOfRepoImport(
|
||||
if (imp.localName !== name) continue;
|
||||
// A package the repository declares itself (`declare module 'x'`) has
|
||||
// its declarations in the repository.
|
||||
return isExternalImport(imp.source, ref.language, context) &&
|
||||
return isExternalImport(imp.source, ref.language, context, { fromFile: ref.filePath }) &&
|
||||
resolveImportPath(imp.source, ref.filePath, ref.language, context) === null &&
|
||||
!ambientModuleDeclares(imp.source, imp.exportedName, imp.isDefault, ref, context);
|
||||
}
|
||||
@@ -4315,7 +4401,7 @@ export function importBindingTail(ref: UnresolvedRef, context: ResolutionContext
|
||||
const binding = mappings.find((m) => m.localName === name) ??
|
||||
(receiver && ref.referenceKind === 'calls' ? localTypeBinding(receiver, ref, context, mappings) : undefined);
|
||||
if (!binding) return undefined;
|
||||
if (isExternalImport(binding.source, ref.language, context) &&
|
||||
if (isExternalImport(binding.source, ref.language, context, { fromFile: ref.filePath }) &&
|
||||
// A monorepo app's own tsconfig alias (`#/views/…`): isExternalImport reads the root's only.
|
||||
!context.getNearestAliases?.(ref.filePath)?.patterns.some((p) => p.prefix !== '' && binding.source.startsWith(p.prefix)) &&
|
||||
// A module the app's `baseUrl` or catch-all `paths` reads (`layout/MainLayout`).
|
||||
|
||||
+34
-2
@@ -45,7 +45,7 @@ import { createYielder, type MaybeYield } from './cooperative-yield';
|
||||
import { MAX_SOURCE_FILE_SIZE_BYTES } from '../file-limits';
|
||||
import { loadProjectAliases, type AliasMap } from './path-aliases';
|
||||
import { findGoModuleForImport, goModulePackageDir, loadGoModule, type GoModule } from './go-module';
|
||||
import { loadWorkspacePackages, type WorkspacePackages } from './workspace-packages';
|
||||
import { loadWorkspacePackages, workspaceFingerprint, workspacePackageDirOf, type WorkspacePackages } from './workspace-packages';
|
||||
import { logDebug } from '../errors';
|
||||
import { stripCommentsForRegex } from './strip-comments';
|
||||
import { lexicalPathWithinRoot } from '../utils';
|
||||
@@ -365,6 +365,9 @@ export class ReferenceResolver {
|
||||
* Initialize the resolver (detect frameworks, etc.)
|
||||
*/
|
||||
initialize(): void {
|
||||
// A full index resolves by the package.json files on disk now, as the
|
||||
// fingerprint it stamps says, even in a process that read them earlier.
|
||||
this.workspacePackages = undefined;
|
||||
this.frameworks = detectFrameworks(this.context);
|
||||
this.frameworksByLanguage.clear();
|
||||
this.clearCaches();
|
||||
@@ -706,7 +709,7 @@ export class ReferenceResolver {
|
||||
// `"lit/decorators": ["./node_modules/lit/decorators.js"]` lands in
|
||||
// `node_modules`, topcoder's `config` package on its `config/` folder.
|
||||
isOutOfRepoImport: (source, fromFile, language) => {
|
||||
if (!isExternalImport(source, language, this.context, { aliasPrefixes: false })) return false;
|
||||
if (!isExternalImport(source, language, this.context, { aliasPrefixes: false, fromFile })) return false;
|
||||
const resolved = resolveImportPath(source, fromFile, language, this.context);
|
||||
if (resolved !== null && this.isIndexedFile(resolved)) return false;
|
||||
return this.isDeclaredOutsidePackage(source, fromFile);
|
||||
@@ -2850,6 +2853,35 @@ export class ReferenceResolver {
|
||||
return pythonRootFingerprint(this.diskContext());
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace packages and path dependencies as package.json files
|
||||
* declare them now, read again from disk, and their fingerprint (see
|
||||
* `workspaceFingerprint`). The resolver keeps the map for its lifetime, so
|
||||
* a sync calls this to see a manifest edit; when the map moved, the
|
||||
* resolver takes the new one and drops every memo resolved through the old
|
||||
* one, and the caller re-opens what was linked by it.
|
||||
*/
|
||||
refreshWorkspacePackages(): string {
|
||||
const fresh = loadWorkspacePackages(this.projectRoot);
|
||||
const fingerprint = workspaceFingerprint(fresh);
|
||||
if (this.workspacePackages === undefined || workspaceFingerprint(this.workspacePackages) !== fingerprint) {
|
||||
this.workspacePackages = fresh;
|
||||
this.clearCaches();
|
||||
}
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
/** The fingerprint of the workspace map this resolver resolves by (see `workspaceFingerprint`). */
|
||||
getWorkspaceFingerprint(): string {
|
||||
return workspaceFingerprint(this.context.getWorkspacePackages?.() ?? null);
|
||||
}
|
||||
|
||||
/** The folder of the workspace package or path dependency `file` is in (see `workspacePackageDirOf`), or null. */
|
||||
workspacePackageDirOf(file: string): string | null {
|
||||
const ws = this.context.getWorkspacePackages?.();
|
||||
return ws ? workspacePackageDirOf(ws, file) : null;
|
||||
}
|
||||
|
||||
/** What a sync must re-open after these Python files were added or removed (see `pythonReopenScope`). */
|
||||
getPythonReopenScope(changedFiles: string[]): ReturnType<typeof pythonReopenScope> {
|
||||
return pythonReopenScope(this.diskContext(), changedFiles);
|
||||
|
||||
@@ -161,6 +161,9 @@ export function reopenReferencesThroughRemovedFiles(
|
||||
/** Resolves a module specifier written in `fromFile` as its import does: the project file it names, or null. */
|
||||
export type ModuleResolver = (specifier: string, fromFile: string, language: Language) => string | null;
|
||||
|
||||
/** The folder of the project's own package (a workspace member, a path dependency) `file` is in, or null. */
|
||||
export type PackageDirOf = (file: string) => string | null;
|
||||
|
||||
/**
|
||||
* The other half: after a sync's retries, re-open the references the files
|
||||
* importing any of `added` make through those imports — the edges a name
|
||||
@@ -170,8 +173,10 @@ export type ModuleResolver = (specifier: string, fromFile: string, language: Lan
|
||||
* them. (The failed-import retry looks those up by the module's name, and
|
||||
* skips a name as common as `models` or `test`.) With `resolveModule`, also an
|
||||
* import that resolved to a file beside an added one that now outranks it
|
||||
* (`./button` to `button.vue` while `button.ts` was missing), and what that
|
||||
* statement binds. Returns the number re-opened.
|
||||
* (`./button` to `button.vue` while `button.ts` was missing), or, with
|
||||
* `packageDirOf`, to another file of the package an added file is in (see
|
||||
* outrankedImports), and what that statement binds. Returns the number
|
||||
* re-opened.
|
||||
*/
|
||||
export function reopenReferencesThroughAddedFiles(
|
||||
queries: QueryBuilder,
|
||||
@@ -179,8 +184,9 @@ export function reopenReferencesThroughAddedFiles(
|
||||
added: ReadonlyArray<{ path: string; language: Language }>,
|
||||
resolvedUpToEdgeId: number,
|
||||
resolveModule?: ModuleResolver,
|
||||
packageDirOf?: PackageDirOf,
|
||||
): number {
|
||||
const outranked = resolveModule ? outrankedImports(queries, added, resolveModule) : [];
|
||||
const outranked = resolveModule ? outrankedImports(queries, added, resolveModule, packageDirOf) : [];
|
||||
const bindings = bindingsOf(importersOf(queries, added, reader, outranked), reader);
|
||||
const failed = [...bindings].flatMap(([file, { modules, names }]) => queries.getFailedReferencesThrough(file, modules, names));
|
||||
return reopenModuleEdges(queries, outranked) +
|
||||
@@ -430,17 +436,38 @@ interface OutrankedImport {
|
||||
* another extension of its name, or its folder's own entry — when the
|
||||
* import's specifier now names the added file: `./button` resolved to
|
||||
* `button.vue` while `button.ts` was missing, and `button.ts` comes first.
|
||||
* With `packageDirOf`, also the imports of a package by its name into any
|
||||
* file of the package an added file is in: the entry its manifest declares
|
||||
* (`./src/index.ts`, or the source of `./dist/index.js`), or the source of a
|
||||
* path into its build output, outranks the file an import fell back to while
|
||||
* it was missing, such as the folder's own index (#2502).
|
||||
*/
|
||||
function outrankedImports(
|
||||
queries: QueryBuilder,
|
||||
added: ReadonlyArray<{ path: string; language: Language }>,
|
||||
resolveModule: ModuleResolver,
|
||||
packageDirOf?: PackageDirOf,
|
||||
): OutrankedImport[] {
|
||||
const out: OutrankedImport[] = [];
|
||||
const packageImports = new Map<string, ReturnType<QueryBuilder['getModuleImportsUnder']>>();
|
||||
for (const file of added) {
|
||||
if (!BINDING_LANGUAGES.has(file.language)) continue;
|
||||
const { dir, name } = importedName(file.path);
|
||||
for (const edge of queries.getModuleImportsNamed(dir, name)) {
|
||||
const edges = queries.getModuleImportsNamed(dir, name);
|
||||
const packageDir = packageDirOf?.(file.path);
|
||||
if (packageDir) {
|
||||
let imports = packageImports.get(packageDir);
|
||||
if (!imports) {
|
||||
imports = queries.getModuleImportsUnder(packageDir).filter((edge) => {
|
||||
const specifier = edge.metadata?.refName;
|
||||
return typeof specifier === 'string' && !specifier.startsWith('.') && !specifier.startsWith('/');
|
||||
});
|
||||
packageImports.set(packageDir, imports);
|
||||
}
|
||||
const seen = new Set(edges.map((edge) => edge.edgeId));
|
||||
for (const edge of imports) if (!seen.has(edge.edgeId)) edges.push(edge);
|
||||
}
|
||||
for (const edge of edges) {
|
||||
const specifier = edge.metadata?.refName;
|
||||
if (edge.targetFilePath === file.path || typeof specifier !== 'string') continue;
|
||||
if (resolveModule(specifier, edge.sourceFilePath, edge.sourceLanguage) === file.path) {
|
||||
|
||||
@@ -11,27 +11,37 @@
|
||||
* `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.
|
||||
* directory, and its package.json's declared entries (`exports`, `main`,
|
||||
* `module`, `types`) to files, so the resolver can rewrite `@scope/ui` →
|
||||
* `packages/ui/src/index.ts` and `@scope/ui/widgets` → the file its
|
||||
* `exports` names, or `packages/ui/widgets` with normal extension/index
|
||||
* resolution when the manifest names none (#2502).
|
||||
*
|
||||
* 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 `*`, `?` 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-
|
||||
* - reads the sibling packages a package.json depends on by path
|
||||
* (`"acme-shared": "file:../common"`, `link:`, `portal:`), which need no
|
||||
* workspace declaration at all: the root manifest's for every file, any
|
||||
* other's for the files under it, as npm installs a package's
|
||||
* dependencies for that package (#2456)
|
||||
* - returns null when the project declares none of these, so single-
|
||||
* package repos pay nothing and see no behaviour change.
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
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). */
|
||||
/**
|
||||
* Package `name` → directory relative to projectRoot (posix): the workspace
|
||||
* members, ohpm's `file:` dependencies, and the root package.json's path
|
||||
* dependencies.
|
||||
*/
|
||||
byName: Map<string, string>;
|
||||
/**
|
||||
* Member package `name` → its declared ENTRY FILE relative to projectRoot
|
||||
@@ -40,15 +50,64 @@ export interface WorkspacePackages {
|
||||
* the member's real barrel even when it doesn't follow an index-file
|
||||
* convention — and independent of the CONSUMER's language (a `.ts` file
|
||||
* importing an `.ets` barrel resolves without `.ets` in the TS candidate
|
||||
* list). Absent for npm/pnpm members (their index conventions cover it).
|
||||
* list). npm/pnpm packages use `entriesByDir`.
|
||||
*/
|
||||
entryByName?: Map<string, string>;
|
||||
/**
|
||||
* The entries an npm/pnpm/yarn/bun package's package.json declares, by the
|
||||
* package's directory (see PackageEntries).
|
||||
*/
|
||||
entriesByDir?: Map<string, PackageEntries>;
|
||||
/**
|
||||
* The path dependencies a package.json below the root declares: its
|
||||
* directory → dependency name → target directory (all projectRoot-relative
|
||||
* posix). Seen only from the files under that manifest — a test fixture's
|
||||
* `"left-pad": "file:../stub"` is the fixture's own, and `left-pad` stays the
|
||||
* npm package for everyone else. The root manifest's are in `byName`.
|
||||
*/
|
||||
scopedByName?: Map<string, Map<string, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a package.json declares a package's importers reach, as targets
|
||||
* relative to projectRoot (posix), best candidate first. A target may name
|
||||
* build output the index does not hold; the resolver then reads the source it
|
||||
* is built from, where that is clear.
|
||||
*/
|
||||
export interface PackageEntries {
|
||||
/**
|
||||
* The `exports` map: `.`, `./utils`, `./schema/*` → targets. A string, an
|
||||
* array or a conditions-only `exports` is the `.` entry. A key mapped to
|
||||
* `null` (not exported) holds no target.
|
||||
*/
|
||||
exports?: Map<string, string[]>;
|
||||
/**
|
||||
* The package root's `source`, `module`, `main`, `types` and `typings`
|
||||
* fields, read after `exports` (as TypeScript's older `node` resolution
|
||||
* reads them in its place).
|
||||
*/
|
||||
main?: string[];
|
||||
}
|
||||
|
||||
/** Where an import of a workspace or path-dependency package lands, before any file is probed. */
|
||||
export interface WorkspaceImportTargets {
|
||||
/**
|
||||
* The entries the package declares for the import, best first: what
|
||||
* `exports` names for it in condition order, then, for the package root,
|
||||
* its `main`-style fields.
|
||||
*/
|
||||
declared: string[];
|
||||
/** The package's directory plus the import's subpath (`packages/ui/widgets`), extension-less. */
|
||||
directory: string;
|
||||
/** The package's directory. */
|
||||
packageDir: 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.
|
||||
* the project declares no workspaces and no package depends on another by
|
||||
* path (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
|
||||
@@ -75,19 +134,105 @@ export function loadWorkspacePackages(projectRoot: string): WorkspacePackages |
|
||||
// oh-package.json5 declares its local siblings as `"data": "file:../../
|
||||
// core/data"` dependencies, and code then imports the bare name
|
||||
// (`import { CartRepository } from "data"`). Same monorepo problem as npm
|
||||
// workspaces, different manifest.
|
||||
// workspaces, different manifest. An npm package does the same with
|
||||
// `"acme-shared": "file:../common"` and no root `workspaces` (#2456).
|
||||
const { ohpm, npm } = collectPathDeps(projectRoot);
|
||||
const entryByName = new Map<string, string>();
|
||||
for (const [name, dir] of collectOhpmFileDeps(projectRoot)) {
|
||||
for (const [name, dir] of ohpm) {
|
||||
if (byName.has(name)) continue;
|
||||
byName.set(name, dir);
|
||||
const entry = readOhpmMain(projectRoot, dir);
|
||||
if (entry) entryByName.set(name, entry);
|
||||
}
|
||||
for (const [name, dir] of npm.get('') ?? []) {
|
||||
if (!byName.has(name)) byName.set(name, dir);
|
||||
}
|
||||
npm.delete('');
|
||||
|
||||
if (byName.size === 0) return null;
|
||||
if (byName.size === 0 && npm.size === 0) return null;
|
||||
|
||||
logDebug('workspace packages loaded', { count: byName.size });
|
||||
return { byName, entryByName: entryByName.size > 0 ? entryByName : undefined };
|
||||
// What each npm-style package's manifest declares for its importers.
|
||||
const entriesByDir = new Map<string, PackageEntries>();
|
||||
const packageDirs = new Set(byName.values());
|
||||
for (const names of npm.values()) for (const dir of names.values()) packageDirs.add(dir);
|
||||
for (const dir of packageDirs) {
|
||||
const entries = readPackageEntries(projectRoot, dir);
|
||||
if (entries) entriesByDir.set(dir, entries);
|
||||
}
|
||||
|
||||
logDebug('workspace packages loaded', { count: byName.size, scoped: npm.size, entries: entriesByDir.size });
|
||||
return {
|
||||
byName,
|
||||
entryByName: entryByName.size > 0 ? entryByName : undefined,
|
||||
entriesByDir: entriesByDir.size > 0 ? entriesByDir : undefined,
|
||||
scopedByName: npm.size > 0 ? npm : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Condition keys an `exports` target is read through first: the ones
|
||||
* TypeScript and the bundlers resolve by. Any other condition (`browser`,
|
||||
* `development`, a custom `@acme/source`) comes next, and `types`/`typings`
|
||||
* last — a declaration file is the package as tsc reads it, the
|
||||
* implementation is what its importers run.
|
||||
*/
|
||||
const RESOLVING_CONDITIONS = new Set(['import', 'require', 'node', 'module', 'module-sync', 'default']);
|
||||
const TYPE_CONDITIONS = new Set(['types', 'typings']);
|
||||
|
||||
/** An `exports` value's targets, best first: a string, a fallback array, or (nested) conditions, by key order within each tier. */
|
||||
function exportTargets(value: unknown): string[] {
|
||||
if (typeof value === 'string') return [value];
|
||||
if (Array.isArray(value)) return value.flatMap(exportTargets);
|
||||
if (!value || typeof value !== 'object') return [];
|
||||
const conditions = Object.entries(value as Record<string, unknown>);
|
||||
const tier = (key: string): number => (RESOLVING_CONDITIONS.has(key) ? 0 : TYPE_CONDITIONS.has(key) ? 2 : 1);
|
||||
return [0, 1, 2].flatMap((t) => conditions.filter(([key]) => tier(key) === t).flatMap(([, v]) => exportTargets(v)));
|
||||
}
|
||||
|
||||
/** The package.json fields an importer of the package root reaches without `exports`, best first. */
|
||||
const MAIN_FIELDS = ['source', 'module', 'main', 'types', 'typings'];
|
||||
|
||||
/** Read a package's declared entries (see PackageEntries); null when its manifest declares none. */
|
||||
function readPackageEntries(projectRoot: string, dirRel: string): PackageEntries | null {
|
||||
let pkg: Record<string, unknown> | null;
|
||||
try {
|
||||
pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, dirRel, 'package.json'), 'utf-8')) as Record<string, unknown> | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!pkg || typeof pkg !== 'object') return null;
|
||||
const inPackage = (target: string): string | null => packageTarget(projectRoot, dirRel, target);
|
||||
const out: PackageEntries = {};
|
||||
const exp = pkg.exports;
|
||||
if (exp !== undefined && exp !== null) {
|
||||
const exports = new Map<string, string[]>();
|
||||
const subpaths = typeof exp === 'object' && !Array.isArray(exp) && Object.keys(exp).some((key) => key.startsWith('.'));
|
||||
const entries: Array<[string, unknown]> = subpaths
|
||||
? Object.entries(exp as Record<string, unknown>).filter(([key]) => key.startsWith('.'))
|
||||
: [['.', exp]];
|
||||
for (const [key, value] of entries) {
|
||||
exports.set(key, [...new Set(exportTargets(value).map(inPackage).filter((t): t is string => t !== null))]);
|
||||
}
|
||||
out.exports = exports;
|
||||
}
|
||||
const main = MAIN_FIELDS
|
||||
.map((field) => pkg![field])
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.map(inPackage)
|
||||
.filter((t): t is string => t !== null);
|
||||
if (main.length > 0) out.main = [...new Set(main)];
|
||||
return out.exports || out.main ? out : null;
|
||||
}
|
||||
|
||||
/** A package-relative target (`./src/index.ts`) → projectRoot-relative posix, or null if it leaves the package. */
|
||||
function packageTarget(projectRoot: string, dirRel: string, target: string): string | null {
|
||||
const trimmed = target.trim();
|
||||
if (!trimmed) return null;
|
||||
const packageDir = path.resolve(projectRoot, dirRel);
|
||||
const abs = path.resolve(packageDir, trimmed);
|
||||
const inside = path.relative(packageDir, abs);
|
||||
if (inside.startsWith('..') || path.isAbsolute(inside)) return null;
|
||||
return path.relative(projectRoot, abs).replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,11 +269,18 @@ function readOhpmMain(projectRoot: string, dirRel: string): string | null {
|
||||
* cross-module link. Registry dependencies (`@ohos/axios: "^2.0.0"`) don't
|
||||
* use `file:` and are ignored, staying external.
|
||||
*
|
||||
* The same walk reads every package.json's `file:`, `link:` and `portal:`
|
||||
* dependencies (#2456), by the directory of the manifest that declares them
|
||||
* (`''` for the root): npm installs a package's dependencies for that package,
|
||||
* so the files under it are the ones that see them. Only a directory of the
|
||||
* project counts, other than the root and nothing under `node_modules` — a
|
||||
* tarball or a path into installed packages maps nothing.
|
||||
*
|
||||
* The walk is bounded (depth + directory budget) and prunes build/dependency
|
||||
* dirs, so non-ArkTS projects pay one readdir at the root and nothing else
|
||||
* (they have no oh-package.json5 anywhere shallow).
|
||||
* dirs; a manifest is parsed only where the walk finds one.
|
||||
*/
|
||||
const OHPM_MANIFEST = 'oh-package.json5';
|
||||
const NPM_MANIFEST = 'package.json';
|
||||
const OHPM_WALK_MAX_DEPTH = 6;
|
||||
const OHPM_WALK_DIR_BUDGET = 8000;
|
||||
const OHPM_SKIP_DIRS = new Set([
|
||||
@@ -136,9 +288,10 @@ const OHPM_SKIP_DIRS = new Set([
|
||||
'build', 'dist', 'out', 'oh-package-lock.json5',
|
||||
]);
|
||||
|
||||
function collectOhpmFileDeps(projectRoot: string): Map<string, string> {
|
||||
const byName = new Map<string, string>();
|
||||
function collectPathDeps(projectRoot: string): { ohpm: Map<string, string>; npm: Map<string, Map<string, string>> } {
|
||||
const ohpm = new Map<string, string>();
|
||||
const ambiguous = new Set<string>();
|
||||
const npm = new Map<string, Map<string, string>>();
|
||||
|
||||
const queue: Array<{ rel: string; depth: number }> = [{ rel: '', depth: 0 }];
|
||||
let visited = 0;
|
||||
@@ -161,6 +314,18 @@ function collectOhpmFileDeps(projectRoot: string): Map<string, string> {
|
||||
queue.push({ rel: rel ? `${rel}/${e.name}` : e.name, depth: depth + 1 });
|
||||
continue;
|
||||
}
|
||||
if (e.name === NPM_MANIFEST) {
|
||||
const deps = new Map<string, string>();
|
||||
for (const [name, target] of readNpmPathDeps(path.join(abs, e.name))) {
|
||||
const targetAbs = path.resolve(abs, target);
|
||||
const targetRel = path.relative(projectRoot, targetAbs).replace(/\\/g, '/');
|
||||
if (targetRel === '' || targetRel.startsWith('..') || path.isAbsolute(targetRel)) continue;
|
||||
if (/(?:^|\/)node_modules(?:\/|$)/.test(targetRel) || !isDirectory(targetAbs)) continue;
|
||||
deps.set(name, targetRel);
|
||||
}
|
||||
if (deps.size > 0) npm.set(rel, deps);
|
||||
continue;
|
||||
}
|
||||
if (e.name !== OHPM_MANIFEST) continue;
|
||||
|
||||
const deps = readOhpmFileDeps(path.join(abs, e.name));
|
||||
@@ -168,18 +333,47 @@ function collectOhpmFileDeps(projectRoot: string): Map<string, string> {
|
||||
const targetAbs = path.resolve(abs, target);
|
||||
const targetRel = path.relative(projectRoot, targetAbs).replace(/\\/g, '/');
|
||||
if (targetRel.startsWith('..')) continue; // escapes the project
|
||||
const existing = byName.get(name);
|
||||
const existing = ohpm.get(name);
|
||||
if (existing === undefined) {
|
||||
if (!ambiguous.has(name)) byName.set(name, targetRel);
|
||||
if (!ambiguous.has(name)) ohpm.set(name, targetRel);
|
||||
} else if (existing !== targetRel) {
|
||||
byName.delete(name);
|
||||
ohpm.delete(name);
|
||||
ambiguous.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return byName;
|
||||
return { ohpm, npm };
|
||||
}
|
||||
|
||||
function isDirectory(abs: string): boolean {
|
||||
try {
|
||||
return fs.statSync(abs).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** A package.json's dependencies on a path (`file:`, `link:`, `portal:`), from every dependency field → [name, target]. */
|
||||
function readNpmPathDeps(manifestAbs: string): Array<[string, string]> {
|
||||
let pkg: Record<string, unknown> | null;
|
||||
try {
|
||||
pkg = JSON.parse(fs.readFileSync(manifestAbs, 'utf-8')) as Record<string, unknown> | null;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!pkg || typeof pkg !== 'object') return [];
|
||||
const out: Array<[string, string]> = [];
|
||||
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
|
||||
const deps = pkg[field];
|
||||
if (!deps || typeof deps !== 'object') continue;
|
||||
for (const [name, value] of Object.entries(deps as Record<string, unknown>)) {
|
||||
const m = typeof value === 'string' ? /^(?:file|link|portal):(.+)$/.exec(value.trim()) : null;
|
||||
if (m && m[1]!.trim()) out.push([name, m[1]!.trim()]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Parse one oh-package.json5's dependencies → [name, file-target] pairs. */
|
||||
@@ -208,32 +402,145 @@ function readOhpmFileDeps(manifestAbs: string): Array<[string, string]> {
|
||||
* 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.
|
||||
* name `@scope/ui` → its directory, or the ohpm entry its manifest names.
|
||||
* `fromFile`, the importing file, sees the path dependencies the manifests
|
||||
* above it declare. Returns `null` when no package name matches.
|
||||
*/
|
||||
export function resolveWorkspaceImport(
|
||||
importPath: string,
|
||||
ws: WorkspacePackages
|
||||
ws: WorkspacePackages,
|
||||
fromFile?: string
|
||||
): 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'
|
||||
const hit = matchPackage(importPath, ws, fromFile);
|
||||
if (!hit) return null;
|
||||
const subpath = importPath.slice(hit.name.length); // '' or '/widgets'
|
||||
// A bare member import resolves straight to the member's declared entry
|
||||
// file when the manifest names one (ohpm `main`) — the caller's exact-path
|
||||
// check hits it without extension/index guessing.
|
||||
if (!subpath) {
|
||||
const entry = ws.entryByName?.get(bestName);
|
||||
if (entry) return entry;
|
||||
// file when the ohpm manifest names one — the caller's exact-path check
|
||||
// hits it without extension/index guessing.
|
||||
const ohpm = subpath ? undefined : ws.entryByName?.get(hit.name);
|
||||
return ohpm ?? (hit.dir + subpath).replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an import of a package of the project lands (see
|
||||
* WorkspaceImportTargets): what its manifest declares for the import, and
|
||||
* the directory form. Null when no package name matches.
|
||||
*/
|
||||
export function workspaceImportTargets(
|
||||
importPath: string,
|
||||
ws: WorkspacePackages,
|
||||
fromFile?: string
|
||||
): WorkspaceImportTargets | null {
|
||||
const hit = matchPackage(importPath, ws, fromFile);
|
||||
if (!hit) return null;
|
||||
const subpath = importPath.slice(hit.name.length); // '' or '/widgets'
|
||||
const directory = (hit.dir + subpath).replace(/\/{2,}/g, '/');
|
||||
const ohpm = subpath ? undefined : ws.entryByName?.get(hit.name);
|
||||
const declared = ohpm ? [ohpm] : declaredEntries(ws.entriesByDir?.get(hit.dir), subpath);
|
||||
return { declared, directory, packageDir: hit.dir };
|
||||
}
|
||||
|
||||
/**
|
||||
* The package an import names: a path dependency of the nearest manifest
|
||||
* above `fromFile` that declares a matching name, else a workspace member or
|
||||
* a root path dependency. The longest name wins, so `@scope/ui/core` prefers
|
||||
* a `@scope/ui/core` package over a `@scope/ui` one when both exist.
|
||||
*/
|
||||
function matchPackage(importPath: string, ws: WorkspacePackages, fromFile?: string): { name: string; dir: string } | null {
|
||||
const scoped = scopedPackage(importPath, ws, fromFile);
|
||||
if (scoped) return scoped;
|
||||
const name = longestName(importPath, ws.byName.keys());
|
||||
return name === null ? null : { name, dir: ws.byName.get(name)! };
|
||||
}
|
||||
|
||||
/** The path dependency the nearest declaring manifest above `fromFile` maps `importPath` to. */
|
||||
function scopedPackage(importPath: string, ws: WorkspacePackages, fromFile?: string): { name: string; dir: string } | null {
|
||||
if (!fromFile || !ws.scopedByName) return null;
|
||||
let dir = fromFile.replace(/\\/g, '/');
|
||||
while (dir !== '') {
|
||||
const slash = dir.lastIndexOf('/');
|
||||
dir = slash < 0 ? '' : dir.slice(0, slash);
|
||||
const names = ws.scopedByName.get(dir);
|
||||
if (!names) continue;
|
||||
const name = longestName(importPath, names.keys());
|
||||
if (name !== null) return { name, dir: names.get(name)! };
|
||||
}
|
||||
return (dir + subpath).replace(/\/{2,}/g, '/');
|
||||
return null;
|
||||
}
|
||||
|
||||
function longestName(importPath: string, names: Iterable<string>): string | null {
|
||||
let best: string | null = null;
|
||||
for (const name of names) {
|
||||
if ((importPath === name || importPath.startsWith(name + '/')) && (best === null || name.length > best.length)) best = name;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* The targets a package's manifest declares for `subpath` (`''` or
|
||||
* `/widgets`), best first: its `exports` entry, then, for the package root,
|
||||
* its `main`-style fields.
|
||||
*/
|
||||
function declaredEntries(entries: PackageEntries | undefined, subpath: string): string[] {
|
||||
if (!entries) return [];
|
||||
const exported = entries.exports ? exportedTargets(entries.exports, '.' + subpath) : [];
|
||||
return subpath ? exported : [...new Set([...exported, ...(entries.main ?? [])])];
|
||||
}
|
||||
|
||||
/** `exports`' targets for `key`: the exact key's, else the `*` pattern's with the longest prefix, its `*`s filled in. */
|
||||
function exportedTargets(exports: Map<string, string[]>, key: string): string[] {
|
||||
const exact = exports.get(key);
|
||||
if (exact) return exact;
|
||||
let best: { prefix: string; match: string; targets: string[] } | null = null;
|
||||
for (const [pattern, targets] of exports) {
|
||||
const star = pattern.indexOf('*');
|
||||
if (star < 0 || pattern.indexOf('*', star + 1) >= 0) continue;
|
||||
const prefix = pattern.slice(0, star);
|
||||
const suffix = pattern.slice(star + 1);
|
||||
if (key.length < prefix.length + suffix.length || !key.startsWith(prefix) || !key.endsWith(suffix)) continue;
|
||||
if (best && prefix.length <= best.prefix.length) continue;
|
||||
best = { prefix, match: key.slice(prefix.length, key.length - suffix.length), targets };
|
||||
}
|
||||
return best ? best.targets.map((t) => t.split('*').join(best!.match)) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace map as a stable string: every name, scope, directory and
|
||||
* declared entry an import can be resolved by. Two loads that agree on it
|
||||
* resolve every package import the same way; sync compares it with the one
|
||||
* the index was built with to see a package.json edit (#2456, #2502).
|
||||
*/
|
||||
export function workspaceFingerprint(ws: WorkspacePackages | null): string {
|
||||
if (!ws) return 'none';
|
||||
const sorted = <V>(map: Map<string, V> | undefined): Array<[string, V]> =>
|
||||
map ? [...map.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) : [];
|
||||
const canonical = JSON.stringify({
|
||||
byName: sorted(ws.byName),
|
||||
entryByName: sorted(ws.entryByName),
|
||||
scoped: sorted(ws.scopedByName).map(([dir, names]) => [dir, sorted(names)]),
|
||||
entries: sorted(ws.entriesByDir).map(([dir, e]) => [dir, sorted(e.exports), e.main ?? []]),
|
||||
});
|
||||
return createHash('sha256').update(canonical).digest('hex');
|
||||
}
|
||||
|
||||
/** Each map's package folders, outermost first. */
|
||||
const PACKAGE_DIRS = new WeakMap<WorkspacePackages, string[]>();
|
||||
|
||||
/**
|
||||
* The folder of the package `file` is in — a workspace member, or a path
|
||||
* dependency's target — the outermost when packages nest, or null. Every
|
||||
* import of a package by its name lands in its folder, inner packages'
|
||||
* files included.
|
||||
*/
|
||||
export function workspacePackageDirOf(ws: WorkspacePackages, file: string): string | null {
|
||||
let dirs = PACKAGE_DIRS.get(ws);
|
||||
if (!dirs) {
|
||||
const all = new Set(ws.byName.values());
|
||||
for (const names of ws.scopedByName?.values() ?? []) for (const dir of names.values()) all.add(dir);
|
||||
dirs = [...all].filter((dir) => dir !== '').sort((a, b) => a.length - b.length);
|
||||
PACKAGE_DIRS.set(ws, dirs);
|
||||
}
|
||||
return dirs.find((dir) => file.startsWith(dir + '/')) ?? null;
|
||||
}
|
||||
|
||||
/** Read workspace glob patterns from package.json + pnpm-workspace.yaml. */
|
||||
|
||||
Reference in New Issue
Block a user